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:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-03-16 21:18:33 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-03-16 21:18:33 +0300
commitf64a639bcfa1fc2bc89ca7db268f594306edfd7c (patch)
treea2c3c2ebcc3b45e596949db485d6ed18ffaacfa1 /spec
parentbfbc3e0d6583ea1a91f627528bedc3d65ba4b10f (diff)
Add latest changes from gitlab-org/gitlab@13-10-stable-eev13.10.0-rc40
Diffstat (limited to 'spec')
-rw-r--r--spec/benchmarks/banzai_benchmark.rb2
-rw-r--r--spec/bin/feature_flag_spec.rb11
-rw-r--r--spec/controllers/admin/application_settings_controller_spec.rb4
-rw-r--r--spec/controllers/admin/usage_trends_controller_spec.rb (renamed from spec/controllers/admin/instance_statistics_controller_spec.rb)2
-rw-r--r--spec/controllers/concerns/spammable_actions_spec.rb33
-rw-r--r--spec/controllers/explore/projects_controller_spec.rb17
-rw-r--r--spec/controllers/groups/boards_controller_spec.rb6
-rw-r--r--spec/controllers/groups/clusters/applications_controller_spec.rb3
-rw-r--r--spec/controllers/groups/clusters_controller_spec.rb33
-rw-r--r--spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb14
-rw-r--r--spec/controllers/groups_controller_spec.rb28
-rw-r--r--spec/controllers/help_controller_spec.rb27
-rw-r--r--spec/controllers/import/bulk_imports_controller_spec.rb31
-rw-r--r--spec/controllers/notification_settings_controller_spec.rb202
-rw-r--r--spec/controllers/projects/blob_controller_spec.rb72
-rw-r--r--spec/controllers/projects/boards_controller_spec.rb8
-rw-r--r--spec/controllers/projects/branches_controller_spec.rb8
-rw-r--r--spec/controllers/projects/ci/daily_build_group_report_results_controller_spec.rb52
-rw-r--r--spec/controllers/projects/ci/pipeline_editor_controller_spec.rb13
-rw-r--r--spec/controllers/projects/commit_controller_spec.rb26
-rw-r--r--spec/controllers/projects/compare_controller_spec.rb243
-rw-r--r--spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb15
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb21
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb15
-rw-r--r--spec/controllers/projects/notes_controller_spec.rb50
-rw-r--r--spec/controllers/projects/security/configuration_controller_spec.rb36
-rw-r--r--spec/controllers/projects/snippets_controller_spec.rb6
-rw-r--r--spec/controllers/projects/templates_controller_spec.rb27
-rw-r--r--spec/controllers/projects/web_ide_schemas_controller_spec.rb4
-rw-r--r--spec/controllers/projects_controller_spec.rb33
-rw-r--r--spec/controllers/repositories/git_http_controller_spec.rb19
-rw-r--r--spec/controllers/root_controller_spec.rb22
-rw-r--r--spec/controllers/search_controller_spec.rb40
-rw-r--r--spec/controllers/snippets/notes_controller_spec.rb16
-rw-r--r--spec/db/schema_spec.rb10
-rw-r--r--spec/deprecation_toolkit_env.rb1
-rw-r--r--spec/experiments/application_experiment/cache_spec.rb54
-rw-r--r--spec/experiments/application_experiment_spec.rb144
-rw-r--r--spec/experiments/members/invite_email_experiment_spec.rb41
-rw-r--r--spec/factories/alert_management/alerts.rb6
-rw-r--r--spec/factories/analytics/usage_trends/measurement.rb (renamed from spec/factories/analytics/instance_statistics/measurement.rb)2
-rw-r--r--spec/factories/bulk_import/trackers.rb1
-rw-r--r--spec/factories/ci/build_report_results.rb29
-rw-r--r--spec/factories/ci/build_trace_chunks.rb2
-rw-r--r--spec/factories/ci/builds.rb2
-rw-r--r--spec/factories/ci/pipelines.rb8
-rw-r--r--spec/factories/clusters/agent_tokens.rb2
-rw-r--r--spec/factories/custom_emoji.rb1
-rw-r--r--spec/factories/dependency_proxy.rb3
-rw-r--r--spec/factories/design_management/versions.rb5
-rw-r--r--spec/factories/environments.rb18
-rw-r--r--spec/factories/gitlab/database/background_migration/batched_jobs.rb12
-rw-r--r--spec/factories/gitlab/database/background_migration/batched_migrations.rb13
-rw-r--r--spec/factories/go_module_commits.rb10
-rw-r--r--spec/factories/go_module_versions.rb40
-rw-r--r--spec/factories/go_modules.rb2
-rw-r--r--spec/factories/groups.rb32
-rw-r--r--spec/factories/iterations.rb66
-rw-r--r--spec/factories/namespaces.rb40
-rw-r--r--spec/factories/packages.rb9
-rw-r--r--spec/factories/project_repository_storage_moves.rb12
-rw-r--r--spec/factories/projects.rb2
-rw-r--r--spec/factories/prometheus_alert_event.rb5
-rw-r--r--spec/factories/self_managed_prometheus_alert_event.rb11
-rw-r--r--spec/factories/snippet_repository_storage_moves.rb12
-rw-r--r--spec/factories_spec.rb57
-rw-r--r--spec/features/admin/admin_projects_spec.rb97
-rw-r--r--spec/features/admin/admin_settings_spec.rb15
-rw-r--r--spec/features/admin/dashboard_spec.rb10
-rw-r--r--spec/features/alerts_settings/user_views_alerts_settings_spec.rb7
-rw-r--r--spec/features/boards/boards_spec.rb20
-rw-r--r--spec/features/boards/sidebar_spec.rb30
-rw-r--r--spec/features/boards/user_adds_lists_to_board_spec.rb92
-rw-r--r--spec/features/commit_spec.rb16
-rw-r--r--spec/features/dashboard/group_spec.rb2
-rw-r--r--spec/features/dashboard/projects_spec.rb8
-rw-r--r--spec/features/discussion_comments/commit_spec.rb2
-rw-r--r--spec/features/discussion_comments/issue_spec.rb4
-rw-r--r--spec/features/discussion_comments/merge_request_spec.rb3
-rw-r--r--spec/features/discussion_comments/snippets_spec.rb4
-rw-r--r--spec/features/expand_collapse_diffs_spec.rb10
-rw-r--r--spec/features/groups/container_registry_spec.rb8
-rw-r--r--spec/features/groups/members/list_members_spec.rb42
-rw-r--r--spec/features/groups/members/manage_members_spec.rb12
-rw-r--r--spec/features/groups/settings/user_searches_in_settings_spec.rb36
-rw-r--r--spec/features/groups/show_spec.rb5
-rw-r--r--spec/features/groups_spec.rb22
-rw-r--r--spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb2
-rw-r--r--spec/features/issues/csv_spec.rb4
-rw-r--r--spec/features/issues/gfm_autocomplete_spec.rb682
-rw-r--r--spec/features/issues/issue_state_spec.rb26
-rw-r--r--spec/features/issues/service_desk_spec.rb16
-rw-r--r--spec/features/issues/user_interacts_with_awards_spec.rb8
-rw-r--r--spec/features/labels_hierarchy_spec.rb2
-rw-r--r--spec/features/markdown/markdown_spec.rb2
-rw-r--r--spec/features/markdown/math_spec.rb16
-rw-r--r--spec/features/merge_request/batch_comments_spec.rb2
-rw-r--r--spec/features/merge_request/user_closes_reopens_merge_request_state_spec.rb33
-rw-r--r--spec/features/merge_request/user_posts_diff_notes_spec.rb2
-rw-r--r--spec/features/merge_request/user_posts_notes_spec.rb5
-rw-r--r--spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb10
-rw-r--r--spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb6
-rw-r--r--spec/features/merge_request/user_sees_discussions_spec.rb2
-rw-r--r--spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb1
-rw-r--r--spec/features/merge_request/user_sees_merge_widget_spec.rb2
-rw-r--r--spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb217
-rw-r--r--spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb2
-rw-r--r--spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb8
-rw-r--r--spec/features/merge_request/user_suggests_changes_on_diff_spec.rb2
-rw-r--r--spec/features/merge_request/user_toggles_whitespace_changes_spec.rb4
-rw-r--r--spec/features/merge_requests/user_exports_as_csv_spec.rb6
-rw-r--r--spec/features/participants_autocomplete_spec.rb1
-rw-r--r--spec/features/profiles/personal_access_tokens_spec.rb6
-rw-r--r--spec/features/profiles/user_visits_notifications_tab_spec.rb9
-rw-r--r--spec/features/project_group_variables_spec.rb2
-rw-r--r--spec/features/project_variables_spec.rb3
-rw-r--r--spec/features/projects/active_tabs_spec.rb6
-rw-r--r--spec/features/projects/ci/lint_spec.rb2
-rw-r--r--spec/features/projects/commit/mini_pipeline_graph_spec.rb52
-rw-r--r--spec/features/projects/container_registry_spec.rb8
-rw-r--r--spec/features/projects/environments/environments_spec.rb80
-rw-r--r--spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb2
-rw-r--r--spec/features/projects/files/gitlab_ci_syntax_yml_dropdown_spec.rb54
-rw-r--r--spec/features/projects/fork_spec.rb270
-rw-r--r--spec/features/projects/members/anonymous_user_sees_members_spec.rb22
-rw-r--r--spec/features/projects/members/group_members_spec.rb244
-rw-r--r--spec/features/projects/members/groups_with_access_list_spec.rb176
-rw-r--r--spec/features/projects/members/invite_group_spec.rb77
-rw-r--r--spec/features/projects/members/list_spec.rb254
-rw-r--r--spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb110
-rw-r--r--spec/features/projects/members/sorting_spec.rb186
-rw-r--r--spec/features/projects/members/tabs_spec.rb72
-rw-r--r--spec/features/projects/merge_request_button_spec.rb64
-rw-r--r--spec/features/projects/new_project_spec.rb48
-rw-r--r--spec/features/projects/pages/user_edits_settings_spec.rb4
-rw-r--r--spec/features/projects/pipelines/pipelines_spec.rb90
-rw-r--r--spec/features/projects/releases/user_creates_release_spec.rb31
-rw-r--r--spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb2
-rw-r--r--spec/features/projects/settings/operations_settings_spec.rb8
-rw-r--r--spec/features/projects/settings/service_desk_setting_spec.rb45
-rw-r--r--spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb8
-rw-r--r--spec/features/projects/settings/user_manages_project_members_spec.rb129
-rw-r--r--spec/features/projects/settings/user_searches_in_settings_spec.rb42
-rw-r--r--spec/features/projects/show/user_manages_notifications_spec.rb34
-rw-r--r--spec/features/projects/show/user_uploads_files_spec.rb20
-rw-r--r--spec/features/projects/user_sees_sidebar_spec.rb4
-rw-r--r--spec/features/projects/user_uses_shortcuts_spec.rb4
-rw-r--r--spec/features/projects_spec.rb2
-rw-r--r--spec/features/security/group/internal_access_spec.rb35
-rw-r--r--spec/features/security/group/private_access_spec.rb42
-rw-r--r--spec/features/security/group/public_access_spec.rb35
-rw-r--r--spec/features/sentry_js_spec.rb2
-rw-r--r--spec/features/task_lists_spec.rb8
-rw-r--r--spec/features/uploads/user_uploads_avatar_to_profile_spec.rb3
-rw-r--r--spec/features/user_can_display_performance_bar_spec.rb24
-rw-r--r--spec/finders/admin/plans_finder_spec.rb54
-rw-r--r--spec/finders/boards/boards_finder_spec.rb (renamed from spec/services/boards/list_service_spec.rb)2
-rw-r--r--spec/finders/ci/daily_build_group_report_results_finder_spec.rb91
-rw-r--r--spec/finders/ci/testing/daily_build_group_report_results_finder_spec.rb99
-rw-r--r--spec/finders/issues_finder_spec.rb59
-rw-r--r--spec/finders/merge_request_target_project_finder_spec.rb11
-rw-r--r--spec/finders/merge_requests/oldest_per_commit_finder_spec.rb61
-rw-r--r--spec/finders/merge_requests_finder_spec.rb55
-rw-r--r--spec/finders/namespaces/projects_finder_spec.rb83
-rw-r--r--spec/finders/packages/group_packages_finder_spec.rb2
-rw-r--r--spec/finders/packages/npm/package_finder_spec.rb130
-rw-r--r--spec/finders/packages/package_finder_spec.rb2
-rw-r--r--spec/finders/packages/packages_finder_spec.rb2
-rw-r--r--spec/finders/projects/groups_finder_spec.rb103
-rw-r--r--spec/finders/repositories/changelog_commits_finder_spec.rb93
-rw-r--r--spec/finders/repositories/commits_with_trailer_finder_spec.rb38
-rw-r--r--spec/finders/repositories/previous_tag_finder_spec.rb8
-rw-r--r--spec/finders/security/license_compliance_jobs_finder_spec.rb5
-rw-r--r--spec/finders/users_finder_spec.rb20
-rw-r--r--spec/fixtures/api/schemas/entities/test_suite_comparer.json9
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/packages/composer/index.json5
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/pipeline.json5
-rw-r--r--spec/fixtures/dependency_proxy/manifest44
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/project.json3
-rw-r--r--spec/fixtures/security_reports/master/gl-sast-report.json16
-rw-r--r--spec/frontend/.eslintrc.yml1
-rw-r--r--spec/frontend/__helpers__/fake_date/fixtures.js4
-rw-r--r--spec/frontend/__helpers__/fake_date/index.js1
-rw-r--r--spec/frontend/__helpers__/vue_test_utils_helper.js13
-rw-r--r--spec/frontend/__helpers__/vue_test_utils_helper_spec.js17
-rw-r--r--spec/frontend/__mocks__/@gitlab/ui.js5
-rw-r--r--spec/frontend/access_tokens/components/projects_field_spec.js131
-rw-r--r--spec/frontend/access_tokens/components/projects_token_selector_spec.js269
-rw-r--r--spec/frontend/access_tokens/index_spec.js74
-rw-r--r--spec/frontend/admin/users/tabs_spec.js37
-rw-r--r--spec/frontend/alert_management/components/alert_management_table_spec.js82
-rw-r--r--spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap700
-rw-r--r--spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js28
-rw-r--r--spec/frontend/alerts_settings/components/alerts_settings_form_spec.js227
-rw-r--r--spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js231
-rw-r--r--spec/frontend/alerts_settings/components/mocks/apollo_mock.js17
-rw-r--r--spec/frontend/alerts_settings/mocks/alert_fields.json (renamed from spec/frontend/alerts_settings/mocks/alertFields.json)0
-rw-r--r--spec/frontend/alerts_settings/mocks/parsed_mapping.json122
-rw-r--r--spec/frontend/alerts_settings/utils/mapping_transformations_spec.js34
-rw-r--r--spec/frontend/analytics/instance_statistics/components/app_spec.js45
-rw-r--r--spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js215
-rw-r--r--spec/frontend/analytics/usage_trends/apollo_mock_data.js (renamed from spec/frontend/analytics/instance_statistics/apollo_mock_data.js)0
-rw-r--r--spec/frontend/analytics/usage_trends/components/__snapshots__/usage_trends_count_chart_spec.js.snap (renamed from spec/frontend/analytics/instance_statistics/components/__snapshots__/instance_statistics_count_chart_spec.js.snap)4
-rw-r--r--spec/frontend/analytics/usage_trends/components/app_spec.js40
-rw-r--r--spec/frontend/analytics/usage_trends/components/instance_counts_spec.js (renamed from spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js)12
-rw-r--r--spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js (renamed from spec/frontend/analytics/instance_statistics/components/instance_statistics_count_chart_spec.js)10
-rw-r--r--spec/frontend/analytics/usage_trends/components/users_chart_spec.js (renamed from spec/frontend/analytics/instance_statistics/components/users_chart_spec.js)4
-rw-r--r--spec/frontend/analytics/usage_trends/mock_data.js (renamed from spec/frontend/analytics/instance_statistics/mock_data.js)2
-rw-r--r--spec/frontend/analytics/usage_trends/utils_spec.js (renamed from spec/frontend/analytics/instance_statistics/utils_spec.js)2
-rw-r--r--spec/frontend/api_spec.js48
-rw-r--r--spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap1
-rw-r--r--spec/frontend/authentication/u2f/authenticate_spec.js2
-rw-r--r--spec/frontend/authentication/u2f/register_spec.js2
-rw-r--r--spec/frontend/authentication/webauthn/authenticate_spec.js1
-rw-r--r--spec/frontend/authentication/webauthn/register_spec.js1
-rw-r--r--spec/frontend/awards_handler_spec.js15
-rw-r--r--spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js59
-rw-r--r--spec/frontend/behaviors/quick_submit_spec.js2
-rw-r--r--spec/frontend/behaviors/requires_input_spec.js1
-rw-r--r--spec/frontend/behaviors/shortcuts/keybindings_spec.js72
-rw-r--r--spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js2
-rw-r--r--spec/frontend/blob/blob_file_dropzone_spec.js1
-rw-r--r--spec/frontend/blob/sketch/index_spec.js2
-rw-r--r--spec/frontend/blob/viewer/index_spec.js8
-rw-r--r--spec/frontend/boards/board_card_inner_spec.js (renamed from spec/frontend/boards/issue_card_inner_spec.js)51
-rw-r--r--spec/frontend/boards/board_list_spec.js43
-rw-r--r--spec/frontend/boards/board_new_issue_deprecated_spec.js8
-rw-r--r--spec/frontend/boards/components/board_add_new_column_form_spec.js166
-rw-r--r--spec/frontend/boards/components/board_add_new_column_spec.js115
-rw-r--r--spec/frontend/boards/components/board_card_deprecated_spec.js219
-rw-r--r--spec/frontend/boards/components/board_card_layout_deprecated_spec.js2
-rw-r--r--spec/frontend/boards/components/board_card_layout_spec.js116
-rw-r--r--spec/frontend/boards/components/board_card_spec.js265
-rw-r--r--spec/frontend/boards/components/board_form_spec.js9
-rw-r--r--spec/frontend/boards/components/board_list_header_spec.js76
-rw-r--r--spec/frontend/boards/components/board_new_issue_spec.js3
-rw-r--r--spec/frontend/boards/components/filtered_search_spec.js65
-rw-r--r--spec/frontend/boards/components/item_count_spec.js (renamed from spec/frontend/boards/components/issue_count_spec.js)26
-rw-r--r--spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js8
-rw-r--r--spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js6
-rw-r--r--spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js4
-rw-r--r--spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js6
-rw-r--r--spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js8
-rw-r--r--spec/frontend/boards/components/sidebar/remove_issue_spec.js28
-rw-r--r--spec/frontend/boards/mock_data.js44
-rw-r--r--spec/frontend/boards/project_select_deprecated_spec.js1
-rw-r--r--spec/frontend/boards/project_select_spec.js64
-rw-r--r--spec/frontend/boards/stores/actions_spec.js206
-rw-r--r--spec/frontend/boards/stores/getters_spec.js71
-rw-r--r--spec/frontend/boards/stores/mutations_spec.js135
-rw-r--r--spec/frontend/bootstrap_linked_tabs_spec.js2
-rw-r--r--spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js119
-rw-r--r--spec/frontend/captcha/wait_for_captcha_to_be_solved_spec.js56
-rw-r--r--spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js3
-rw-r--r--spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js2
-rw-r--r--spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js27
-rw-r--r--spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js6
-rw-r--r--spec/frontend/clusters/forms/components/integration_form_spec.js6
-rw-r--r--spec/frontend/clusters_list/components/clusters_spec.js2
-rw-r--r--spec/frontend/clusters_list/store/actions_spec.js2
-rw-r--r--spec/frontend/collapsed_sidebar_todo_spec.js3
-rw-r--r--spec/frontend/commit/pipelines/pipelines_spec.js5
-rw-r--r--spec/frontend/create_item_dropdown_spec.js2
-rw-r--r--spec/frontend/deprecated_jquery_dropdown_spec.js2
-rw-r--r--spec/frontend/design_management/components/delete_button_spec.js4
-rw-r--r--spec/frontend/design_management/components/design_notes/design_discussion_spec.js10
-rw-r--r--spec/frontend/design_management/components/list/item_spec.js2
-rw-r--r--spec/frontend/design_management/components/toolbar/index_spec.js4
-rw-r--r--spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap4
-rw-r--r--spec/frontend/design_management/pages/index_spec.js6
-rw-r--r--spec/frontend/diffs/components/app_spec.js22
-rw-r--r--spec/frontend/diffs/components/inline_diff_table_row_spec.js13
-rw-r--r--spec/frontend/diffs/components/settings_dropdown_spec.js160
-rw-r--r--spec/frontend/diffs/mock_data/diff_with_commit.js2
-rw-r--r--spec/frontend/diffs/store/utils_spec.js6
-rw-r--r--spec/frontend/diffs/utils/file_reviews_spec.js10
-rw-r--r--spec/frontend/diffs/utils/preferences_spec.js35
-rw-r--r--spec/frontend/emoji/components/category_spec.js49
-rw-r--r--spec/frontend/emoji/components/emoji_group_spec.js56
-rw-r--r--spec/frontend/emoji/components/emoji_list_spec.js73
-rw-r--r--spec/frontend/environments/environments_app_spec.js12
-rw-r--r--spec/frontend/environments/folder/environments_folder_view_spec.js14
-rw-r--r--spec/frontend/experimentation/experiment_tracking_spec.js80
-rw-r--r--spec/frontend/experimentation/utils_spec.js38
-rw-r--r--spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js23
-rw-r--r--spec/frontend/feature_flags/components/edit_feature_flag_spec.js7
-rw-r--r--spec/frontend/feature_flags/components/feature_flags_table_spec.js5
-rw-r--r--spec/frontend/filtered_search/dropdown_user_spec.js1
-rw-r--r--spec/frontend/filtered_search/dropdown_utils_spec.js1
-rw-r--r--spec/frontend/filtered_search/visual_token_value_spec.js2
-rw-r--r--spec/frontend/fixtures/issues.rb2
-rw-r--r--spec/frontend/fixtures/pipelines.rb32
-rw-r--r--spec/frontend/fixtures/projects.rb33
-rw-r--r--spec/frontend/fixtures/test_report.rb29
-rw-r--r--spec/frontend/gfm_auto_complete_spec.js132
-rw-r--r--spec/frontend/gl_field_errors_spec.js2
-rw-r--r--spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap17
-rw-r--r--spec/frontend/grafana_integration/components/grafana_integration_spec.js2
-rw-r--r--spec/frontend/graphql_shared/utils_spec.js34
-rw-r--r--spec/frontend/groups/components/group_item_spec.js5
-rw-r--r--spec/frontend/header_spec.js1
-rw-r--r--spec/frontend/ide/components/commit_sidebar/form_spec.js5
-rw-r--r--spec/frontend/ide/components/ide_spec.js3
-rw-r--r--spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap1
-rw-r--r--spec/frontend/ide/components/pipelines/list_spec.js1
-rw-r--r--spec/frontend/ide/components/repo_editor_spec.js1106
-rw-r--r--spec/frontend/ide/components/repo_tab_spec.js19
-rw-r--r--spec/frontend/ide/services/index_spec.js4
-rw-r--r--spec/frontend/ide/stores/getters_spec.js70
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_table_row_spec.js109
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_table_spec.js50
-rw-r--r--spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js90
-rw-r--r--spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js55
-rw-r--r--spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js21
-rw-r--r--spec/frontend/incidents/mocks/incidents.json2
-rw-r--r--spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap4
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_spec.js21
-rw-r--r--spec/frontend/integrations/integration_settings_form_spec.js1
-rw-r--r--spec/frontend/invite_members/components/group_select_spec.js90
-rw-r--r--spec/frontend/invite_members/components/invite_group_trigger_spec.js50
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js141
-rw-r--r--spec/frontend/invite_members/components/invite_members_trigger_spec.js31
-rw-r--r--spec/frontend/invite_members/components/members_token_select_spec.js2
-rw-r--r--spec/frontend/issuable/components/csv_export_modal_spec.js91
-rw-r--r--spec/frontend/issuable/components/csv_import_export_buttons_spec.js187
-rw-r--r--spec/frontend/issuable/components/csv_import_modal_spec.js86
-rw-r--r--spec/frontend/issuable_list/components/issuable_item_spec.js14
-rw-r--r--spec/frontend/issuable_list/mock_data.js4
-rw-r--r--spec/frontend/issuable_show/components/issuable_body_spec.js71
-rw-r--r--spec/frontend/issuable_show/components/issuable_description_spec.js32
-rw-r--r--spec/frontend/issuable_show/components/issuable_header_spec.js21
-rw-r--r--spec/frontend/issuable_show/components/issuable_show_root_spec.js22
-rw-r--r--spec/frontend/issuable_show/mock_data.js8
-rw-r--r--spec/frontend/issue_show/components/app_spec.js47
-rw-r--r--spec/frontend/issue_show/components/description_spec.js31
-rw-r--r--spec/frontend/issue_show/components/fields/description_template_spec.js39
-rw-r--r--spec/frontend/issue_show/components/form_spec.js12
-rw-r--r--spec/frontend/issue_spec.js121
-rw-r--r--spec/frontend/issues_list/components/issuable_spec.js35
-rw-r--r--spec/frontend/issues_list/components/issue_card_time_info_spec.js109
-rw-r--r--spec/frontend/issues_list/components/issues_list_app_spec.js98
-rw-r--r--spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js (renamed from spec/frontend/issues_list/components/jira_issues_list_root_spec.js)6
-rw-r--r--spec/frontend/jira_connect/components/app_spec.js106
-rw-r--r--spec/frontend/jira_connect/components/groups_list_item_spec.js12
-rw-r--r--spec/frontend/jira_connect/store/mutations_spec.js18
-rw-r--r--spec/frontend/jira_connect/utils_spec.js32
-rw-r--r--spec/frontend/jobs/components/job_sidebar_retry_button_spec.js2
-rw-r--r--spec/frontend/jobs/components/jobs_container_spec.js70
-rw-r--r--spec/frontend/lib/utils/datetime_utility_spec.js119
-rw-r--r--spec/frontend/lib/utils/experimentation_spec.js20
-rw-r--r--spec/frontend/lib/utils/number_utility_spec.js22
-rw-r--r--spec/frontend/lib/utils/select2_utils_spec.js100
-rw-r--r--spec/frontend/lib/utils/text_markdown_spec.js53
-rw-r--r--spec/frontend/lib/utils/unit_format/index_spec.js304
-rw-r--r--spec/frontend/lib/utils/url_utility_spec.js8
-rw-r--r--spec/frontend/line_highlighter_spec.js1
-rw-r--r--spec/frontend/locale/index_spec.js66
-rw-r--r--spec/frontend/members/components/avatars/user_avatar_spec.js26
-rw-r--r--spec/frontend/members/mock_data.js2
-rw-r--r--spec/frontend/members/utils_spec.js37
-rw-r--r--spec/frontend/merge_conflicts/store/actions_spec.js257
-rw-r--r--spec/frontend/merge_request_spec.js1
-rw-r--r--spec/frontend/merge_request_tabs_spec.js5
-rw-r--r--spec/frontend/mini_pipeline_graph_dropdown_spec.js2
-rw-r--r--spec/frontend/monitoring/components/dashboard_panel_builder_spec.js2
-rw-r--r--spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js2
-rw-r--r--spec/frontend/monitoring/requests/index_spec.js6
-rw-r--r--spec/frontend/new_branch_spec.js2
-rw-r--r--spec/frontend/notes/components/comment_form_spec.js134
-rw-r--r--spec/frontend/notes/components/diff_discussion_header_spec.js4
-rw-r--r--spec/frontend/notes/components/discussion_actions_spec.js2
-rw-r--r--spec/frontend/notes/components/discussion_reply_placeholder_spec.js14
-rw-r--r--spec/frontend/notes/components/note_actions_spec.js11
-rw-r--r--spec/frontend/notes/components/note_form_spec.js2
-rw-r--r--spec/frontend/notes/components/noteable_discussion_spec.js4
-rw-r--r--spec/frontend/notes/components/noteable_note_spec.js214
-rw-r--r--spec/frontend/notes/components/notes_app_spec.js4
-rw-r--r--spec/frontend/notes/stores/actions_spec.js66
-rw-r--r--spec/frontend/notes/stores/getters_spec.js2
-rw-r--r--spec/frontend/notifications/components/custom_notifications_modal_spec.js4
-rw-r--r--spec/frontend/notifications/components/notifications_dropdown_spec.js38
-rw-r--r--spec/frontend/oauth_remember_me_spec.js2
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap9
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap40
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap9
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap9
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap9
-rw-r--r--spec/frontend/packages/details/components/composer_installation_spec.js15
-rw-r--r--spec/frontend/packages/details/components/conan_installation_spec.js14
-rw-r--r--spec/frontend/packages/details/components/installation_title_spec.js58
-rw-r--r--spec/frontend/packages/details/components/maven_installation_spec.js120
-rw-r--r--spec/frontend/packages/details/components/npm_installation_spec.js14
-rw-r--r--spec/frontend/packages/details/components/nuget_installation_spec.js14
-rw-r--r--spec/frontend/packages/details/components/pypi_installation_spec.js14
-rw-r--r--spec/frontend/packages/details/store/getters_spec.js28
-rw-r--r--spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap4
-rw-r--r--spec/frontend/packages/shared/components/package_list_row_spec.js20
-rw-r--r--spec/frontend/packages/shared/utils_spec.js2
-rw-r--r--spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js2
-rw-r--r--spec/frontend/pages/admin/application_settings/account_and_limits_spec.js1
-rw-r--r--spec/frontend/pages/admin/users/new/index_spec.js2
-rw-r--r--spec/frontend/pages/dashboard/todos/index/todos_spec.js1
-rw-r--r--spec/frontend/pages/projects/forks/new/components/app_spec.js42
-rw-r--r--spec/frontend/pages/projects/forks/new/components/fork_form_spec.js275
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap16
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap569
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js45
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js75
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_info_card_spec.js57
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/mock_data.js42
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js2
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js1
-rw-r--r--spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js2
-rw-r--r--spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js2
-rw-r--r--spec/frontend/pages/shared/wikis/wiki_alert_spec.js40
-rw-r--r--spec/frontend/performance_bar/components/performance_bar_app_spec.js1
-rw-r--r--spec/frontend/performance_bar/index_spec.js1
-rw-r--r--spec/frontend/pipeline_editor/components/commit/commit_form_spec.js8
-rw-r--r--spec/frontend/pipeline_editor/components/commit/commit_section_spec.js65
-rw-r--r--spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js37
-rw-r--r--spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js150
-rw-r--r--spec/frontend/pipeline_editor/components/header/validation_segment_spec.js21
-rw-r--r--spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js79
-rw-r--r--spec/frontend/pipeline_editor/graphql/resolvers_spec.js18
-rw-r--r--spec/frontend/pipeline_editor/mock_data.js16
-rw-r--r--spec/frontend/pipeline_editor/pipeline_editor_app_spec.js118
-rw-r--r--spec/frontend/pipeline_new/components/pipeline_new_form_spec.js141
-rw-r--r--spec/frontend/pipeline_new/components/refs_dropdown_spec.js182
-rw-r--r--spec/frontend/pipeline_new/mock_data.js20
-rw-r--r--spec/frontend/pipelines/components/pipelines_list/pipeline_mini_graph_spec.js83
-rw-r--r--spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js210
-rw-r--r--spec/frontend/pipelines/empty_state_spec.js58
-rw-r--r--spec/frontend/pipelines/graph/graph_component_spec.js4
-rw-r--r--spec/frontend/pipelines/graph/graph_component_wrapper_spec.js53
-rw-r--r--spec/frontend/pipelines/graph/linked_pipelines_column_spec.js8
-rw-r--r--spec/frontend/pipelines/graph_shared/links_inner_spec.js149
-rw-r--r--spec/frontend/pipelines/graph_shared/links_layer_spec.js18
-rw-r--r--spec/frontend/pipelines/mock_data.js322
-rw-r--r--spec/frontend/pipelines/pipeline_triggerer_spec.js4
-rw-r--r--spec/frontend/pipelines/pipeline_url_spec.js34
-rw-r--r--spec/frontend/pipelines/pipelines_actions_spec.js10
-rw-r--r--spec/frontend/pipelines/pipelines_artifacts_spec.js17
-rw-r--r--spec/frontend/pipelines/pipelines_spec.js36
-rw-r--r--spec/frontend/pipelines/pipelines_table_row_spec.js35
-rw-r--r--spec/frontend/pipelines/pipelines_table_spec.js223
-rw-r--r--spec/frontend/pipelines/stage_spec.js297
-rw-r--r--spec/frontend/pipelines/time_ago_spec.js32
-rw-r--r--spec/frontend/pipelines_spec.js2
-rw-r--r--spec/frontend/profile/account/components/update_username_spec.js33
-rw-r--r--spec/frontend/profile/preferences/components/profile_preferences_spec.js147
-rw-r--r--spec/frontend/profile/preferences/mock_data.js12
-rw-r--r--spec/frontend/project_select_combo_button_spec.js2
-rw-r--r--spec/frontend/projects/commit/components/form_modal_spec.js20
-rw-r--r--spec/frontend/projects/commit/components/projects_dropdown_spec.js124
-rw-r--r--spec/frontend/projects/commit/mock_data.js1
-rw-r--r--spec/frontend/projects/commit/store/actions_spec.js41
-rw-r--r--spec/frontend/projects/commit/store/getters_spec.js17
-rw-r--r--spec/frontend/projects/commit/store/mutations_spec.js20
-rw-r--r--spec/frontend/projects/compare/components/app_legacy_spec.js116
-rw-r--r--spec/frontend/projects/compare/components/app_spec.js10
-rw-r--r--spec/frontend/projects/compare/components/repo_dropdown_spec.js98
-rw-r--r--spec/frontend/projects/compare/components/revision_card_spec.js49
-rw-r--r--spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js106
-rw-r--r--spec/frontend/projects/compare/components/revision_dropdown_spec.js18
-rw-r--r--spec/frontend/projects/details/upload_button_spec.js61
-rw-r--r--spec/frontend/projects/experiment_new_project_creation/components/new_project_push_tip_popover_spec.js75
-rw-r--r--spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js10
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js2
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js2
-rw-r--r--spec/frontend/projects/upload_file_experiment_tracking_spec.js43
-rw-r--r--spec/frontend/prometheus_metrics/custom_metrics_spec.js1
-rw-r--r--spec/frontend/prometheus_metrics/prometheus_metrics_spec.js1
-rw-r--r--spec/frontend/protected_branches/protected_branch_edit_spec.js88
-rw-r--r--spec/frontend/read_more_spec.js2
-rw-r--r--spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap70
-rw-r--r--spec/frontend/ref/components/ref_selector_spec.js211
-rw-r--r--spec/frontend/ref/stores/actions_spec.js22
-rw-r--r--spec/frontend/ref/stores/mutations_spec.js11
-rw-r--r--spec/frontend/registry/explorer/components/delete_button_spec.js1
-rw-r--r--spec/frontend/registry/explorer/components/details_page/details_header_spec.js51
-rw-r--r--spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js11
-rw-r--r--spec/frontend/registry/explorer/components/list_page/registry_header_spec.js37
-rw-r--r--spec/frontend/registry/explorer/pages/details_spec.js22
-rw-r--r--spec/frontend/registry/settings/components/expiration_toggle_spec.js2
-rw-r--r--spec/frontend/related_issues/components/related_issuable_input_spec.js117
-rw-r--r--spec/frontend/releases/components/tag_field_new_spec.js115
-rw-r--r--spec/frontend/reports/components/summary_row_spec.js24
-rw-r--r--spec/frontend/reports/components/test_issue_body_spec.js72
-rw-r--r--spec/frontend/reports/grouped_test_report/components/modal_spec.js (renamed from spec/frontend/reports/components/modal_spec.js)4
-rw-r--r--spec/frontend/reports/grouped_test_report/components/test_issue_body_spec.js97
-rw-r--r--spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js (renamed from spec/frontend/reports/components/grouped_test_reports_app_spec.js)53
-rw-r--r--spec/frontend/reports/grouped_test_report/store/actions_spec.js (renamed from spec/frontend/reports/store/actions_spec.js)6
-rw-r--r--spec/frontend/reports/grouped_test_report/store/mutations_spec.js (renamed from spec/frontend/reports/store/mutations_spec.js)18
-rw-r--r--spec/frontend/reports/grouped_test_report/store/utils_spec.js (renamed from spec/frontend/reports/store/utils_spec.js)2
-rw-r--r--spec/frontend/reports/mock_data/mock_data.js16
-rw-r--r--spec/frontend/repository/components/upload_blob_modal_spec.js203
-rw-r--r--spec/frontend/right_sidebar_spec.js1
-rw-r--r--spec/frontend/search/highlight_blob_search_result_spec.js2
-rw-r--r--spec/frontend/search_autocomplete_spec.js1
-rw-r--r--spec/frontend/security_configuration/configuration_table_spec.js42
-rw-r--r--spec/frontend/security_configuration/upgrade_spec.js19
-rw-r--r--spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap7
-rw-r--r--spec/frontend/self_monitor/components/self_monitor_form_spec.js10
-rw-r--r--spec/frontend/sentry/sentry_config_spec.js2
-rw-r--r--spec/frontend/settings_panels_spec.js2
-rw-r--r--spec/frontend/shared/popover_spec.js166
-rw-r--r--spec/frontend/shortcuts_spec.js2
-rw-r--r--spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap199
-rw-r--r--spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js71
-rw-r--r--spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js173
-rw-r--r--spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js159
-rw-r--r--spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js93
-rw-r--r--spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js18
-rw-r--r--spec/frontend/sidebar/confidential/__snapshots__/edit_form_spec.js.snap50
-rw-r--r--spec/frontend/sidebar/confidential/edit_form_buttons_spec.js146
-rw-r--r--spec/frontend/sidebar/confidential/edit_form_spec.js48
-rw-r--r--spec/frontend/sidebar/confidential_issue_sidebar_spec.js159
-rw-r--r--spec/frontend/sidebar/mock_data.js25
-rw-r--r--spec/frontend/sidebar/subscriptions_spec.js9
-rw-r--r--spec/frontend/sidebar/user_data_mock.js1
-rw-r--r--spec/frontend/single_file_diff_spec.js96
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap6
-rw-r--r--spec/frontend/test_setup.js5
-rw-r--r--spec/frontend/tooltips/components/tooltips_spec.js10
-rw-r--r--spec/frontend/tracking_spec.js92
-rw-r--r--spec/frontend/user_popovers_spec.js27
-rw-r--r--spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js2
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js14
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_merge_help_spec.js46
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js8
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js38
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js51
-rw-r--r--spec/frontend/vue_mr_widget/components/review_app_link_spec.js26
-rw-r--r--spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap2
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js109
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js38
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js12
-rw-r--r--spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js101
-rw-r--r--spec/frontend/vue_mr_widget/mr_widget_options_spec.js12
-rw-r--r--spec/frontend/vue_shared/alert_details/alert_details_spec.js24
-rw-r--r--spec/frontend/vue_shared/alert_details/mocks/alerts.json2
-rw-r--r--spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/multiselect_dropdown_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap90
-rw-r--r--spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js122
-rw-r--r--spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap2
-rw-r--r--spec/frontend/vue_shared/components/settings/settings_block_spec.js21
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js44
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js1
-rw-r--r--spec/frontend/vue_shared/components/tabs/tab_spec.js32
-rw-r--r--spec/frontend/vue_shared/components/tabs/tabs_spec.js61
-rw-r--r--spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js11
-rw-r--r--spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap67
-rw-r--r--spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js34
-rw-r--r--spec/frontend/vue_shared/components/user_access_role_badge_spec.js26
-rw-r--r--spec/frontend/vue_shared/components/user_popover/user_popover_spec.js25
-rw-r--r--spec/frontend/vue_shared/directives/tooltip_spec.js157
-rw-r--r--spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js8
-rw-r--r--spec/frontend/zen_mode_spec.js2
-rw-r--r--spec/frontend_integration/ide/helpers/ide_helper.js3
-rw-r--r--spec/frontend_integration/ide/helpers/mock_data.js1
-rw-r--r--spec/frontend_integration/ide/helpers/start.js11
-rw-r--r--spec/frontend_integration/ide/ide_integration_spec.js27
-rw-r--r--spec/frontend_integration/ide/user_opens_mr_spec.js6
-rw-r--r--spec/frontend_integration/test_helpers/mock_server/graphql.js8
-rw-r--r--spec/generator_helper.rb15
-rw-r--r--spec/graphql/features/authorization_spec.rb37
-rw-r--r--spec/graphql/features/feature_flag_spec.rb3
-rw-r--r--spec/graphql/gitlab_schema_spec.rb25
-rw-r--r--spec/graphql/mutations/boards/update_spec.rb57
-rw-r--r--spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb2
-rw-r--r--spec/graphql/mutations/custom_emoji/create_spec.rb27
-rw-r--r--spec/graphql/mutations/merge_requests/accept_spec.rb171
-rw-r--r--spec/graphql/mutations/release_asset_links/create_spec.rb105
-rw-r--r--spec/graphql/mutations/release_asset_links/update_spec.rb184
-rw-r--r--spec/graphql/mutations/user_callouts/create_spec.rb42
-rw-r--r--spec/graphql/resolvers/admin/analytics/usage_trends/measurements_resolver_spec.rb (renamed from spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb)10
-rw-r--r--spec/graphql/resolvers/alert_management/http_integrations_resolver_spec.rb51
-rw-r--r--spec/graphql/resolvers/board_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/boards_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/branch_commit_resolver_spec.rb20
-rw-r--r--spec/graphql/resolvers/concerns/caching_array_resolver_spec.rb3
-rw-r--r--spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb6
-rw-r--r--spec/graphql/resolvers/group_labels_resolver_spec.rb2
-rw-r--r--spec/graphql/resolvers/group_packages_resolver_spec.rb18
-rw-r--r--spec/graphql/resolvers/issues_resolver_spec.rb8
-rw-r--r--spec/graphql/resolvers/labels_resolver_spec.rb36
-rw-r--r--spec/graphql/resolvers/merge_requests_resolver_spec.rb2
-rw-r--r--spec/graphql/resolvers/namespace_projects_resolver_spec.rb57
-rw-r--r--spec/graphql/resolvers/project_packages_resolver_spec.rb (renamed from spec/graphql/resolvers/packages_resolver_spec.rb)2
-rw-r--r--spec/graphql/resolvers/project_pipeline_resolver_spec.rb37
-rw-r--r--spec/graphql/resolvers/release_milestones_resolver_spec.rb2
-rw-r--r--spec/graphql/types/access_level_enum_spec.rb2
-rw-r--r--spec/graphql/types/admin/analytics/usage_trends/measurement_identifier_enum_spec.rb (renamed from spec/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum_spec.rb)2
-rw-r--r--spec/graphql/types/admin/analytics/usage_trends/measurement_type_spec.rb (renamed from spec/graphql/types/admin/analytics/instance_statistics/measurement_type_spec.rb)12
-rw-r--r--spec/graphql/types/alert_management/alert_type_spec.rb3
-rw-r--r--spec/graphql/types/base_argument_spec.rb17
-rw-r--r--spec/graphql/types/board_type_spec.rb2
-rw-r--r--spec/graphql/types/ci/job_type_spec.rb2
-rw-r--r--spec/graphql/types/ci/pipeline_type_spec.rb4
-rw-r--r--spec/graphql/types/global_id_type_spec.rb23
-rw-r--r--spec/graphql/types/group_type_spec.rb1
-rw-r--r--spec/graphql/types/label_type_spec.rb11
-rw-r--r--spec/graphql/types/merge_request_type_spec.rb31
-rw-r--r--spec/graphql/types/query_type_spec.rb10
-rw-r--r--spec/graphql/types/snippet_type_spec.rb10
-rw-r--r--spec/graphql/types/snippets/blob_type_spec.rb47
-rw-r--r--spec/graphql/types/user_callout_feature_name_enum_spec.rb11
-rw-r--r--spec/graphql/types/user_callout_type_spec.rb11
-rw-r--r--spec/graphql/types/user_type_spec.rb9
-rw-r--r--spec/helpers/application_settings_helper_spec.rb15
-rw-r--r--spec/helpers/auth_helper_spec.rb8
-rw-r--r--spec/helpers/avatars_helper_spec.rb65
-rw-r--r--spec/helpers/boards_helper_spec.rb81
-rw-r--r--spec/helpers/ci/pipeline_editor_helper_spec.rb7
-rw-r--r--spec/helpers/commits_helper_spec.rb21
-rw-r--r--spec/helpers/gitlab_routing_helper_spec.rb10
-rw-r--r--spec/helpers/groups_helper_spec.rb22
-rw-r--r--spec/helpers/ide_helper_spec.rb47
-rw-r--r--spec/helpers/invite_members_helper_spec.rb25
-rw-r--r--spec/helpers/issuables_description_templates_helper_spec.rb104
-rw-r--r--spec/helpers/learn_gitlab_helper_spec.rb6
-rw-r--r--spec/helpers/merge_requests_helper_spec.rb53
-rw-r--r--spec/helpers/namespaces_helper_spec.rb36
-rw-r--r--spec/helpers/notifications_helper_spec.rb16
-rw-r--r--spec/helpers/preferences_helper_spec.rb1
-rw-r--r--spec/helpers/projects/project_members_helper_spec.rb4
-rw-r--r--spec/helpers/projects/security/configuration_helper_spec.rb13
-rw-r--r--spec/helpers/projects_helper_spec.rb26
-rw-r--r--spec/helpers/search_helper_spec.rb5
-rw-r--r--spec/helpers/services_helper_spec.rb46
-rw-r--r--spec/helpers/stat_anchors_helper_spec.rb18
-rw-r--r--spec/helpers/timeboxes_helper_spec.rb9
-rw-r--r--spec/helpers/visibility_level_helper_spec.rb16
-rw-r--r--spec/initializers/rack_multipart_patch_spec.rb79
-rw-r--r--spec/lib/api/entities/plan_limit_spec.rb24
-rw-r--r--spec/lib/api/entities/projects/repository_storage_move_spec.rb (renamed from spec/lib/api/entities/project_repository_storage_move_spec.rb)2
-rw-r--r--spec/lib/api/entities/public_group_details_spec.rb24
-rw-r--r--spec/lib/api/entities/snippets/repository_storage_move_spec.rb (renamed from spec/lib/api/entities/snippet_repository_storage_move_spec.rb)2
-rw-r--r--spec/lib/backup/repositories_spec.rb10
-rw-r--r--spec/lib/banzai/filter/custom_emoji_filter_spec.rb29
-rw-r--r--spec/lib/banzai/filter/emoji_filter_spec.rb27
-rw-r--r--spec/lib/banzai/filter/gollum_tags_filter_spec.rb4
-rw-r--r--spec/lib/banzai/filter/sanitization_filter_spec.rb28
-rw-r--r--spec/lib/banzai/filter/video_link_filter_spec.rb1
-rw-r--r--spec/lib/banzai/pipeline/full_pipeline_spec.rb7
-rw-r--r--spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb80
-rw-r--r--spec/lib/bulk_imports/common/loaders/entity_loader_spec.rb30
-rw-r--r--spec/lib/bulk_imports/common/transformers/prohibited_attributes_transformer_spec.rb6
-rw-r--r--spec/lib/bulk_imports/common/transformers/user_reference_transformer_spec.rb (renamed from spec/lib/bulk_imports/common/transformers/award_emoji_transformer_spec.rb)25
-rw-r--r--spec/lib/bulk_imports/groups/graphql/get_labels_query_spec.rb21
-rw-r--r--spec/lib/bulk_imports/groups/graphql/get_milestones_query_spec.rb35
-rw-r--r--spec/lib/bulk_imports/groups/loaders/labels_loader_spec.rb30
-rw-r--r--spec/lib/bulk_imports/groups/loaders/members_loader_spec.rb42
-rw-r--r--spec/lib/bulk_imports/groups/pipelines/labels_pipeline_spec.rb41
-rw-r--r--spec/lib/bulk_imports/groups/pipelines/members_pipeline_spec.rb32
-rw-r--r--spec/lib/bulk_imports/groups/pipelines/milestones_pipeline_spec.rb151
-rw-r--r--spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb40
-rw-r--r--spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb4
-rw-r--r--spec/lib/bulk_imports/importers/group_importer_spec.rb3
-rw-r--r--spec/lib/bulk_imports/pipeline/runner_spec.rb162
-rw-r--r--spec/lib/bulk_imports/pipeline_spec.rb114
-rw-r--r--spec/lib/error_tracking/sentry_client/api_urls_spec.rb (renamed from spec/lib/sentry/api_urls_spec.rb)4
-rw-r--r--spec/lib/error_tracking/sentry_client/event_spec.rb (renamed from spec/lib/sentry/client/event_spec.rb)2
-rw-r--r--spec/lib/error_tracking/sentry_client/issue_link_spec.rb (renamed from spec/lib/sentry/client/issue_link_spec.rb)2
-rw-r--r--spec/lib/error_tracking/sentry_client/issue_spec.rb (renamed from spec/lib/sentry/client/issue_spec.rb)10
-rw-r--r--spec/lib/error_tracking/sentry_client/pagination_parser_spec.rb (renamed from spec/lib/sentry/pagination_parser_spec.rb)2
-rw-r--r--spec/lib/error_tracking/sentry_client/projects_spec.rb (renamed from spec/lib/sentry/client/projects_spec.rb)6
-rw-r--r--spec/lib/error_tracking/sentry_client/repo_spec.rb (renamed from spec/lib/sentry/client/repo_spec.rb)4
-rw-r--r--spec/lib/error_tracking/sentry_client_spec.rb (renamed from spec/lib/sentry/client_spec.rb)4
-rw-r--r--spec/lib/expand_variables_spec.rb7
-rw-r--r--spec/lib/feature_spec.rb2
-rw-r--r--spec/lib/generators/gitlab/usage_metric_definition_generator_spec.rb75
-rw-r--r--spec/lib/gitlab/alert_management/payload/generic_spec.rb2
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/average_spec.rb66
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/sorting_spec.rb58
-rw-r--r--spec/lib/gitlab/analytics/usage_trends/workers_argument_builder_spec.rb (renamed from spec/lib/gitlab/analytics/instance_statistics/workers_argument_builder_spec.rb)12
-rw-r--r--spec/lib/gitlab/application_context_spec.rb30
-rw-r--r--spec/lib/gitlab/auth/o_auth/user_spec.rb17
-rw-r--r--spec/lib/gitlab/avatar_cache_spec.rb101
-rw-r--r--spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb45
-rw-r--r--spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb29
-rw-r--r--spec/lib/gitlab/background_migration/merge_request_assignees_migration_progress_check_spec.rb99
-rw-r--r--spec/lib/gitlab/background_migration/migrate_legacy_artifacts_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/move_container_registry_enabled_to_project_feature_spec.rb86
-rw-r--r--spec/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback_spec.rb33
-rw-r--r--spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb149
-rw-r--r--spec/lib/gitlab/background_migration/set_default_iteration_cadences_spec.rb80
-rw-r--r--spec/lib/gitlab/checks/branch_check_spec.rb76
-rw-r--r--spec/lib/gitlab/checks/lfs_check_spec.rb23
-rw-r--r--spec/lib/gitlab/ci/artifacts/metrics_spec.rb22
-rw-r--r--spec/lib/gitlab/ci/build/cache_spec.rb105
-rw-r--r--spec/lib/gitlab/ci/build/context/build_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/build/context/global_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/build/policy/variables_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/build/rules/rule_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/build/rules_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/charts_spec.rb7
-rw-r--r--spec/lib/gitlab/ci/config/entry/bridge_spec.rb46
-rw-r--r--spec/lib/gitlab/ci/config/entry/cache_spec.rb344
-rw-r--r--spec/lib/gitlab/ci/config/entry/environment_spec.rb33
-rw-r--r--spec/lib/gitlab/ci/config/entry/job_spec.rb40
-rw-r--r--spec/lib/gitlab/ci/config/entry/need_spec.rb86
-rw-r--r--spec/lib/gitlab/ci/config/entry/needs_spec.rb16
-rw-r--r--spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb34
-rw-r--r--spec/lib/gitlab/ci/config/entry/root_spec.rb229
-rw-r--r--spec/lib/gitlab/ci/jwt_spec.rb11
-rw-r--r--spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb17
-rw-r--r--spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb287
-rw-r--r--spec/lib/gitlab/ci/pipeline/seed/build_spec.rb282
-rw-r--r--spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb49
-rw-r--r--spec/lib/gitlab/ci/reports/codequality_reports_comparer_spec.rb56
-rw-r--r--spec/lib/gitlab/ci/reports/reports_comparer_spec.rb34
-rw-r--r--spec/lib/gitlab/ci/reports/test_suite_summary_spec.rb34
-rw-r--r--spec/lib/gitlab/ci/status/composite_spec.rb21
-rw-r--r--spec/lib/gitlab/ci/status/factory_spec.rb10
-rw-r--r--spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb372
-rw-r--r--spec/lib/gitlab/ci/trace_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/variables/collection/item_spec.rb104
-rw-r--r--spec/lib/gitlab/ci/variables/collection/sort_spec.rb185
-rw-r--r--spec/lib/gitlab/ci/variables/collection/sorted_spec.rb259
-rw-r--r--spec/lib/gitlab/ci/variables/collection_spec.rb386
-rw-r--r--spec/lib/gitlab/ci/yaml_processor_spec.rb263
-rw-r--r--spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb33
-rw-r--r--spec/lib/gitlab/data_builder/build_spec.rb8
-rw-r--r--spec/lib/gitlab/data_builder/pipeline_spec.rb8
-rw-r--r--spec/lib/gitlab/database/background_migration/batched_job_spec.rb50
-rw-r--r--spec/lib/gitlab/database/background_migration/batched_migration_spec.rb160
-rw-r--r--spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb70
-rw-r--r--spec/lib/gitlab/database/background_migration/scheduler_spec.rb182
-rw-r--r--spec/lib/gitlab/database/bulk_update_spec.rb36
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb28
-rw-r--r--spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb116
-rw-r--r--spec/lib/gitlab/database/migrations/observers/query_statistics_spec.rb68
-rw-r--r--spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb31
-rw-r--r--spec/lib/gitlab/database/similarity_score_spec.rb2
-rw-r--r--spec/lib/gitlab/database_spec.rb108
-rw-r--r--spec/lib/gitlab/diff/highlight_cache_spec.rb8
-rw-r--r--spec/lib/gitlab/diff/highlight_spec.rb16
-rw-r--r--spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb4
-rw-r--r--spec/lib/gitlab/diff/inline_diff_spec.rb11
-rw-r--r--spec/lib/gitlab/diff/pair_selector_spec.rb84
-rw-r--r--spec/lib/gitlab/email/handler/service_desk_handler_spec.rb2
-rw-r--r--spec/lib/gitlab/error_tracking/context_payload_generator_spec.rb176
-rw-r--r--spec/lib/gitlab/error_tracking/log_formatter_spec.rb71
-rw-r--r--spec/lib/gitlab/error_tracking/processor/context_payload_processor_spec.rb45
-rw-r--r--spec/lib/gitlab/error_tracking_spec.rb219
-rw-r--r--spec/lib/gitlab/etag_caching/router/graphql_spec.rb50
-rw-r--r--spec/lib/gitlab/etag_caching/router/restful_spec.rb124
-rw-r--r--spec/lib/gitlab/etag_caching/router_spec.rb147
-rw-r--r--spec/lib/gitlab/etag_caching/store_spec.rb84
-rw-r--r--spec/lib/gitlab/experimentation/controller_concern_spec.rb74
-rw-r--r--spec/lib/gitlab/experimentation_spec.rb6
-rw-r--r--spec/lib/gitlab/git/push_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/importer/pull_request_merged_by_importer_spec.rb41
-rw-r--r--spec/lib/gitlab/github_import/importer/pull_request_review_importer_spec.rb14
-rw-r--r--spec/lib/gitlab/graphql/calls_gitaly/field_extension_spec.rb87
-rw-r--r--spec/lib/gitlab/graphql/calls_gitaly/instrumentation_spec.rb23
-rw-r--r--spec/lib/gitlab/graphql/docs/renderer_spec.rb234
-rw-r--r--spec/lib/gitlab/graphql/pagination/keyset/last_items_spec.rb2
-rw-r--r--spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb12
-rw-r--r--spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb38
-rw-r--r--spec/lib/gitlab/graphql/present/field_extension_spec.rb143
-rw-r--r--spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb56
-rw-r--r--spec/lib/gitlab/hook_data/project_member_builder_spec.rb58
-rw-r--r--spec/lib/gitlab/http_connection_adapter_spec.rb231
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml1
-rw-r--r--spec/lib/gitlab/import_export/import_export_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/project/tree_saver_spec.rb20
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml3
-rw-r--r--spec/lib/gitlab/marker_range_spec.rb71
-rw-r--r--spec/lib/gitlab/metrics/background_transaction_spec.rb67
-rw-r--r--spec/lib/gitlab/metrics/subscribers/action_cable_spec.rb106
-rw-r--r--spec/lib/gitlab/metrics/subscribers/active_record_spec.rb315
-rw-r--r--spec/lib/gitlab/object_hierarchy_spec.rb260
-rw-r--r--spec/lib/gitlab/optimistic_locking_spec.rb109
-rw-r--r--spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb188
-rw-r--r--spec/lib/gitlab/pagination/keyset/order_spec.rb420
-rw-r--r--spec/lib/gitlab/query_limiting/active_support_subscriber_spec.rb12
-rw-r--r--spec/lib/gitlab/query_limiting/transaction_spec.rb24
-rw-r--r--spec/lib/gitlab/query_limiting_spec.rb14
-rw-r--r--spec/lib/gitlab/regex_spec.rb29
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb10
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/size_limiter/client_spec.rb99
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/size_limiter/exceed_limit_error_spec.rb35
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb253
-rw-r--r--spec/lib/gitlab/sidekiq_middleware_spec.rb1
-rw-r--r--spec/lib/gitlab/string_range_marker_spec.rb2
-rw-r--r--spec/lib/gitlab/string_regex_marker_spec.rb4
-rw-r--r--spec/lib/gitlab/tracking/standard_context_spec.rb20
-rw-r--r--spec/lib/gitlab/tracking_spec.rb4
-rw-r--r--spec/lib/gitlab/tree_summary_spec.rb31
-rw-r--r--spec/lib/gitlab/url_blocker_spec.rb4
-rw-r--r--spec/lib/gitlab/usage/docs/renderer_spec.rb10
-rw-r--r--spec/lib/gitlab/usage/docs/value_formatter_spec.rb6
-rw-r--r--spec/lib/gitlab/usage/metrics/aggregates/aggregate_spec.rb243
-rw-r--r--spec/lib/gitlab/usage/metrics/aggregates/sources/postgres_hll_spec.rb6
-rw-r--r--spec/lib/gitlab/usage/metrics/names_suggestions/generator_spec.rb67
-rw-r--r--spec/lib/gitlab/usage/metrics/names_suggestions/relation_parsers/constraints_spec.rb17
-rw-r--r--spec/lib/gitlab/usage_data_counters/aggregated_metrics_spec.rb24
-rw-r--r--spec/lib/gitlab/usage_data_counters/code_review_events_spec.rb23
-rw-r--r--spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb12
-rw-r--r--spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb52
-rw-r--r--spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb102
-rw-r--r--spec/lib/gitlab/usage_data_counters/package_event_counter_spec.rb2
-rw-r--r--spec/lib/gitlab/usage_data_counters/quick_action_activity_unique_counter_spec.rb20
-rw-r--r--spec/lib/gitlab/usage_data_queries_spec.rb8
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb72
-rw-r--r--spec/lib/gitlab/utils/usage_data_spec.rb166
-rw-r--r--spec/lib/gitlab/visibility_level_spec.rb25
-rw-r--r--spec/lib/gitlab/word_diff/chunk_collection_spec.rb44
-rw-r--r--spec/lib/gitlab/word_diff/line_processor_spec.rb46
-rw-r--r--spec/lib/gitlab/word_diff/parser_spec.rb67
-rw-r--r--spec/lib/gitlab/word_diff/positions_counter_spec.rb35
-rw-r--r--spec/lib/gitlab/word_diff/segments/chunk_spec.rb53
-rw-r--r--spec/lib/gitlab/word_diff/segments/diff_hunk_spec.rb51
-rw-r--r--spec/lib/gitlab/word_diff/segments/newline_spec.rb13
-rw-r--r--spec/lib/gitlab/x509/signature_spec.rb122
-rw-r--r--spec/lib/marginalia_spec.rb83
-rw-r--r--spec/lib/object_storage/direct_upload_spec.rb11
-rw-r--r--spec/lib/pager_duty/webhook_payload_parser_spec.rb3
-rw-r--r--spec/lib/peek/views/active_record_spec.rb71
-rw-r--r--spec/lib/quality/test_level_spec.rb4
-rw-r--r--spec/lib/release_highlights/validator/entry_spec.rb19
-rw-r--r--spec/lib/release_highlights/validator_spec.rb5
-rw-r--r--spec/lib/system_check/sidekiq_check_spec.rb81
-rw-r--r--spec/mailers/emails/in_product_marketing_spec.rb44
-rw-r--r--spec/mailers/emails/merge_requests_spec.rb200
-rw-r--r--spec/mailers/emails/pipelines_spec.rb2
-rw-r--r--spec/mailers/emails/profile_spec.rb9
-rw-r--r--spec/mailers/emails/service_desk_spec.rb11
-rw-r--r--spec/mailers/notify_spec.rb112
-rw-r--r--spec/migrations/20201112130715_schedule_recalculate_uuid_on_vulnerabilities_occurrences_spec.rb138
-rw-r--r--spec/migrations/20210218040814_add_environment_scope_to_group_variables_spec.rb46
-rw-r--r--spec/migrations/cleanup_projects_with_bad_has_external_issue_tracker_data_spec.rb94
-rw-r--r--spec/migrations/migrate_delayed_project_removal_from_namespaces_to_namespace_settings_spec.rb30
-rw-r--r--spec/migrations/move_container_registry_enabled_to_project_features_spec.rb43
-rw-r--r--spec/migrations/reschedule_artifact_expiry_backfill_spec.rb38
-rw-r--r--spec/migrations/reschedule_set_default_iteration_cadences_spec.rb41
-rw-r--r--spec/migrations/schedule_merge_request_assignees_migration_progress_check_spec.rb16
-rw-r--r--spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb10
-rw-r--r--spec/models/alert_management/http_integration_spec.rb26
-rw-r--r--spec/models/analytics/usage_trends/measurement_spec.rb (renamed from spec/models/analytics/instance_statistics/measurement_spec.rb)16
-rw-r--r--spec/models/application_setting_spec.rb31
-rw-r--r--spec/models/board_spec.rb1
-rw-r--r--spec/models/bulk_imports/entity_spec.rb18
-rw-r--r--spec/models/bulk_imports/tracker_spec.rb2
-rw-r--r--spec/models/ci/bridge_spec.rb6
-rw-r--r--spec/models/ci/build_spec.rb143
-rw-r--r--spec/models/ci/daily_build_group_report_result_spec.rb34
-rw-r--r--spec/models/ci/group_variable_spec.rb12
-rw-r--r--spec/models/ci/pipeline_spec.rb728
-rw-r--r--spec/models/ci/processable_spec.rb4
-rw-r--r--spec/models/ci/runner_spec.rb112
-rw-r--r--spec/models/ci/variable_spec.rb9
-rw-r--r--spec/models/clusters/agent_token_spec.rb5
-rw-r--r--spec/models/concerns/ci/has_variable_spec.rb11
-rw-r--r--spec/models/concerns/project_features_compatibility_spec.rb4
-rw-r--r--spec/models/custom_emoji_spec.rb6
-rw-r--r--spec/models/dependency_proxy/manifest_spec.rb20
-rw-r--r--spec/models/email_spec.rb2
-rw-r--r--spec/models/environment_spec.rb133
-rw-r--r--spec/models/error_tracking/project_error_tracking_setting_spec.rb14
-rw-r--r--spec/models/experiment_spec.rb157
-rw-r--r--spec/models/group_spec.rb337
-rw-r--r--spec/models/issue_email_participant_spec.rb11
-rw-r--r--spec/models/issue_spec.rb19
-rw-r--r--spec/models/iteration_spec.rb335
-rw-r--r--spec/models/list_spec.rb71
-rw-r--r--spec/models/member_spec.rb34
-rw-r--r--spec/models/merge_request_spec.rb273
-rw-r--r--spec/models/namespace/traversal_hierarchy_spec.rb16
-rw-r--r--spec/models/namespace_spec.rb246
-rw-r--r--spec/models/note_spec.rb19
-rw-r--r--spec/models/notification_recipient_spec.rb33
-rw-r--r--spec/models/notification_setting_spec.rb3
-rw-r--r--spec/models/onboarding_progress_spec.rb26
-rw-r--r--spec/models/packages/maven/metadatum_spec.rb33
-rw-r--r--spec/models/packages/package_file_spec.rb15
-rw-r--r--spec/models/packages/package_spec.rb93
-rw-r--r--spec/models/pages/lookup_path_spec.rb8
-rw-r--r--spec/models/project_feature_spec.rb28
-rw-r--r--spec/models/project_repository_storage_move_spec.rb2
-rw-r--r--spec/models/project_services/discord_service_spec.rb22
-rw-r--r--spec/models/project_services/hangouts_chat_service_spec.rb6
-rw-r--r--spec/models/project_services/jira_service_spec.rb130
-rw-r--r--spec/models/project_services/prometheus_service_spec.rb10
-rw-r--r--spec/models/project_services/slack_service_spec.rb112
-rw-r--r--spec/models/project_services/unify_circuit_service_spec.rb8
-rw-r--r--spec/models/project_services/webex_teams_service_spec.rb6
-rw-r--r--spec/models/project_spec.rb124
-rw-r--r--spec/models/projects/repository_storage_move_spec.rb35
-rw-r--r--spec/models/prometheus_alert_event_spec.rb2
-rw-r--r--spec/models/protected_branch_spec.rb22
-rw-r--r--spec/models/snippet_repository_spec.rb1
-rw-r--r--spec/models/snippet_repository_storage_move_spec.rb2
-rw-r--r--spec/models/snippet_spec.rb12
-rw-r--r--spec/models/snippets/repository_storage_move_spec.rb13
-rw-r--r--spec/models/todo_spec.rb17
-rw-r--r--spec/models/upload_spec.rb2
-rw-r--r--spec/models/user_spec.rb218
-rw-r--r--spec/policies/base_policy_spec.rb6
-rw-r--r--spec/policies/group_member_policy_spec.rb8
-rw-r--r--spec/policies/group_policy_spec.rb38
-rw-r--r--spec/policies/project_policy_spec.rb94
-rw-r--r--spec/presenters/ci/build_runner_presenter_spec.rb24
-rw-r--r--spec/presenters/packages/composer/packages_presenter_spec.rb7
-rw-r--r--spec/presenters/packages/detail/package_presenter_spec.rb5
-rw-r--r--spec/presenters/project_presenter_spec.rb115
-rw-r--r--spec/presenters/projects/import_export/project_export_presenter_spec.rb20
-rw-r--r--spec/presenters/snippet_presenter_spec.rb2
-rw-r--r--spec/requests/api/admin/plan_limits_spec.rb177
-rw-r--r--spec/requests/api/api_spec.rb24
-rw-r--r--spec/requests/api/ci/pipelines_spec.rb5
-rw-r--r--spec/requests/api/ci/runner/jobs_artifacts_spec.rb16
-rw-r--r--spec/requests/api/ci/runner/jobs_put_spec.rb5
-rw-r--r--spec/requests/api/ci/runner/jobs_request_post_spec.rb58
-rw-r--r--spec/requests/api/ci/runner/jobs_trace_spec.rb5
-rw-r--r--spec/requests/api/ci/runner/runners_delete_spec.rb8
-rw-r--r--spec/requests/api/ci/runner/runners_post_spec.rb64
-rw-r--r--spec/requests/api/ci/runner/runners_verify_post_spec.rb8
-rw-r--r--spec/requests/api/commit_statuses_spec.rb40
-rw-r--r--spec/requests/api/composer_packages_spec.rb46
-rw-r--r--spec/requests/api/discussions_spec.rb7
-rw-r--r--spec/requests/api/environments_spec.rb72
-rw-r--r--spec/requests/api/generic_packages_spec.rb10
-rw-r--r--spec/requests/api/graphql/container_repository/container_repository_details_spec.rb2
-rw-r--r--spec/requests/api/graphql/group/container_repositories_spec.rb2
-rw-r--r--spec/requests/api/graphql/group/packages_spec.rb78
-rw-r--r--spec/requests/api/graphql/issue/issue_spec.rb13
-rw-r--r--spec/requests/api/graphql/mutations/alert_management/alerts/create_alert_issue_spec.rb6
-rw-r--r--spec/requests/api/graphql/mutations/merge_requests/accept_spec.rb44
-rw-r--r--spec/requests/api/graphql/mutations/notes/create/diff_note_spec.rb23
-rw-r--r--spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb59
-rw-r--r--spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb67
-rw-r--r--spec/requests/api/graphql/mutations/user_callouts/create_spec.rb29
-rw-r--r--spec/requests/api/graphql/namespace/projects_spec.rb2
-rw-r--r--spec/requests/api/graphql/packages/package_spec.rb2
-rw-r--r--spec/requests/api/graphql/project/alert_management/alert/issue_spec.rb71
-rw-r--r--spec/requests/api/graphql/project/alert_management/alerts_spec.rb4
-rw-r--r--spec/requests/api/graphql/project/container_repositories_spec.rb2
-rw-r--r--spec/requests/api/graphql/project/merge_request/diff_notes_spec.rb2
-rw-r--r--spec/requests/api/graphql/project/merge_request_spec.rb2
-rw-r--r--spec/requests/api/graphql/project/merge_requests_spec.rb249
-rw-r--r--spec/requests/api/graphql/project/packages_spec.rb71
-rw-r--r--spec/requests/api/graphql/project/pipeline_spec.rb12
-rw-r--r--spec/requests/api/graphql/snippets_spec.rb25
-rw-r--r--spec/requests/api/graphql/usage_trends_measurements_spec.rb (renamed from spec/requests/api/graphql/instance_statistics_measurements_spec.rb)12
-rw-r--r--spec/requests/api/helpers_spec.rb20
-rw-r--r--spec/requests/api/invitations_spec.rb112
-rw-r--r--spec/requests/api/jobs_spec.rb185
-rw-r--r--spec/requests/api/lint_spec.rb18
-rw-r--r--spec/requests/api/merge_requests_spec.rb4
-rw-r--r--spec/requests/api/npm_instance_packages_spec.rb5
-rw-r--r--spec/requests/api/npm_project_packages_spec.rb95
-rw-r--r--spec/requests/api/oauth_tokens_spec.rb8
-rw-r--r--spec/requests/api/project_attributes.yml5
-rw-r--r--spec/requests/api/project_packages_spec.rb17
-rw-r--r--spec/requests/api/project_repository_storage_moves_spec.rb2
-rw-r--r--spec/requests/api/projects_spec.rb131
-rw-r--r--spec/requests/api/protected_branches_spec.rb18
-rw-r--r--spec/requests/api/repositories_spec.rb34
-rw-r--r--spec/requests/api/resource_access_tokens_spec.rb19
-rw-r--r--spec/requests/api/rubygem_packages_spec.rb294
-rw-r--r--spec/requests/api/snippet_repository_storage_moves_spec.rb2
-rw-r--r--spec/requests/api/users_spec.rb43
-rw-r--r--spec/requests/api/v3/github_spec.rb13
-rw-r--r--spec/requests/api/wikis_spec.rb15
-rw-r--r--spec/requests/ide_controller_spec.rb174
-rw-r--r--spec/requests/projects/merge_requests/content_spec.rb41
-rw-r--r--spec/requests/projects/noteable_notes_spec.rb11
-rw-r--r--spec/rubocop/code_reuse_helpers_spec.rb1
-rw-r--r--spec/rubocop/cop/active_record_association_reload_spec.rb1
-rw-r--r--spec/rubocop/cop/api/base_spec.rb1
-rw-r--r--spec/rubocop/cop/api/grape_array_missing_coerce_spec.rb1
-rw-r--r--spec/rubocop/cop/avoid_becomes_spec.rb1
-rw-r--r--spec/rubocop/cop/avoid_break_from_strong_memoize_spec.rb1
-rw-r--r--spec/rubocop/cop/avoid_keyword_arguments_in_sidekiq_workers_spec.rb1
-rw-r--r--spec/rubocop/cop/avoid_return_from_blocks_spec.rb13
-rw-r--r--spec/rubocop/cop/avoid_route_redirect_leading_slash_spec.rb13
-rw-r--r--spec/rubocop/cop/ban_catch_throw_spec.rb1
-rw-r--r--spec/rubocop/cop/code_reuse/finder_spec.rb1
-rw-r--r--spec/rubocop/cop/code_reuse/presenter_spec.rb1
-rw-r--r--spec/rubocop/cop/code_reuse/serializer_spec.rb1
-rw-r--r--spec/rubocop/cop/code_reuse/service_class_spec.rb1
-rw-r--r--spec/rubocop/cop/code_reuse/worker_spec.rb1
-rw-r--r--spec/rubocop/cop/default_scope_spec.rb1
-rw-r--r--spec/rubocop/cop/destroy_all_spec.rb1
-rw-r--r--spec/rubocop/cop/filename_length_spec.rb1
-rw-r--r--spec/rubocop/cop/gitlab/avoid_uploaded_file_from_params_spec.rb1
-rw-r--r--spec/rubocop/cop/gitlab/bulk_insert_spec.rb1
-rw-r--r--spec/rubocop/cop/gitlab/change_timezone_spec.rb1
-rw-r--r--spec/rubocop/cop/gitlab/const_get_inherit_false_spec.rb1
-rw-r--r--spec/rubocop/cop/gitlab/duplicate_spec_location_spec.rb1
-rw-r--r--spec/rubocop/cop/gitlab/except_spec.rb1
-rw-r--r--spec/rubocop/cop/gitlab/finder_with_find_by_spec.rb1
-rw-r--r--spec/rubocop/cop/gitlab/httparty_spec.rb1
-rw-r--r--spec/rubocop/cop/gitlab/intersect_spec.rb1
-rw-r--r--spec/rubocop/cop/gitlab/json_spec.rb1
-rw-r--r--spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb1
-rw-r--r--spec/rubocop/cop/gitlab/namespaced_class_spec.rb1
-rw-r--r--spec/rubocop/cop/gitlab/policy_rule_boolean_spec.rb1
-rw-r--r--spec/rubocop/cop/gitlab/predicate_memoization_spec.rb1
-rw-r--r--spec/rubocop/cop/gitlab/rails_logger_spec.rb1
-rw-r--r--spec/rubocop/cop/gitlab/union_spec.rb1
-rw-r--r--spec/rubocop/cop/graphql/authorize_types_spec.rb31
-rw-r--r--spec/rubocop/cop/graphql/descriptions_spec.rb45
-rw-r--r--spec/rubocop/cop/graphql/gid_expected_type_spec.rb1
-rw-r--r--spec/rubocop/cop/graphql/id_type_spec.rb1
-rw-r--r--spec/rubocop/cop/graphql/json_type_spec.rb1
-rw-r--r--spec/rubocop/cop/graphql/resolver_type_spec.rb1
-rw-r--r--spec/rubocop/cop/group_public_or_visible_to_user_spec.rb1
-rw-r--r--spec/rubocop/cop/ignored_columns_spec.rb11
-rw-r--r--spec/rubocop/cop/include_sidekiq_worker_spec.rb1
-rw-r--r--spec/rubocop/cop/inject_enterprise_edition_module_spec.rb1
-rw-r--r--spec/rubocop/cop/lint/last_keyword_argument_spec.rb1
-rw-r--r--spec/rubocop/cop/migration/add_column_with_default_spec.rb11
-rw-r--r--spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb15
-rw-r--r--spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb31
-rw-r--r--spec/rubocop/cop/migration/add_concurrent_index_spec.rb27
-rw-r--r--spec/rubocop/cop/migration/add_index_spec.rb3
-rw-r--r--spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb41
-rw-r--r--spec/rubocop/cop/migration/add_reference_spec.rb9
-rw-r--r--spec/rubocop/cop/migration/add_timestamps_spec.rb46
-rw-r--r--spec/rubocop/cop/migration/complex_indexes_require_name_spec.rb23
-rw-r--r--spec/rubocop/cop/migration/create_table_with_foreign_keys_spec.rb5
-rw-r--r--spec/rubocop/cop/migration/datetime_spec.rb149
-rw-r--r--spec/rubocop/cop/migration/drop_table_spec.rb15
-rw-r--r--spec/rubocop/cop/migration/hash_index_spec.rb47
-rw-r--r--spec/rubocop/cop/migration/prevent_strings_spec.rb23
-rw-r--r--spec/rubocop/cop/migration/refer_to_index_by_name_spec.rb21
-rw-r--r--spec/rubocop/cop/migration/remove_column_spec.rb45
-rw-r--r--spec/rubocop/cop/migration/remove_concurrent_index_spec.rb23
-rw-r--r--spec/rubocop/cop/migration/remove_index_spec.rb23
-rw-r--r--spec/rubocop/cop/migration/safer_boolean_column_spec.rb30
-rw-r--r--spec/rubocop/cop/migration/schedule_async_spec.rb97
-rw-r--r--spec/rubocop/cop/migration/timestamps_spec.rb45
-rw-r--r--spec/rubocop/cop/migration/update_column_in_batches_spec.rb35
-rw-r--r--spec/rubocop/cop/migration/with_lock_retries_disallowed_method_spec.rb68
-rw-r--r--spec/rubocop/cop/migration/with_lock_retries_with_change_spec.rb35
-rw-r--r--spec/rubocop/cop/performance/ar_count_each_spec.rb1
-rw-r--r--spec/rubocop/cop/performance/ar_exists_and_present_blank_spec.rb1
-rw-r--r--spec/rubocop/cop/performance/readlines_each_spec.rb1
-rw-r--r--spec/rubocop/cop/prefer_class_methods_over_module_spec.rb43
-rw-r--r--spec/rubocop/cop/project_path_helper_spec.rb1
-rw-r--r--spec/rubocop/cop/put_group_routes_under_scope_spec.rb13
-rw-r--r--spec/rubocop/cop/put_project_routes_under_scope_spec.rb1
-rw-r--r--spec/rubocop/cop/qa/ambiguous_page_object_name_spec.rb1
-rw-r--r--spec/rubocop/cop/qa/element_with_pattern_spec.rb1
-rw-r--r--spec/rubocop/cop/rspec/be_success_matcher_spec.rb1
-rw-r--r--spec/rubocop/cop/rspec/env_assignment_spec.rb1
-rw-r--r--spec/rubocop/cop/rspec/expect_gitlab_tracking_spec.rb1
-rw-r--r--spec/rubocop/cop/rspec/factories_in_migration_specs_spec.rb1
-rw-r--r--spec/rubocop/cop/rspec/factory_bot/inline_association_spec.rb1
-rw-r--r--spec/rubocop/cop/rspec/have_gitlab_http_status_spec.rb1
-rw-r--r--spec/rubocop/cop/rspec/modify_sidekiq_middleware_spec.rb1
-rw-r--r--spec/rubocop/cop/rspec/timecop_freeze_spec.rb1
-rw-r--r--spec/rubocop/cop/rspec/timecop_travel_spec.rb1
-rw-r--r--spec/rubocop/cop/rspec/top_level_describe_path_spec.rb1
-rw-r--r--spec/rubocop/cop/ruby_interpolation_in_translation_spec.rb1
-rw-r--r--spec/rubocop/cop/safe_params_spec.rb1
-rw-r--r--spec/rubocop/cop/scalability/bulk_perform_with_context_spec.rb1
-rw-r--r--spec/rubocop/cop/scalability/cron_worker_context_spec.rb1
-rw-r--r--spec/rubocop/cop/scalability/file_uploads_spec.rb1
-rw-r--r--spec/rubocop/cop/scalability/idempotent_worker_spec.rb1
-rw-r--r--spec/rubocop/cop/sidekiq_options_queue_spec.rb20
-rw-r--r--spec/rubocop/cop/static_translation_definition_spec.rb1
-rw-r--r--spec/rubocop/cop/usage_data/distinct_count_by_large_foreign_key_spec.rb1
-rw-r--r--spec/rubocop/cop/usage_data/large_table_spec.rb1
-rw-r--r--spec/rubocop/migration_helpers_spec.rb1
-rw-r--r--spec/rubocop/qa_helpers_spec.rb1
-rw-r--r--spec/serializers/base_discussion_entity_spec.rb9
-rw-r--r--spec/serializers/merge_request_user_entity_spec.rb53
-rw-r--r--spec/serializers/pipeline_serializer_spec.rb16
-rw-r--r--spec/serializers/test_suite_comparer_entity_spec.rb44
-rw-r--r--spec/serializers/test_suite_summary_entity_spec.rb4
-rw-r--r--spec/services/alert_management/create_alert_issue_service_spec.rb27
-rw-r--r--spec/services/alert_management/process_prometheus_alert_service_spec.rb16
-rw-r--r--spec/services/boards/issues/list_service_spec.rb78
-rw-r--r--spec/services/boards/lists/list_service_spec.rb22
-rw-r--r--spec/services/bulk_import_service_spec.rb17
-rw-r--r--spec/services/ci/build_report_result_service_spec.rb32
-rw-r--r--spec/services/ci/create_pipeline_service/environment_spec.rb48
-rw-r--r--spec/services/ci/create_pipeline_service/needs_spec.rb46
-rw-r--r--spec/services/ci/create_pipeline_service/parallel_spec.rb118
-rw-r--r--spec/services/ci/create_pipeline_service/rules_spec.rb20
-rw-r--r--spec/services/ci/create_pipeline_service_spec.rb2
-rw-r--r--spec/services/ci/destroy_expired_job_artifacts_service_spec.rb20
-rw-r--r--spec/services/ci/expire_pipeline_cache_service_spec.rb41
-rw-r--r--spec/services/ci/job_artifacts_destroy_batch_service_spec.rb81
-rw-r--r--spec/services/ci/pipeline_processing/shared_processing_service.rb98
-rw-r--r--spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb8
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_build2_build1_rules_out_test_needs_build1_with_optional.yml50
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_build_rules_out_test_needs_build_with_optional.yml31
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_both.yml6
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_test.yml6
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_build_test_manual_review_deploy.yml22
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true.yml23
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_always.yml9
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_on_failure.yml13
-rw-r--r--spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_other_test_succeeds_deploy_needs_both.yml13
-rw-r--r--spec/services/ci/process_pipeline_service_spec.rb53
-rw-r--r--spec/services/ci/register_job_service_spec.rb933
-rw-r--r--spec/services/ci/update_build_queue_service_spec.rb58
-rw-r--r--spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb2
-rw-r--r--spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb43
-rw-r--r--spec/services/dependency_proxy/find_or_create_manifest_service_spec.rb46
-rw-r--r--spec/services/dependency_proxy/head_manifest_service_spec.rb9
-rw-r--r--spec/services/dependency_proxy/pull_manifest_service_spec.rb6
-rw-r--r--spec/services/deployments/update_environment_service_spec.rb55
-rw-r--r--spec/services/environments/schedule_to_delete_review_apps_service_spec.rb136
-rw-r--r--spec/services/groups/destroy_service_spec.rb6
-rw-r--r--spec/services/groups/group_links/create_service_spec.rb56
-rw-r--r--spec/services/groups/group_links/destroy_service_spec.rb4
-rw-r--r--spec/services/groups/group_links/update_service_spec.rb8
-rw-r--r--spec/services/groups/import_export/import_service_spec.rb28
-rw-r--r--spec/services/import/github_service_spec.rb64
-rw-r--r--spec/services/issuable/bulk_update_service_spec.rb45
-rw-r--r--spec/services/issuable/process_assignees_spec.rb71
-rw-r--r--spec/services/issues/clone_service_spec.rb6
-rw-r--r--spec/services/issues/create_service_spec.rb6
-rw-r--r--spec/services/issues/move_service_spec.rb6
-rw-r--r--spec/services/jira_import/users_importer_spec.rb5
-rw-r--r--spec/services/labels/promote_service_spec.rb66
-rw-r--r--spec/services/members/invite_service_spec.rb173
-rw-r--r--spec/services/merge_requests/after_create_service_spec.rb26
-rw-r--r--spec/services/merge_requests/build_service_spec.rb123
-rw-r--r--spec/services/merge_requests/merge_service_spec.rb13
-rw-r--r--spec/services/merge_requests/post_merge_service_spec.rb134
-rw-r--r--spec/services/merge_requests/refresh_service_spec.rb27
-rw-r--r--spec/services/merge_requests/retarget_chain_service_spec.rb154
-rw-r--r--spec/services/merge_requests/update_service_spec.rb179
-rw-r--r--spec/services/namespaces/in_product_marketing_emails_service_spec.rb55
-rw-r--r--spec/services/notes/build_service_spec.rb140
-rw-r--r--spec/services/notes/update_service_spec.rb34
-rw-r--r--spec/services/notification_service_spec.rb202
-rw-r--r--spec/services/onboarding_progress_service_spec.rb54
-rw-r--r--spec/services/packages/composer/create_package_service_spec.rb8
-rw-r--r--spec/services/packages/create_event_service_spec.rb24
-rw-r--r--spec/services/packages/create_temporary_package_service_spec.rb44
-rw-r--r--spec/services/packages/debian/find_or_create_incoming_service_spec.rb (renamed from spec/services/packages/debian/get_or_create_incoming_service_spec.rb)2
-rw-r--r--spec/services/packages/debian/find_or_create_package_service_spec.rb54
-rw-r--r--spec/services/packages/maven/metadata/append_package_file_service_spec.rb59
-rw-r--r--spec/services/packages/maven/metadata/create_plugins_xml_service_spec.rb124
-rw-r--r--spec/services/packages/maven/metadata/create_versions_xml_service_spec.rb227
-rw-r--r--spec/services/packages/maven/metadata/sync_service_spec.rb259
-rw-r--r--spec/services/packages/npm/create_package_service_spec.rb51
-rw-r--r--spec/services/packages/nuget/create_package_service_spec.rb37
-rw-r--r--spec/services/packages/nuget/update_package_from_metadata_service_spec.rb5
-rw-r--r--spec/services/packages/rubygems/dependency_resolver_service_spec.rb100
-rw-r--r--spec/services/pages/legacy_storage_lease_spec.rb8
-rw-r--r--spec/services/projects/alerting/notify_service_spec.rb8
-rw-r--r--spec/services/projects/branches_by_mode_service_spec.rb4
-rw-r--r--spec/services/projects/create_service_spec.rb43
-rw-r--r--spec/services/projects/destroy_service_spec.rb503
-rw-r--r--spec/services/projects/schedule_bulk_repository_shard_moves_service_spec.rb4
-rw-r--r--spec/services/projects/update_pages_configuration_service_spec.rb17
-rw-r--r--spec/services/projects/update_pages_service_spec.rb35
-rw-r--r--spec/services/projects/update_service_spec.rb2
-rw-r--r--spec/services/quick_actions/interpret_service_spec.rb94
-rw-r--r--spec/services/repositories/changelog_service_spec.rb118
-rw-r--r--spec/services/snippets/schedule_bulk_repository_shard_moves_service_spec.rb4
-rw-r--r--spec/services/system_hooks_service_spec.rb3
-rw-r--r--spec/services/system_note_service_spec.rb13
-rw-r--r--spec/services/system_notes/alert_management_service_spec.rb13
-rw-r--r--spec/services/system_notes/merge_requests_service_spec.rb2
-rw-r--r--spec/services/users/dismiss_user_callout_service_spec.rb27
-rw-r--r--spec/services/users/refresh_authorized_projects_service_spec.rb8
-rw-r--r--spec/spam/concerns/has_spam_action_response_fields_spec.rb35
-rw-r--r--spec/spec_helper.rb15
-rw-r--r--spec/support/capybara.rb24
-rw-r--r--spec/support/gitlab_experiment.rb22
-rw-r--r--spec/support/graphql/resolver_factories.rb40
-rw-r--r--spec/support/helpers/cycle_analytics_helpers.rb4
-rw-r--r--spec/support/helpers/database/database_helpers.rb56
-rw-r--r--spec/support/helpers/dependency_proxy_helpers.rb4
-rw-r--r--spec/support/helpers/design_management_test_helpers.rb2
-rw-r--r--spec/support/helpers/features/releases_helpers.rb105
-rw-r--r--spec/support/helpers/gpg_helpers.rb4
-rw-r--r--spec/support/helpers/graphql_helpers.rb200
-rw-r--r--spec/support/helpers/javascript_fixtures_helpers.rb1
-rw-r--r--spec/support/helpers/notification_helpers.rb4
-rw-r--r--spec/support/helpers/stub_object_storage.rb2
-rw-r--r--spec/support/helpers/test_env.rb24
-rw-r--r--spec/support/matchers/background_migrations_matchers.rb20
-rw-r--r--spec/support/matchers/email_matcher.rb19
-rw-r--r--spec/support/matchers/graphql_matchers.rb9
-rw-r--r--spec/support/services/issues/move_and_clone_services_shared_examples.rb22
-rw-r--r--spec/support/services/service_response_shared_examples.rb25
-rw-r--r--spec/support/shared_contexts/features/error_tracking_shared_context.rb2
-rw-r--r--spec/support/shared_contexts/issuable/merge_request_shared_context.rb64
-rw-r--r--spec/support/shared_contexts/navbar_structure_context.rb11
-rw-r--r--spec/support/shared_contexts/policies/group_policy_shared_context.rb4
-rw-r--r--spec/support/shared_contexts/policies/project_policy_shared_context.rb6
-rw-r--r--spec/support/shared_contexts/policies/project_policy_table_shared_context.rb2
-rw-r--r--spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb9
-rw-r--r--spec/support/shared_contexts/security_and_compliance_permissions_shared_context.rb33
-rw-r--r--spec/support/shared_examples/alert_notification_service_shared_examples.rb15
-rw-r--r--spec/support/shared_examples/banzai/filters/emoji_shared_examples.rb46
-rw-r--r--spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/controllers/create_notes_rate_limit_shared_examples.rb44
-rw-r--r--spec/support/shared_examples/controllers/snippet_blob_shared_examples.rb32
-rw-r--r--spec/support/shared_examples/features/comment_and_close_button_shared_examples.rb29
-rw-r--r--spec/support/shared_examples/features/discussion_comments_shared_example.rb216
-rw-r--r--spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/features/project_upload_files_shared_examples.rb34
-rw-r--r--spec/support/shared_examples/features/variable_list_shared_examples.rb26
-rw-r--r--spec/support/shared_examples/graphql/mutation_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb8
-rw-r--r--spec/support/shared_examples/graphql/notes_on_noteables_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/helpers/issuable_description_templates_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb1
-rw-r--r--spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb (renamed from spec/support/shared_examples/lib/gitlab/usage_data_counters/issue_activity_shared_examples.rb)12
-rw-r--r--spec/support/shared_examples/lib/sentry/client_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/metrics/active_record_subscriber_shared_examples.rb137
-rw-r--r--spec/support/shared_examples/models/application_setting_shared_examples.rb15
-rw-r--r--spec/support/shared_examples/models/boards/user_preferences_shared_examples.rb68
-rw-r--r--spec/support/shared_examples/models/chat_service_shared_examples.rb11
-rw-r--r--spec/support/shared_examples/models/concerns/timebox_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/models/email_format_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb241
-rw-r--r--spec/support/shared_examples/models/wiki_shared_examples.rb46
-rw-r--r--spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb106
-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/graphql/packages/group_and_project_packages_list_shared_examples.rb51
-rw-r--r--spec/support/shared_examples/requests/api/logging_application_context_shared_examples.rb16
-rw-r--r--spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb593
-rw-r--r--spec/support/shared_examples/requests/api/rubygems_packages_shared_examples.rb194
-rw-r--r--spec/support/shared_examples/service_desk_issue_templates_examples.rb12
-rw-r--r--spec/support/shared_examples/services/boards/update_boards_shared_examples.rb83
-rw-r--r--spec/support/shared_examples/services/packages/maven/metadata_shared_examples.rb57
-rw-r--r--spec/support/snowplow.rb19
-rw-r--r--spec/support/stub_snowplow.rb23
-rw-r--r--spec/tasks/admin_mode_spec.rb32
-rw-r--r--spec/tasks/gitlab/packages/composer_rake_spec.rb28
-rw-r--r--spec/tooling/danger/base_linter_spec.rb192
-rw-r--r--spec/tooling/danger/changelog_spec.rb193
-rw-r--r--spec/tooling/danger/commit_linter_spec.rb241
-rw-r--r--spec/tooling/danger/danger_spec_helper.rb17
-rw-r--r--spec/tooling/danger/emoji_checker_spec.rb37
-rw-r--r--spec/tooling/danger/feature_flag_spec.rb23
-rw-r--r--spec/tooling/danger/helper_spec.rb682
-rw-r--r--spec/tooling/danger/merge_request_linter_spec.rb54
-rw-r--r--spec/tooling/danger/project_helper_spec.rb260
-rw-r--r--spec/tooling/danger/roulette_spec.rb429
-rw-r--r--spec/tooling/danger/sidekiq_queues_spec.rb11
-rw-r--r--spec/tooling/danger/teammate_spec.rb225
-rw-r--r--spec/tooling/danger/title_linting_spec.rb91
-rw-r--r--spec/tooling/danger/weightage/maintainers_spec.rb34
-rw-r--r--spec/tooling/danger/weightage/reviewers_spec.rb63
-rw-r--r--spec/tooling/gitlab_danger_spec.rb76
-rw-r--r--spec/tooling/rspec_flaky/config_spec.rb (renamed from spec/lib/rspec_flaky/config_spec.rb)26
-rw-r--r--spec/tooling/rspec_flaky/example_spec.rb (renamed from spec/lib/rspec_flaky/example_spec.rb)2
-rw-r--r--spec/tooling/rspec_flaky/flaky_example_spec.rb (renamed from spec/lib/rspec_flaky/flaky_example_spec.rb)40
-rw-r--r--spec/tooling/rspec_flaky/flaky_examples_collection_spec.rb (renamed from spec/lib/rspec_flaky/flaky_examples_collection_spec.rb)2
-rw-r--r--spec/tooling/rspec_flaky/listener_spec.rb (renamed from spec/lib/rspec_flaky/listener_spec.rb)30
-rw-r--r--spec/tooling/rspec_flaky/report_spec.rb (renamed from spec/lib/rspec_flaky/report_spec.rb)16
-rw-r--r--spec/uploaders/dependency_proxy/file_uploader_spec.rb46
-rw-r--r--spec/validators/gitlab/utils/zoom_url_validator_spec.rb (renamed from spec/validators/zoom_url_validator_spec.rb)2
-rw-r--r--spec/views/admin/application_settings/_package_registry.html.haml_spec.rb12
-rw-r--r--spec/views/admin/application_settings/_repository_storage.html.haml_spec.rb47
-rw-r--r--spec/views/groups/show.html.haml_spec.rb52
-rw-r--r--spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb4
-rw-r--r--spec/views/notify/change_in_merge_request_draft_status_email.html.haml_spec.rb19
-rw-r--r--spec/views/notify/change_in_merge_request_draft_status_email.text.erb_spec.rb20
-rw-r--r--spec/views/projects/_home_panel.html.haml_spec.rb10
-rw-r--r--spec/views/projects/commit/_commit_box.html.haml_spec.rb27
-rw-r--r--spec/views/projects/empty.html.haml_spec.rb37
-rw-r--r--spec/views/projects/issues/import_csv/_button.html.haml_spec.rb43
-rw-r--r--spec/views/projects/merge_requests/show.html.haml_spec.rb38
-rw-r--r--spec/views/projects/settings/operations/show.html.haml_spec.rb4
-rw-r--r--spec/views/projects/show.html.haml_spec.rb51
-rw-r--r--spec/views/shared/snippets/_snippet.html.haml_spec.rb52
-rw-r--r--spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb4
-rw-r--r--spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb14
-rw-r--r--spec/workers/analytics/usage_trends/count_job_trigger_worker_spec.rb17
-rw-r--r--spec/workers/analytics/usage_trends/counter_job_worker_spec.rb70
-rw-r--r--spec/workers/emails_on_push_worker_spec.rb36
-rw-r--r--spec/workers/error_tracking_issue_link_worker_spec.rb12
-rw-r--r--spec/workers/expire_job_cache_worker_spec.rb8
-rw-r--r--spec/workers/expire_pipeline_cache_worker_spec.rb9
-rw-r--r--spec/workers/jira_connect/sync_project_worker_spec.rb2
-rw-r--r--spec/workers/merge_requests/delete_source_branch_worker_spec.rb63
-rw-r--r--spec/workers/merge_worker_spec.rb2
-rw-r--r--spec/workers/namespaces/in_product_marketing_emails_worker_spec.rb36
-rw-r--r--spec/workers/namespaces/onboarding_issue_created_worker_spec.rb28
-rw-r--r--spec/workers/namespaces/onboarding_progress_worker_spec.rb22
-rw-r--r--spec/workers/new_issue_worker_spec.rb43
-rw-r--r--spec/workers/new_merge_request_worker_spec.rb45
-rw-r--r--spec/workers/packages/composer/cache_update_worker_spec.rb48
-rw-r--r--spec/workers/packages/maven/metadata/sync_worker_spec.rb253
-rw-r--r--spec/workers/pages_update_configuration_worker_spec.rb22
-rw-r--r--spec/workers/personal_access_tokens/expiring_worker_spec.rb15
-rw-r--r--spec/workers/post_receive_spec.rb25
-rw-r--r--spec/workers/project_schedule_bulk_repository_shard_moves_worker_spec.rb4
-rw-r--r--spec/workers/project_update_repository_storage_worker_spec.rb2
-rw-r--r--spec/workers/projects/schedule_bulk_repository_shard_moves_worker_spec.rb12
-rw-r--r--spec/workers/projects/update_repository_storage_worker_spec.rb15
-rw-r--r--spec/workers/purge_dependency_proxy_cache_worker_spec.rb22
-rw-r--r--spec/workers/snippet_schedule_bulk_repository_shard_moves_worker_spec.rb4
-rw-r--r--spec/workers/snippet_update_repository_storage_worker_spec.rb2
-rw-r--r--spec/workers/snippets/schedule_bulk_repository_shard_moves_worker_spec.rb12
-rw-r--r--spec/workers/snippets/update_repository_storage_worker_spec.rb15
1307 files changed, 42048 insertions, 19404 deletions
diff --git a/spec/benchmarks/banzai_benchmark.rb b/spec/benchmarks/banzai_benchmark.rb
index a87414ba512..4cf079b2130 100644
--- a/spec/benchmarks/banzai_benchmark.rb
+++ b/spec/benchmarks/banzai_benchmark.rb
@@ -56,7 +56,7 @@ RSpec.describe 'GitLab Markdown Benchmark', :aggregate_failures do
it 'benchmarks several pipelines' do
path = 'images/example.jpg'
gitaly_wiki_file = Gitlab::GitalyClient::WikiFile.new(path: path)
- allow(wiki).to receive(:find_file).with(path).and_return(Gitlab::Git::WikiFile.new(gitaly_wiki_file))
+ allow(wiki).to receive(:find_file).with(path, load_content: false).and_return(Gitlab::Git::WikiFile.new(gitaly_wiki_file))
allow(wiki).to receive(:wiki_base_path) { '/namespace1/gitlabhq/wikis' }
puts "\n--> Benchmarking Full, Wiki, and Plain pipelines\n"
diff --git a/spec/bin/feature_flag_spec.rb b/spec/bin/feature_flag_spec.rb
index 710b1606923..de0db8ba256 100644
--- a/spec/bin/feature_flag_spec.rb
+++ b/spec/bin/feature_flag_spec.rb
@@ -265,16 +265,9 @@ RSpec.describe 'bin/feature-flag' do
end
describe '.read_ee_only' do
- where(:type, :is_ee_only) do
- :development | false
- :licensed | true
- end
-
- with_them do
- let(:options) { OpenStruct.new(name: 'foo', type: type) }
+ let(:options) { OpenStruct.new(name: 'foo', type: :development) }
- it { expect(described_class.read_ee_only(options)).to eq(is_ee_only) }
- end
+ it { expect(described_class.read_ee_only(options)).to eq(false) }
end
end
end
diff --git a/spec/controllers/admin/application_settings_controller_spec.rb b/spec/controllers/admin/application_settings_controller_spec.rb
index 71abf3191b8..2b562e2dd64 100644
--- a/spec/controllers/admin/application_settings_controller_spec.rb
+++ b/spec/controllers/admin/application_settings_controller_spec.rb
@@ -144,10 +144,10 @@ RSpec.describe Admin::ApplicationSettingsController do
end
it 'updates repository_storages_weighted setting' do
- put :update, params: { application_setting: { repository_storages_weighted_default: 75 } }
+ put :update, params: { application_setting: { repository_storages_weighted: { default: 75 } } }
expect(response).to redirect_to(general_admin_application_settings_path)
- expect(ApplicationSetting.current.repository_storages_weighted_default).to eq(75)
+ expect(ApplicationSetting.current.repository_storages_weighted).to eq('default' => 75)
end
it 'updates kroki_formats setting' do
diff --git a/spec/controllers/admin/instance_statistics_controller_spec.rb b/spec/controllers/admin/usage_trends_controller_spec.rb
index c589e46857f..35fb005aacb 100644
--- a/spec/controllers/admin/instance_statistics_controller_spec.rb
+++ b/spec/controllers/admin/usage_trends_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Admin::InstanceStatisticsController do
+RSpec.describe Admin::UsageTrendsController do
let(:admin) { create(:user, :admin) }
before do
diff --git a/spec/controllers/concerns/spammable_actions_spec.rb b/spec/controllers/concerns/spammable_actions_spec.rb
index 25d5398c9da..7bd5a76e60c 100644
--- a/spec/controllers/concerns/spammable_actions_spec.rb
+++ b/spec/controllers/concerns/spammable_actions_spec.rb
@@ -69,8 +69,11 @@ RSpec.describe SpammableActions do
end
context 'when spammable.render_recaptcha? is true' do
+ let(:spam_log) { instance_double(SpamLog, id: 123) }
+ let(:captcha_site_key) { 'abc123' }
+
before do
- expect(spammable).to receive(:render_recaptcha?) { true }
+ expect(spammable).to receive(:render_recaptcha?).at_least(:once) { true }
end
context 'when format is :html' do
@@ -83,24 +86,24 @@ RSpec.describe SpammableActions do
context 'when format is :json' do
let(:format) { :json }
- let(:recaptcha_html) { '<recaptcha-html/>' }
- it 'renders json with recaptcha_html' do
- expect(controller).to receive(:render_to_string).with(
- {
- partial: 'shared/recaptcha_form',
- formats: :html,
- locals: {
- spammable: spammable,
- script: false,
- has_submit: false
- }
- }
- ) { recaptcha_html }
+ before do
+ expect(spammable).to receive(:spam?) { false }
+ expect(spammable).to receive(:spam_log) { spam_log }
+ expect(Gitlab::CurrentSettings).to receive(:recaptcha_site_key) { captcha_site_key }
+ end
+ it 'renders json with spam_action_response_fields' do
subject
- expect(json_response).to eq({ 'recaptcha_html' => recaptcha_html })
+ expected_json_response = HashWithIndifferentAccess.new(
+ {
+ spam: false,
+ needs_captcha_response: true,
+ spam_log_id: spam_log.id,
+ captcha_site_key: captcha_site_key
+ })
+ expect(json_response).to eq(expected_json_response)
end
end
end
diff --git a/spec/controllers/explore/projects_controller_spec.rb b/spec/controllers/explore/projects_controller_spec.rb
index cfbd129388d..a2b62aa49d2 100644
--- a/spec/controllers/explore/projects_controller_spec.rb
+++ b/spec/controllers/explore/projects_controller_spec.rb
@@ -4,6 +4,8 @@ require 'spec_helper'
RSpec.describe Explore::ProjectsController do
shared_examples 'explore projects' do
+ let(:expected_default_sort) { 'latest_activity_desc' }
+
describe 'GET #index.json' do
render_views
@@ -12,6 +14,11 @@ RSpec.describe Explore::ProjectsController do
end
it { is_expected.to respond_with(:success) }
+
+ it 'sets a default sort parameter' do
+ expect(controller.params[:sort]).to eq(expected_default_sort)
+ expect(assigns[:sort]).to eq(expected_default_sort)
+ end
end
describe 'GET #trending.json' do
@@ -22,6 +29,11 @@ RSpec.describe Explore::ProjectsController do
end
it { is_expected.to respond_with(:success) }
+
+ it 'sets a default sort parameter' do
+ expect(controller.params[:sort]).to eq(expected_default_sort)
+ expect(assigns[:sort]).to eq(expected_default_sort)
+ end
end
describe 'GET #starred.json' do
@@ -32,6 +44,11 @@ RSpec.describe Explore::ProjectsController do
end
it { is_expected.to respond_with(:success) }
+
+ it 'sets a default sort parameter' do
+ expect(controller.params[:sort]).to eq(expected_default_sort)
+ expect(assigns[:sort]).to eq(expected_default_sort)
+ end
end
describe 'GET #trending' do
diff --git a/spec/controllers/groups/boards_controller_spec.rb b/spec/controllers/groups/boards_controller_spec.rb
index a7480130e0a..6201cddecb0 100644
--- a/spec/controllers/groups/boards_controller_spec.rb
+++ b/spec/controllers/groups/boards_controller_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe Groups::BoardsController do
expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original
allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(true)
- allow(Ability).to receive(:allowed?).with(user, :read_board, group).and_return(false)
+ allow(Ability).to receive(:allowed?).with(user, :read_issue_board, group).and_return(false)
end
it 'returns a not found 404 response' do
@@ -74,7 +74,7 @@ RSpec.describe Groups::BoardsController do
expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original
allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(true)
- allow(Ability).to receive(:allowed?).with(user, :read_board, group).and_return(false)
+ allow(Ability).to receive(:allowed?).with(user, :read_issue_board, group).and_return(false)
end
it 'returns a not found 404 response' do
@@ -111,7 +111,7 @@ RSpec.describe Groups::BoardsController do
expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original
allow(Ability).to receive(:allowed?).with(user, :read_cross_project, :global).and_return(true)
allow(Ability).to receive(:allowed?).with(user, :read_group, group).and_return(true)
- allow(Ability).to receive(:allowed?).with(user, :read_board, group).and_return(false)
+ allow(Ability).to receive(:allowed?).with(user, :read_issue_board, group).and_return(false)
end
it 'returns a not found 404 response' do
diff --git a/spec/controllers/groups/clusters/applications_controller_spec.rb b/spec/controllers/groups/clusters/applications_controller_spec.rb
index c3947c27399..5629e86c928 100644
--- a/spec/controllers/groups/clusters/applications_controller_spec.rb
+++ b/spec/controllers/groups/clusters/applications_controller_spec.rb
@@ -10,7 +10,8 @@ RSpec.describe Groups::Clusters::ApplicationsController do
end
shared_examples 'a secure endpoint' do
- it { expect { subject }.to be_allowed_for(:admin) }
+ it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { subject }.to be_allowed_for(:admin) }
+ it('is denied for admin when admin mode is disabled') { expect { subject }.to be_denied_for(:admin) }
it { expect { subject }.to be_allowed_for(:owner).of(group) }
it { expect { subject }.to be_allowed_for(:maintainer).of(group) }
it { expect { subject }.to be_denied_for(:developer).of(group) }
diff --git a/spec/controllers/groups/clusters_controller_spec.rb b/spec/controllers/groups/clusters_controller_spec.rb
index b287aca1e46..1334372a1f5 100644
--- a/spec/controllers/groups/clusters_controller_spec.rb
+++ b/spec/controllers/groups/clusters_controller_spec.rb
@@ -99,7 +99,8 @@ RSpec.describe Groups::ClustersController do
describe 'security' do
let(:cluster) { create(:cluster, :provided_by_gcp, cluster_type: :group_type, groups: [group]) }
- it { expect { go }.to be_allowed_for(:admin) }
+ it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) }
+ it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(group) }
it { expect { go }.to be_allowed_for(:maintainer).of(group) }
it { expect { go }.to be_denied_for(:developer).of(group) }
@@ -183,7 +184,8 @@ RSpec.describe Groups::ClustersController do
include_examples 'GET new cluster shared examples'
describe 'security' do
- it { expect { go }.to be_allowed_for(:admin) }
+ it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) }
+ it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(group) }
it { expect { go }.to be_allowed_for(:maintainer).of(group) }
it { expect { go }.to be_denied_for(:developer).of(group) }
@@ -316,7 +318,8 @@ RSpec.describe Groups::ClustersController do
allow(WaitForClusterCreationWorker).to receive(:perform_in).and_return(nil)
end
- it { expect { go }.to be_allowed_for(:admin) }
+ it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) }
+ it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(group) }
it { expect { go }.to be_allowed_for(:maintainer).of(group) }
it { expect { go }.to be_denied_for(:developer).of(group) }
@@ -418,7 +421,8 @@ RSpec.describe Groups::ClustersController do
end
describe 'security' do
- it { expect { go }.to be_allowed_for(:admin) }
+ it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) }
+ it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(group) }
it { expect { go }.to be_allowed_for(:maintainer).of(group) }
it { expect { go }.to be_denied_for(:developer).of(group) }
@@ -486,7 +490,8 @@ RSpec.describe Groups::ClustersController do
allow(WaitForClusterCreationWorker).to receive(:perform_in)
end
- it { expect { post_create_aws }.to be_allowed_for(:admin) }
+ it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { post_create_aws }.to be_allowed_for(:admin) }
+ it('is denied for admin when admin mode is disabled') { expect { post_create_aws }.to be_denied_for(:admin) }
it { expect { post_create_aws }.to be_allowed_for(:owner).of(group) }
it { expect { post_create_aws }.to be_allowed_for(:maintainer).of(group) }
it { expect { post_create_aws }.to be_denied_for(:developer).of(group) }
@@ -544,7 +549,8 @@ RSpec.describe Groups::ClustersController do
end
end
- it { expect { go }.to be_allowed_for(:admin) }
+ it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) }
+ it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(group) }
it { expect { go }.to be_allowed_for(:maintainer).of(group) }
it { expect { go }.to be_denied_for(:developer).of(group) }
@@ -580,7 +586,8 @@ RSpec.describe Groups::ClustersController do
end
describe 'security' do
- it { expect { go }.to be_allowed_for(:admin) }
+ it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) }
+ it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(group) }
it { expect { go }.to be_allowed_for(:maintainer).of(group) }
it { expect { go }.to be_denied_for(:developer).of(group) }
@@ -619,7 +626,8 @@ RSpec.describe Groups::ClustersController do
end
describe 'security' do
- it { expect { go }.to be_allowed_for(:admin) }
+ it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) }
+ it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(group) }
it { expect { go }.to be_allowed_for(:maintainer).of(group) }
it { expect { go }.to be_denied_for(:developer).of(group) }
@@ -651,7 +659,8 @@ RSpec.describe Groups::ClustersController do
end
describe 'security' do
- it { expect { go }.to be_allowed_for(:admin) }
+ it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) }
+ it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(group) }
it { expect { go }.to be_allowed_for(:maintainer).of(group) }
it { expect { go }.to be_denied_for(:developer).of(group) }
@@ -759,7 +768,8 @@ RSpec.describe Groups::ClustersController do
describe 'security' do
let_it_be(:cluster) { create(:cluster, :provided_by_gcp, cluster_type: :group_type, groups: [group]) }
- it { expect { go }.to be_allowed_for(:admin) }
+ it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) }
+ it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(group) }
it { expect { go }.to be_allowed_for(:maintainer).of(group) }
it { expect { go }.to be_denied_for(:developer).of(group) }
@@ -827,7 +837,8 @@ RSpec.describe Groups::ClustersController do
describe 'security' do
let_it_be(:cluster) { create(:cluster, :provided_by_gcp, :production_environment, cluster_type: :group_type, groups: [group]) }
- it { expect { go }.to be_allowed_for(:admin) }
+ it('is allowed for admin when admin mode is enabled', :enable_admin_mode) { expect { go }.to be_allowed_for(:admin) }
+ it('is denied for admin when admin mode is disabled') { expect { go }.to be_denied_for(:admin) }
it { expect { go }.to be_allowed_for(:owner).of(group) }
it { expect { go }.to be_allowed_for(:maintainer).of(group) }
it { expect { go }.to be_denied_for(:developer).of(group) }
diff --git a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
index 39cbdfb9123..83775dcdbdf 100644
--- a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
+++ b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
@@ -130,7 +130,7 @@ RSpec.describe Groups::DependencyProxyForContainersController do
}
end
- it 'proxies status from the remote token request' do
+ it 'proxies status from the remote token request', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:service_unavailable)
@@ -147,7 +147,7 @@ RSpec.describe Groups::DependencyProxyForContainersController do
}
end
- it 'proxies status from the remote manifest request' do
+ it 'proxies status from the remote manifest request', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:bad_request)
@@ -156,7 +156,7 @@ RSpec.describe Groups::DependencyProxyForContainersController do
end
it 'sends a file' do
- expect(controller).to receive(:send_file).with(manifest.file.path, {})
+ expect(controller).to receive(:send_file).with(manifest.file.path, type: manifest.content_type)
subject
end
@@ -165,6 +165,10 @@ RSpec.describe Groups::DependencyProxyForContainersController do
subject
expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers['Docker-Content-Digest']).to eq(manifest.digest)
+ expect(response.headers['Content-Length']).to eq(manifest.size)
+ expect(response.headers['Docker-Distribution-Api-Version']).to eq(DependencyProxy::DISTRIBUTION_API_VERSION)
+ expect(response.headers['Etag']).to eq("\"#{manifest.digest}\"")
expect(response.headers['Content-Disposition']).to match(/^attachment/)
end
end
@@ -207,7 +211,7 @@ RSpec.describe Groups::DependencyProxyForContainersController do
}
end
- it 'proxies status from the remote blob request' do
+ it 'proxies status from the remote blob request', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:bad_request)
@@ -221,7 +225,7 @@ RSpec.describe Groups::DependencyProxyForContainersController do
subject
end
- it 'returns Content-Disposition: attachment' do
+ it 'returns Content-Disposition: attachment', :aggregate_failures do
subject
expect(response).to have_gitlab_http_status(:ok)
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index 9e5f68820d9..cce61c4534b 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -4,17 +4,23 @@ require 'spec_helper'
RSpec.describe GroupsController, factory_default: :keep do
include ExternalAuthorizationServiceHelpers
+ include AdminModeHelper
let_it_be_with_refind(:group) { create_default(:group, :public) }
let_it_be_with_refind(:project) { create(:project, namespace: group) }
let_it_be(:user) { create(:user) }
- let_it_be(:admin) { create(:admin) }
+ let_it_be(:admin_with_admin_mode) { create(:admin) }
+ let_it_be(:admin_without_admin_mode) { create(:admin) }
let_it_be(:group_member) { create(:group_member, group: group, user: user) }
let_it_be(:owner) { group.add_owner(create(:user)).user }
let_it_be(:maintainer) { group.add_maintainer(create(:user)).user }
let_it_be(:developer) { group.add_developer(create(:user)).user }
let_it_be(:guest) { group.add_guest(create(:user)).user }
+ before do
+ enable_admin_mode!(admin_with_admin_mode)
+ end
+
shared_examples 'member with ability to create subgroups' do
it 'renders the new page' do
sign_in(member)
@@ -105,10 +111,10 @@ RSpec.describe GroupsController, factory_default: :keep do
[true, false].each do |can_create_group_status|
context "and can_create_group is #{can_create_group_status}" do
before do
- User.where(id: [admin, owner, maintainer, developer, guest]).update_all(can_create_group: can_create_group_status)
+ User.where(id: [admin_with_admin_mode, admin_without_admin_mode, owner, maintainer, developer, guest]).update_all(can_create_group: can_create_group_status)
end
- [:admin, :owner, :maintainer].each do |member_type|
+ [:admin_with_admin_mode, :owner, :maintainer].each do |member_type|
context "and logged in as #{member_type.capitalize}" do
it_behaves_like 'member with ability to create subgroups' do
let(:member) { send(member_type) }
@@ -116,7 +122,7 @@ RSpec.describe GroupsController, factory_default: :keep do
end
end
- [:guest, :developer].each do |member_type|
+ [:guest, :developer, :admin_without_admin_mode].each do |member_type|
context "and logged in as #{member_type.capitalize}" do
it_behaves_like 'member without ability to create subgroups' do
let(:member) { send(member_type) }
@@ -856,6 +862,12 @@ RSpec.describe GroupsController, factory_default: :keep do
end
describe 'POST #export' do
+ let(:admin) { create(:admin) }
+
+ before do
+ enable_admin_mode!(admin)
+ end
+
context 'when the group export feature flag is not enabled' do
before do
sign_in(admin)
@@ -918,6 +930,12 @@ RSpec.describe GroupsController, factory_default: :keep do
end
describe 'GET #download_export' do
+ let(:admin) { create(:admin) }
+
+ before do
+ enable_admin_mode!(admin)
+ end
+
context 'when there is a file available to download' do
let(:export_file) { fixture_file_upload('spec/fixtures/group_export.tar.gz') }
@@ -934,8 +952,6 @@ RSpec.describe GroupsController, factory_default: :keep do
end
context 'when there is no file available to download' do
- let(:admin) { create(:admin) }
-
before do
sign_in(admin)
end
diff --git a/spec/controllers/help_controller_spec.rb b/spec/controllers/help_controller_spec.rb
index 629d9b50d73..71d9cab7280 100644
--- a/spec/controllers/help_controller_spec.rb
+++ b/spec/controllers/help_controller_spec.rb
@@ -132,6 +132,18 @@ RSpec.describe HelpController do
expect(response).to redirect_to(new_user_session_path)
end
end
+
+ context 'when two factor is required' do
+ before do
+ stub_two_factor_required
+ end
+
+ it 'does not redirect to two factor auth' do
+ get :index
+
+ expect(response).not_to redirect_to(profile_two_factor_auth_path)
+ end
+ end
end
describe 'GET #show' do
@@ -152,6 +164,16 @@ RSpec.describe HelpController do
end
it_behaves_like 'documentation pages local render'
+
+ context 'when two factor is required' do
+ before do
+ stub_two_factor_required
+ end
+
+ it 'does not redirect to two factor auth' do
+ expect(response).not_to redirect_to(profile_two_factor_auth_path)
+ end
+ end
end
context 'when a custom help_page_documentation_url is set in database' do
@@ -254,4 +276,9 @@ RSpec.describe HelpController do
def stub_readme(content)
expect_file_read(Rails.root.join('doc', 'README.md'), content: content)
end
+
+ def stub_two_factor_required
+ allow(controller).to receive(:two_factor_authentication_required?).and_return(true)
+ allow(controller).to receive(:current_user_requires_two_factor?).and_return(true)
+ end
end
diff --git a/spec/controllers/import/bulk_imports_controller_spec.rb b/spec/controllers/import/bulk_imports_controller_spec.rb
index 08a54f112bb..b450318f6f7 100644
--- a/spec/controllers/import/bulk_imports_controller_spec.rb
+++ b/spec/controllers/import/bulk_imports_controller_spec.rb
@@ -123,7 +123,7 @@ RSpec.describe Import::BulkImportsController do
it 'denies network request' do
get :status
- expect(controller).to redirect_to(new_group_path)
+ expect(controller).to redirect_to(new_group_path(anchor: 'import-group-pane'))
expect(flash[:alert]).to eq('Specified URL cannot be used: "Only allowed schemes are http, https"')
end
end
@@ -184,9 +184,15 @@ RSpec.describe Import::BulkImportsController do
end
describe 'POST create' do
- let(:instance_url) { "http://fake-intance" }
+ let(:instance_url) { "http://fake-instance" }
let(:bulk_import) { create(:bulk_import) }
let(:pat) { "fake-pat" }
+ let(:bulk_import_params) do
+ [{ "source_type" => "group_entity",
+ "source_full_path" => "full_path",
+ "destination_name" => "destination_name",
+ "destination_namespace" => "root" }]
+ end
before do
session[:bulk_import_gitlab_access_token] = pat
@@ -194,15 +200,9 @@ RSpec.describe Import::BulkImportsController do
end
it 'executes BulkImportService' do
- bulk_import_params = [{ "source_type" => "group_entity",
- "source_full_path" => "full_path",
- "destination_name" =>
- "destination_name",
- "destination_namespace" => "root" }]
-
expect_next_instance_of(
BulkImportService, user, bulk_import_params, { url: instance_url, access_token: pat }) do |service|
- allow(service).to receive(:execute).and_return(bulk_import)
+ allow(service).to receive(:execute).and_return(ServiceResponse.success(payload: bulk_import))
end
post :create, params: { bulk_import: bulk_import_params }
@@ -210,6 +210,19 @@ RSpec.describe Import::BulkImportsController do
expect(response).to have_gitlab_http_status(:ok)
expect(response.body).to eq({ id: bulk_import.id }.to_json)
end
+
+ it 'returns error when validation fails' do
+ error_response = ServiceResponse.error(message: 'Record invalid', http_status: :unprocessable_entity)
+ expect_next_instance_of(
+ BulkImportService, user, bulk_import_params, { url: instance_url, access_token: pat }) do |service|
+ allow(service).to receive(:execute).and_return(error_response)
+ end
+
+ post :create, params: { bulk_import: bulk_import_params }
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ expect(response.body).to eq({ error: 'Record invalid' }.to_json)
+ end
end
end
diff --git a/spec/controllers/notification_settings_controller_spec.rb b/spec/controllers/notification_settings_controller_spec.rb
deleted file mode 100644
index c4d67df15f7..00000000000
--- a/spec/controllers/notification_settings_controller_spec.rb
+++ /dev/null
@@ -1,202 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe NotificationSettingsController do
- let(:project) { create(:project) }
- let(:group) { create(:group, :internal) }
- let(:user) { create(:user) }
-
- before do
- project.add_developer(user)
- end
-
- describe '#create' do
- context 'when not authorized' do
- it 'redirects to sign in page' do
- post :create,
- params: {
- project_id: project.id,
- notification_setting: { level: :participating }
- }
-
- expect(response).to redirect_to(new_user_session_path)
- end
- end
-
- context 'when authorized' do
- let(:notification_setting) { user.notification_settings_for(source) }
- let(:custom_events) do
- events = {}
-
- NotificationSetting.email_events(source).each do |event|
- events[event.to_s] = true
- end
-
- events
- end
-
- before do
- sign_in(user)
- end
-
- context 'for projects' do
- let(:source) { project }
-
- it 'creates notification setting' do
- post :create,
- params: {
- project_id: project.id,
- notification_setting: { level: :participating }
- }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(notification_setting.level).to eq("participating")
- expect(notification_setting.user_id).to eq(user.id)
- expect(notification_setting.source_id).to eq(project.id)
- expect(notification_setting.source_type).to eq("Project")
- end
-
- context 'with custom settings' do
- it 'creates notification setting' do
- post :create,
- params: {
- project_id: project.id,
- notification_setting: { level: :custom }.merge(custom_events)
- }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(notification_setting.level).to eq("custom")
-
- custom_events.each do |event, value|
- expect(notification_setting.event_enabled?(event)).to eq(value)
- end
- end
- end
- end
-
- context 'for groups' do
- let(:source) { group }
-
- it 'creates notification setting' do
- post :create,
- params: {
- namespace_id: group.id,
- notification_setting: { level: :watch }
- }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(notification_setting.level).to eq("watch")
- expect(notification_setting.user_id).to eq(user.id)
- expect(notification_setting.source_id).to eq(group.id)
- expect(notification_setting.source_type).to eq("Namespace")
- end
-
- context 'with custom settings' do
- it 'creates notification setting' do
- post :create,
- params: {
- namespace_id: group.id,
- notification_setting: { level: :custom }.merge(custom_events)
- }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(notification_setting.level).to eq("custom")
-
- custom_events.each do |event, value|
- expect(notification_setting.event_enabled?(event)).to eq(value)
- end
- end
- end
- end
- end
-
- context 'not authorized' do
- let(:private_project) { create(:project, :private) }
-
- before do
- sign_in(user)
- end
-
- it 'returns 404' do
- post :create,
- params: {
- project_id: private_project.id,
- notification_setting: { level: :participating }
- }
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
- end
-
- describe '#update' do
- let(:notification_setting) { user.global_notification_setting }
-
- context 'when not authorized' do
- it 'redirects to sign in page' do
- put :update,
- params: {
- id: notification_setting,
- notification_setting: { level: :participating }
- }
-
- expect(response).to redirect_to(new_user_session_path)
- end
- end
-
- context 'when authorized' do
- before do
- sign_in(user)
- end
-
- it 'returns success' do
- put :update,
- params: {
- id: notification_setting,
- notification_setting: { level: :participating }
- }
-
- expect(response).to have_gitlab_http_status(:ok)
- end
-
- context 'and setting custom notification setting' do
- let(:custom_events) do
- events = {}
-
- notification_setting.email_events.each do |event|
- events[event] = "true"
- end
- end
-
- it 'returns success' do
- put :update,
- params: {
- id: notification_setting,
- notification_setting: { level: :participating, events: custom_events }
- }
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
- end
-
- context 'not authorized' do
- let(:other_user) { create(:user) }
-
- before do
- sign_in(other_user)
- end
-
- it 'returns 404' do
- put :update,
- params: {
- id: notification_setting,
- notification_setting: { level: :participating }
- }
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
- end
-end
diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb
index 68551ce4858..c9a76049e19 100644
--- a/spec/controllers/projects/blob_controller_spec.rb
+++ b/spec/controllers/projects/blob_controller_spec.rb
@@ -20,8 +20,8 @@ RSpec.describe Projects::BlobController do
project.add_maintainer(user)
sign_in(user)
- stub_experiment(ci_syntax_templates: experiment_active)
- stub_experiment_for_subject(ci_syntax_templates: in_experiment_group)
+ stub_experiment(ci_syntax_templates_b: experiment_active)
+ stub_experiment_for_subject(ci_syntax_templates_b: in_experiment_group)
end
context 'when the experiment is not active' do
@@ -35,48 +35,62 @@ RSpec.describe Projects::BlobController do
end
end
- context 'when the experiment is active and the user is in the control group' do
+ context 'when the experiment is active' do
let(:experiment_active) { true }
- let(:in_experiment_group) { false }
-
- it 'records the experiment user in the control group' do
- expect(Experiment).to receive(:add_user)
- .with(:ci_syntax_templates, :control, user, namespace_id: project.namespace_id)
- request
- end
- end
+ context 'when the user is in the control group' do
+ let(:in_experiment_group) { false }
- context 'when the experiment is active and the user is in the experimental group' do
- let(:experiment_active) { true }
- let(:in_experiment_group) { true }
-
- it 'records the experiment user in the experimental group' do
- expect(Experiment).to receive(:add_user)
- .with(:ci_syntax_templates, :experimental, user, namespace_id: project.namespace_id)
+ it 'records the experiment user in the control group' do
+ expect(Experiment).to receive(:add_user)
+ .with(:ci_syntax_templates_b, :control, user, namespace_id: project.namespace_id)
- request
+ request
+ end
end
- context 'when requesting a non default config file type' do
- let(:file_name) { '.non_default_ci_config' }
- let(:project) { create(:project, :public, :repository, ci_config_path: file_name) }
+ context 'when the user is in the experimental group' do
+ let(:in_experiment_group) { true }
it 'records the experiment user in the experimental group' do
expect(Experiment).to receive(:add_user)
- .with(:ci_syntax_templates, :experimental, user, namespace_id: project.namespace_id)
+ .with(:ci_syntax_templates_b, :experimental, user, namespace_id: project.namespace_id)
request
end
- end
- context 'when requesting a different file type' do
- let(:file_name) { '.gitignore' }
+ context 'when requesting a non default config file type' do
+ let(:file_name) { '.non_default_ci_config' }
+ let(:project) { create(:project, :public, :repository, ci_config_path: file_name) }
- it 'does not record the experiment user' do
- expect(Experiment).not_to receive(:add_user)
+ it 'records the experiment user in the experimental group' do
+ expect(Experiment).to receive(:add_user)
+ .with(:ci_syntax_templates_b, :experimental, user, namespace_id: project.namespace_id)
- request
+ request
+ end
+ end
+
+ context 'when requesting a different file type' do
+ let(:file_name) { '.gitignore' }
+
+ it 'does not record the experiment user' do
+ expect(Experiment).not_to receive(:add_user)
+
+ request
+ end
+ end
+
+ context 'when the group is created longer than 90 days ago' do
+ before do
+ project.namespace.update_attribute(:created_at, 91.days.ago)
+ end
+
+ it 'does not record the experiment user' do
+ expect(Experiment).not_to receive(:add_user)
+
+ request
+ end
end
end
end
diff --git a/spec/controllers/projects/boards_controller_spec.rb b/spec/controllers/projects/boards_controller_spec.rb
index 1ed61e0990f..cde3a8d4761 100644
--- a/spec/controllers/projects/boards_controller_spec.rb
+++ b/spec/controllers/projects/boards_controller_spec.rb
@@ -34,7 +34,7 @@ RSpec.describe Projects::BoardsController do
before do
expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original
allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true)
- allow(Ability).to receive(:allowed?).with(user, :read_board, project).and_return(false)
+ allow(Ability).to receive(:allowed?).with(user, :read_issue_board, project).and_return(false)
end
it 'returns a not found 404 response' do
@@ -78,7 +78,7 @@ RSpec.describe Projects::BoardsController do
before do
expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original
allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true)
- allow(Ability).to receive(:allowed?).with(user, :read_board, project).and_return(false)
+ allow(Ability).to receive(:allowed?).with(user, :read_issue_board, project).and_return(false)
end
it 'returns a not found 404 response' do
@@ -134,7 +134,7 @@ RSpec.describe Projects::BoardsController do
before do
expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original
allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true)
- allow(Ability).to receive(:allowed?).with(user, :read_board, project).and_return(false)
+ allow(Ability).to receive(:allowed?).with(user, :read_issue_board, project).and_return(false)
end
it 'returns a not found 404 response' do
@@ -172,7 +172,7 @@ RSpec.describe Projects::BoardsController do
before do
expect(Ability).to receive(:allowed?).with(user, :log_in, :global).and_call_original
allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(true)
- allow(Ability).to receive(:allowed?).with(user, :read_board, project).and_return(false)
+ allow(Ability).to receive(:allowed?).with(user, :read_issue_board, project).and_return(false)
end
it 'returns a not found 404 response' do
diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb
index 14a5e7da7d2..a99db2664a7 100644
--- a/spec/controllers/projects/branches_controller_spec.rb
+++ b/spec/controllers/projects/branches_controller_spec.rb
@@ -648,7 +648,9 @@ RSpec.describe Projects::BranchesController do
end
it 'sets active and stale branches' do
- expect(assigns[:active_branches]).to eq([])
+ expect(assigns[:active_branches].map(&:name)).not_to include(
+ "feature", "improve/awesome", "merge-test", "markdown", "feature_conflict", "'test'"
+ )
expect(assigns[:stale_branches].map(&:name)).to eq(
["feature", "improve/awesome", "merge-test", "markdown", "feature_conflict", "'test'"]
)
@@ -660,7 +662,9 @@ RSpec.describe Projects::BranchesController do
end
it 'sets active and stale branches' do
- expect(assigns[:active_branches]).to eq([])
+ expect(assigns[:active_branches].map(&:name)).not_to include(
+ "feature", "improve/awesome", "merge-test", "markdown", "feature_conflict", "'test'"
+ )
expect(assigns[:stale_branches].map(&:name)).to eq(
["feature", "improve/awesome", "merge-test", "markdown", "feature_conflict", "'test'"]
)
diff --git a/spec/controllers/projects/ci/daily_build_group_report_results_controller_spec.rb b/spec/controllers/projects/ci/daily_build_group_report_results_controller_spec.rb
index 81318b49cd9..3c4376909f8 100644
--- a/spec/controllers/projects/ci/daily_build_group_report_results_controller_spec.rb
+++ b/spec/controllers/projects/ci/daily_build_group_report_results_controller_spec.rb
@@ -4,29 +4,25 @@ require 'spec_helper'
RSpec.describe Projects::Ci::DailyBuildGroupReportResultsController do
describe 'GET index' do
- let(:project) { create(:project, :public, :repository) }
- let(:ref_path) { 'refs/heads/master' }
- let(:param_type) { 'coverage' }
- let(:start_date) { '2019-12-10' }
- let(:end_date) { '2020-03-09' }
- let(:allowed_to_read) { true }
- let(:user) { create(:user) }
- let(:feature_enabled?) { true }
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:ref_path) { 'refs/heads/master' }
+ let_it_be(:param_type) { 'coverage' }
+ let_it_be(:start_date) { '2019-12-10' }
+ let_it_be(:end_date) { '2020-03-09' }
+ let_it_be(:allowed_to_read) { true }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:rspec_coverage_1) { create_daily_coverage('rspec', 79.0, '2020-03-09') }
+ let_it_be(:rspec_coverage_2) { create_daily_coverage('rspec', 77.0, '2020-03-08') }
+ let_it_be(:karma_coverage) { create_daily_coverage('karma', 81.0, '2019-12-10') }
+ let_it_be(:minitest_coverage) { create_daily_coverage('minitest', 67.0, '2019-12-09') }
+ let_it_be(:mocha_coverage) { create_daily_coverage('mocha', 71.0, '2019-12-09') }
before do
- create_daily_coverage('rspec', 79.0, '2020-03-09')
- create_daily_coverage('rspec', 77.0, '2020-03-08')
- create_daily_coverage('karma', 81.0, '2019-12-10')
- create_daily_coverage('minitest', 67.0, '2019-12-09')
- create_daily_coverage('mocha', 71.0, '2019-12-09')
-
sign_in(user)
allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?).with(user, :read_build_report_results, project).and_return(allowed_to_read)
- stub_feature_flags(coverage_data_new_finder: feature_enabled?)
-
get :index, params: {
namespace_id: project.namespace,
project_id: project,
@@ -140,33 +136,13 @@ RSpec.describe Projects::Ci::DailyBuildGroupReportResultsController do
context 'when format is JSON' do
let(:format) { :json }
- context 'when coverage_data_new_finder flag is enabled' do
- let(:feature_enabled?) { true }
-
- it_behaves_like 'JSON results'
- end
-
- context 'when coverage_data_new_finder flag is disabled' do
- let(:feature_enabled?) { false }
-
- it_behaves_like 'JSON results'
- end
+ it_behaves_like 'JSON results'
end
context 'when format is CSV' do
let(:format) { :csv }
- context 'when coverage_data_new_finder flag is enabled' do
- let(:feature_enabled?) { true }
-
- it_behaves_like 'CSV results'
- end
-
- context 'when coverage_data_new_finder flag is disabled' do
- let(:feature_enabled?) { false }
-
- it_behaves_like 'CSV results'
- end
+ it_behaves_like 'CSV results'
end
end
diff --git a/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb b/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb
index 1bf6ff95c44..942402a6d00 100644
--- a/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb
+++ b/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb
@@ -36,18 +36,5 @@ RSpec.describe Projects::Ci::PipelineEditorController do
expect(response).to have_gitlab_http_status(:not_found)
end
end
-
- context 'when ci_pipeline_editor_page feature flag is disabled' do
- before do
- stub_feature_flags(ci_pipeline_editor_page: false)
- project.add_developer(user)
-
- get :show, params: { namespace_id: project.namespace, project_id: project }
- end
-
- it 'responds with 404' do
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
end
end
diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb
index 706bf787b2d..2d7f036be21 100644
--- a/spec/controllers/projects/commit_controller_spec.rb
+++ b/spec/controllers/projects/commit_controller_spec.rb
@@ -9,6 +9,8 @@ RSpec.describe Projects::CommitController do
let(:commit) { project.commit("master") }
let(:master_pickable_sha) { '7d3b0f7cff5f37573aea97cebfd5692ea1689924' }
let(:master_pickable_commit) { project.commit(master_pickable_sha) }
+ let(:pipeline) { create(:ci_pipeline, project: project, ref: project.default_branch, sha: commit.sha, status: :running) }
+ let(:build) { create(:ci_build, pipeline: pipeline, status: :running) }
before do
sign_in(user)
@@ -33,6 +35,19 @@ RSpec.describe Projects::CommitController do
expect(response).to be_ok
end
+
+ context 'when a pipeline job is running' do
+ before do
+ build.run
+ end
+
+ it 'defines last pipeline information' do
+ go(id: commit.id)
+
+ expect(assigns(:last_pipeline)).to have_attributes(id: pipeline.id, status: 'running')
+ expect(assigns(:last_pipeline_stages)).not_to be_empty
+ end
+ end
end
context 'with invalid id' do
@@ -363,15 +378,22 @@ RSpec.describe Projects::CommitController do
context 'when the commit exists' do
context 'when the commit has pipelines' do
before do
- create(:ci_pipeline, project: project, sha: commit.id)
+ build.run
end
context 'when rendering a HTML format' do
- it 'shows pipelines' do
+ before do
get_pipelines(id: commit.id)
+ end
+ it 'shows pipelines' do
expect(response).to be_ok
end
+
+ it 'defines last pipeline information' do
+ expect(assigns(:last_pipeline)).to have_attributes(id: pipeline.id, status: 'running')
+ expect(assigns(:last_pipeline_stages)).not_to be_empty
+ end
end
context 'when rendering a JSON format' do
diff --git a/spec/controllers/projects/compare_controller_spec.rb b/spec/controllers/projects/compare_controller_spec.rb
index 6aa4bfe235b..80a6d3960cd 100644
--- a/spec/controllers/projects/compare_controller_spec.rb
+++ b/spec/controllers/projects/compare_controller_spec.rb
@@ -3,8 +3,21 @@
require 'spec_helper'
RSpec.describe Projects::CompareController do
- let(:project) { create(:project, :repository) }
- let(:user) { create(:user) }
+ include ProjectForksHelper
+
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:project) { create(:project, :repository, :public) }
+ let_it_be(:user) { create(:user) }
+
+ let(:private_fork) { fork_project(project, nil, repository: true).tap { |fork| fork.update!(visibility: 'private') } }
+ let(:public_fork) do
+ fork_project(project, nil, repository: true).tap do |fork|
+ fork.update!(visibility: 'public')
+ # Create a reference that only exists in this project
+ fork.repository.create_ref('refs/heads/improve/awesome', 'refs/heads/improve/more-awesome')
+ end
+ end
before do
sign_in(user)
@@ -32,18 +45,20 @@ RSpec.describe Projects::CompareController do
{
namespace_id: project.namespace,
project_id: project,
- from: source_ref,
- to: target_ref,
+ from_project_id: from_project_id,
+ from: from_ref,
+ to: to_ref,
w: whitespace
}
end
let(:whitespace) { nil }
- context 'when the refs exist' do
+ context 'when the refs exist in the same project' do
context 'when we set the white space param' do
- let(:source_ref) { "08f22f25" }
- let(:target_ref) { "66eceea0" }
+ let(:from_project_id) { nil }
+ let(:from_ref) { '08f22f25' }
+ let(:to_ref) { '66eceea0' }
let(:whitespace) { 1 }
it 'shows some diffs with ignore whitespace change option' do
@@ -60,8 +75,9 @@ RSpec.describe Projects::CompareController do
end
context 'when we do not set the white space param' do
- let(:source_ref) { "improve%2Fawesome" }
- let(:target_ref) { "feature" }
+ let(:from_project_id) { nil }
+ let(:from_ref) { 'improve%2Fawesome' }
+ let(:to_ref) { 'feature' }
let(:whitespace) { nil }
it 'sets the diffs and commits ivars' do
@@ -74,9 +90,40 @@ RSpec.describe Projects::CompareController do
end
end
+ context 'when the refs exist in different projects that the user can see' do
+ let(:from_project_id) { public_fork.id }
+ let(:from_ref) { 'improve%2Fmore-awesome' }
+ let(:to_ref) { 'feature' }
+ let(:whitespace) { nil }
+
+ it 'shows the diff' do
+ show_request
+
+ expect(response).to be_successful
+ expect(assigns(:diffs).diff_files.first).not_to be_nil
+ expect(assigns(:commits).length).to be >= 1
+ end
+ end
+
+ context 'when the refs exist in different projects but the user cannot see' do
+ let(:from_project_id) { private_fork.id }
+ let(:from_ref) { 'improve%2Fmore-awesome' }
+ let(:to_ref) { 'feature' }
+ let(:whitespace) { nil }
+
+ it 'does not show the diff' do
+ show_request
+
+ expect(response).to be_successful
+ expect(assigns(:diffs)).to be_empty
+ expect(assigns(:commits)).to be_empty
+ end
+ end
+
context 'when the source ref does not exist' do
- let(:source_ref) { 'non-existent-source-ref' }
- let(:target_ref) { "feature" }
+ let(:from_project_id) { nil }
+ let(:from_ref) { 'non-existent-source-ref' }
+ let(:to_ref) { 'feature' }
it 'sets empty diff and commit ivars' do
show_request
@@ -88,8 +135,9 @@ RSpec.describe Projects::CompareController do
end
context 'when the target ref does not exist' do
- let(:target_ref) { 'non-existent-target-ref' }
- let(:source_ref) { "improve%2Fawesome" }
+ let(:from_project_id) { nil }
+ let(:from_ref) { 'improve%2Fawesome' }
+ let(:to_ref) { 'non-existent-target-ref' }
it 'sets empty diff and commit ivars' do
show_request
@@ -101,8 +149,9 @@ RSpec.describe Projects::CompareController do
end
context 'when the target ref is invalid' do
- let(:target_ref) { "master%' AND 2554=4423 AND '%'='" }
- let(:source_ref) { "improve%2Fawesome" }
+ let(:from_project_id) { nil }
+ let(:from_ref) { 'improve%2Fawesome' }
+ let(:to_ref) { "master%' AND 2554=4423 AND '%'='" }
it 'shows a flash message and redirects' do
show_request
@@ -113,8 +162,9 @@ RSpec.describe Projects::CompareController do
end
context 'when the source ref is invalid' do
- let(:source_ref) { "master%' AND 2554=4423 AND '%'='" }
- let(:target_ref) { "improve%2Fawesome" }
+ let(:from_project_id) { nil }
+ let(:from_ref) { "master%' AND 2554=4423 AND '%'='" }
+ let(:to_ref) { 'improve%2Fawesome' }
it 'shows a flash message and redirects' do
show_request
@@ -126,24 +176,33 @@ RSpec.describe Projects::CompareController do
end
describe 'GET diff_for_path' do
- def diff_for_path(extra_params = {})
- params = {
+ subject(:diff_for_path_request) { get :diff_for_path, params: request_params }
+
+ let(:request_params) do
+ {
+ from_project_id: from_project_id,
+ from: from_ref,
+ to: to_ref,
namespace_id: project.namespace,
- project_id: project
+ project_id: project,
+ old_path: old_path,
+ new_path: new_path
}
-
- get :diff_for_path, params: params.merge(extra_params)
end
let(:existing_path) { 'files/ruby/feature.rb' }
- let(:source_ref) { "improve%2Fawesome" }
- let(:target_ref) { "feature" }
- context 'when the source and target refs exist' do
+ let(:from_project_id) { nil }
+ let(:from_ref) { 'improve%2Fawesome' }
+ let(:to_ref) { 'feature' }
+ let(:old_path) { existing_path }
+ let(:new_path) { existing_path }
+
+ context 'when the source and target refs exist in the same project' do
context 'when the user has access target the project' do
context 'when the path exists in the diff' do
it 'disables diff notes' do
- diff_for_path(from: source_ref, to: target_ref, old_path: existing_path, new_path: existing_path)
+ diff_for_path_request
expect(assigns(:diff_notes_disabled)).to be_truthy
end
@@ -154,16 +213,17 @@ RSpec.describe Projects::CompareController do
meth.call(diffs)
end
- diff_for_path(from: source_ref, to: target_ref, old_path: existing_path, new_path: existing_path)
+ diff_for_path_request
end
end
context 'when the path does not exist in the diff' do
- before do
- diff_for_path(from: source_ref, to: target_ref, old_path: existing_path.succ, new_path: existing_path.succ)
- end
+ let(:old_path) { existing_path.succ }
+ let(:new_path) { existing_path.succ }
it 'returns a 404' do
+ diff_for_path_request
+
expect(response).to have_gitlab_http_status(:not_found)
end
end
@@ -172,31 +232,56 @@ RSpec.describe Projects::CompareController do
context 'when the user does not have access target the project' do
before do
project.team.truncate
- diff_for_path(from: source_ref, to: target_ref, old_path: existing_path, new_path: existing_path)
end
it 'returns a 404' do
+ diff_for_path_request
+
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
- context 'when the source ref does not exist' do
- before do
- diff_for_path(from: source_ref.succ, to: target_ref, old_path: existing_path, new_path: existing_path)
+ context 'when the source and target refs exist in different projects and the user can see' do
+ let(:from_project_id) { public_fork.id }
+ let(:from_ref) { 'improve%2Fmore-awesome' }
+
+ it 'shows the diff for that path' do
+ expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs|
+ expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path)
+ meth.call(diffs)
+ end
+
+ diff_for_path_request
+ end
+ end
+
+ context 'when the source and target refs exist in different projects and the user cannot see' do
+ let(:from_project_id) { private_fork.id }
+
+ it 'does not show the diff for that path' do
+ diff_for_path_request
+
+ expect(response).to have_gitlab_http_status(:not_found)
end
+ end
+
+ context 'when the source ref does not exist' do
+ let(:from_ref) { 'this-ref-does-not-exist' }
it 'returns a 404' do
+ diff_for_path_request
+
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when the target ref does not exist' do
- before do
- diff_for_path(from: source_ref, to: target_ref.succ, old_path: existing_path, new_path: existing_path)
- end
+ let(:to_ref) { 'this-ref-does-not-exist' }
it 'returns a 404' do
+ diff_for_path_request
+
expect(response).to have_gitlab_http_status(:not_found)
end
end
@@ -209,53 +294,54 @@ RSpec.describe Projects::CompareController do
{
namespace_id: project.namespace,
project_id: project,
- from: source_ref,
- to: target_ref
+ from_project_id: from_project_id,
+ from: from_ref,
+ to: to_ref
}
end
context 'when sending valid params' do
- let(:source_ref) { "improve%2Fawesome" }
- let(:target_ref) { "feature" }
+ let(:from_ref) { 'awesome%2Ffeature' }
+ let(:to_ref) { 'feature' }
- it 'redirects back to show' do
- create_request
-
- expect(response).to redirect_to(project_compare_path(project, to: target_ref, from: source_ref))
- end
- end
+ context 'without a from_project_id' do
+ let(:from_project_id) { nil }
- context 'when sending invalid params' do
- context 'when the source ref is empty and target ref is set' do
- let(:source_ref) { '' }
- let(:target_ref) { 'master' }
-
- it 'redirects back to index and preserves the target ref' do
+ it 'redirects to the show page' do
create_request
- expect(response).to redirect_to(project_compare_index_path(project, to: target_ref))
+ expect(response).to redirect_to(project_compare_path(project, from: from_ref, to: to_ref))
end
end
- context 'when the target ref is empty and source ref is set' do
- let(:source_ref) { 'master' }
- let(:target_ref) { '' }
+ context 'with a from_project_id' do
+ let(:from_project_id) { 'something or another' }
- it 'redirects back to index and preserves source ref' do
+ it 'redirects to the show page without interpreting from_project_id' do
create_request
- expect(response).to redirect_to(project_compare_index_path(project, from: source_ref))
+ expect(response).to redirect_to(project_compare_path(project, from: from_ref, to: to_ref, from_project_id: from_project_id))
end
end
+ end
+
+ context 'when sending invalid params' do
+ where(:from_ref, :to_ref, :from_project_id, :expected_redirect_params) do
+ '' | '' | '' | {}
+ 'main' | '' | '' | { from: 'main' }
+ '' | 'main' | '' | { to: 'main' }
+ '' | '' | '1' | { from_project_id: 1 }
+ 'main' | '' | '1' | { from: 'main', from_project_id: 1 }
+ '' | 'main' | '1' | { to: 'main', from_project_id: 1 }
+ end
- context 'when the target and source ref are empty' do
- let(:source_ref) { '' }
- let(:target_ref) { '' }
+ with_them do
+ let(:expected_redirect) { project_compare_index_path(project, expected_redirect_params) }
- it 'redirects back to index' do
+ it 'redirects back to the index' do
create_request
- expect(response).to redirect_to(namespace_project_compare_index_path)
+ expect(response).to redirect_to(expected_redirect)
end
end
end
@@ -268,15 +354,15 @@ RSpec.describe Projects::CompareController do
{
namespace_id: project.namespace,
project_id: project,
- from: source_ref,
- to: target_ref,
+ from: from_ref,
+ to: to_ref,
format: :json
}
end
context 'when the source and target refs exist' do
- let(:source_ref) { "improve%2Fawesome" }
- let(:target_ref) { "feature" }
+ let(:from_ref) { 'improve%2Fawesome' }
+ let(:to_ref) { 'feature' }
context 'when the user has access to the project' do
render_views
@@ -285,14 +371,14 @@ RSpec.describe Projects::CompareController do
let(:non_signature_commit) { build(:commit, project: project, safe_message: "message", sha: 'non_signature_commit') }
before do
- escaped_source_ref = Addressable::URI.unescape(source_ref)
- escaped_target_ref = Addressable::URI.unescape(target_ref)
+ escaped_from_ref = Addressable::URI.unescape(from_ref)
+ escaped_to_ref = Addressable::URI.unescape(to_ref)
- compare_service = CompareService.new(project, escaped_target_ref)
- compare = compare_service.execute(project, escaped_source_ref)
+ compare_service = CompareService.new(project, escaped_to_ref)
+ compare = compare_service.execute(project, escaped_from_ref)
- expect(CompareService).to receive(:new).with(project, escaped_target_ref).and_return(compare_service)
- expect(compare_service).to receive(:execute).with(project, escaped_source_ref).and_return(compare)
+ expect(CompareService).to receive(:new).with(project, escaped_to_ref).and_return(compare_service)
+ expect(compare_service).to receive(:execute).with(project, escaped_from_ref).and_return(compare)
expect(compare).to receive(:commits).and_return([signature_commit, non_signature_commit])
expect(non_signature_commit).to receive(:has_signature?).and_return(false)
@@ -313,6 +399,7 @@ RSpec.describe Projects::CompareController do
context 'when the user does not have access to the project' do
before do
project.team.truncate
+ project.update!(visibility: 'private')
end
it 'returns a 404' do
@@ -324,8 +411,8 @@ RSpec.describe Projects::CompareController do
end
context 'when the source ref does not exist' do
- let(:source_ref) { 'non-existent-ref-source' }
- let(:target_ref) { "feature" }
+ let(:from_ref) { 'non-existent-ref-source' }
+ let(:to_ref) { 'feature' }
it 'returns no signatures' do
signatures_request
@@ -336,8 +423,8 @@ RSpec.describe Projects::CompareController do
end
context 'when the target ref does not exist' do
- let(:target_ref) { 'non-existent-ref-target' }
- let(:source_ref) { "improve%2Fawesome" }
+ let(:from_ref) { 'improve%2Fawesome' }
+ let(:to_ref) { 'non-existent-ref-target' }
it 'returns no signatures' do
signatures_request
diff --git a/spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb b/spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb
index f664604ac15..e0f86876f67 100644
--- a/spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb
+++ b/spec/controllers/projects/design_management/designs/raw_images_controller_spec.rb
@@ -37,13 +37,24 @@ RSpec.describe Projects::DesignManagement::Designs::RawImagesController do
# For security, .svg images should only ever be served with Content-Disposition: attachment.
# If this specs ever fails we must assess whether we should be serving svg images.
# See https://gitlab.com/gitlab-org/gitlab/issues/12771
- it 'serves files with `Content-Disposition: attachment`' do
+ it 'serves files with `Content-Disposition` header set to attachment plus the filename' do
subject
- expect(response.header['Content-Disposition']).to eq('attachment')
+ expect(response.header['Content-Disposition']).to match "attachment; filename=\"#{design.filename}\""
expect(response).to have_gitlab_http_status(:ok)
end
+ context 'when the feature flag attachment_with_filename is disabled' do
+ it 'serves files with just `attachment` in the disposition header' do
+ stub_feature_flags(attachment_with_filename: false)
+
+ subject
+
+ expect(response.header['Content-Disposition']).to eq('attachment')
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
it 'serves files with Workhorse' do
subject
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index 81ffd2c4512..74062038248 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -9,6 +9,7 @@ RSpec.describe Projects::IssuesController do
let_it_be(:project, reload: true) { create(:project) }
let_it_be(:user, reload: true) { create(:user) }
let(:issue) { create(:issue, project: project) }
+ let(:spam_action_response_fields) { { 'stub_spam_action_response_fields' => true } }
describe "GET #index" do
context 'external issue tracker' do
@@ -613,12 +614,15 @@ RSpec.describe Projects::IssuesController do
context 'when allow_possible_spam feature flag is false' do
before do
stub_feature_flags(allow_possible_spam: false)
+ expect(controller).to(receive(:spam_action_response_fields).with(issue)) do
+ spam_action_response_fields
+ end
end
- it 'renders json with recaptcha_html' do
+ it 'renders json with spam_action_response_fields' do
subject
- expect(json_response).to have_key('recaptcha_html')
+ expect(json_response).to eq(spam_action_response_fields)
end
end
@@ -948,12 +952,17 @@ RSpec.describe Projects::IssuesController do
context 'renders properly' do
render_views
- it 'renders recaptcha_html json response' do
+ before do
+ expect(controller).to(receive(:spam_action_response_fields).with(issue)) do
+ spam_action_response_fields
+ end
+ end
+
+ it 'renders spam_action_response_fields json response' do
update_issue
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to have_key('recaptcha_html')
- expect(json_response['recaptcha_html']).not_to be_empty
+ expect(response).to have_gitlab_http_status(:conflict)
+ expect(json_response).to eq(spam_action_response_fields)
end
end
end
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index 9b37c46fd86..93d5e7eff6c 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -2048,21 +2048,6 @@ RSpec.describe Projects::MergeRequestsController do
end
end
- context 'with SELECT FOR UPDATE lock' do
- before do
- stub_feature_flags(merge_request_rebase_nowait_lock: false)
- end
-
- it 'executes rebase' do
- allow_any_instance_of(MergeRequest).to receive(:with_lock).with(true).and_call_original
- expect(RebaseWorker).to receive(:perform_async)
-
- post_rebase
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
-
context 'with NOWAIT lock' do
it 'returns a 409' do
allow_any_instance_of(MergeRequest).to receive(:with_lock).with('FOR UPDATE NOWAIT').and_raise(ActiveRecord::LockWaitTimeout)
diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb
index edebaf294c4..add249e2c74 100644
--- a/spec/controllers/projects/notes_controller_spec.rb
+++ b/spec/controllers/projects/notes_controller_spec.rb
@@ -150,7 +150,7 @@ RSpec.describe Projects::NotesController do
end
it 'returns an empty page of notes' do
- expect(Gitlab::EtagCaching::Middleware).to receive(:skip!)
+ expect(Gitlab::EtagCaching::Middleware).not_to receive(:skip!)
request.headers['X-Last-Fetched-At'] = microseconds(Time.zone.now)
@@ -169,8 +169,6 @@ RSpec.describe Projects::NotesController do
end
it 'returns all notes' do
- expect(Gitlab::EtagCaching::Middleware).to receive(:skip!)
-
get :index, params: request_params
expect(json_response['notes'].count).to eq((page_1 + page_2 + page_3).size + 1)
@@ -764,49 +762,9 @@ RSpec.describe Projects::NotesController do
end
end
- context 'when the endpoint receives requests above the limit' do
- before do
- stub_application_setting(notes_create_limit: 3)
- end
-
- it 'prevents from creating more notes', :request_store do
- 3.times { create! }
-
- expect { create! }
- .to change { Gitlab::GitalyClient.get_request_count }.by(0)
-
- create!
- 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
-
- it 'logs the event in auth.log' do
- attributes = {
- message: 'Application_Rate_Limiter_Request',
- env: :notes_create_request_limit,
- remote_ip: '0.0.0.0',
- request_method: 'POST',
- path: "/#{project.full_path}/notes",
- user_id: user.id,
- username: user.username
- }
-
- expect(Gitlab::AuthLogger).to receive(:error).with(attributes).once
-
- project.add_developer(user)
- sign_in(user)
-
- 4.times { create! }
- end
-
- it 'allows user in allow-list to create notes, even if the case is different' do
- user.update_attribute(:username, user.username.titleize)
- stub_application_setting(notes_create_limit_allowlist: ["#{user.username.downcase}"])
- 3.times { create! }
-
- create!
- expect(response).to have_gitlab_http_status(:found)
- end
+ it_behaves_like 'request exceeding rate limit', :clean_gitlab_redis_cache do
+ let(:params) { request_params.except(:format) }
+ let(:request_full_path) { project_notes_path(project) }
end
end
diff --git a/spec/controllers/projects/security/configuration_controller_spec.rb b/spec/controllers/projects/security/configuration_controller_spec.rb
index ef255d1efd0..848db16fb02 100644
--- a/spec/controllers/projects/security/configuration_controller_spec.rb
+++ b/spec/controllers/projects/security/configuration_controller_spec.rb
@@ -13,42 +13,28 @@ RSpec.describe Projects::Security::ConfigurationController do
end
describe 'GET show' do
- context 'when feature flag is disabled' do
+ context 'when user has guest access' do
before do
- stub_feature_flags(secure_security_and_compliance_configuration_page_on_ce: false)
+ project.add_guest(user)
end
- it 'renders not found' do
+ it 'denies access' do
get :show, params: { namespace_id: project.namespace, project_id: project }
- expect(response).to have_gitlab_http_status(:not_found)
+ expect(response).to have_gitlab_http_status(:forbidden)
end
end
- context 'when feature flag is enabled' do
- context 'when user has guest access' do
- before do
- project.add_guest(user)
- end
-
- it 'denies access' do
- get :show, params: { namespace_id: project.namespace, project_id: project }
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
+ context 'when user has developer access' do
+ before do
+ project.add_developer(user)
end
- context 'when user has developer access' do
- before do
- project.add_developer(user)
- end
-
- it 'grants access' do
- get :show, params: { namespace_id: project.namespace, project_id: project }
+ it 'grants access' do
+ get :show, params: { namespace_id: project.namespace, project_id: project }
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to render_template(:show)
- end
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:show)
end
end
end
diff --git a/spec/controllers/projects/snippets_controller_spec.rb b/spec/controllers/projects/snippets_controller_spec.rb
index f9221c5a4ef..793ffbbfad9 100644
--- a/spec/controllers/projects/snippets_controller_spec.rb
+++ b/spec/controllers/projects/snippets_controller_spec.rb
@@ -207,14 +207,14 @@ RSpec.describe Projects::SnippetsController do
subject
expect(assigns(:snippet)).to eq(project_snippet)
- expect(assigns(:blobs)).to eq(project_snippet.blobs)
+ expect(assigns(:blobs).map(&:name)).to eq(project_snippet.blobs.map(&:name))
expect(response).to have_gitlab_http_status(:ok)
end
it 'does not show the blobs expanded by default' do
subject
- expect(project_snippet.blobs.map(&:expanded?)).to be_all(false)
+ expect(assigns(:blobs).map(&:expanded?)).to be_all(false)
end
context 'when param expanded is set' do
@@ -223,7 +223,7 @@ RSpec.describe Projects::SnippetsController do
it 'shows all blobs expanded' do
subject
- expect(project_snippet.blobs.map(&:expanded?)).to be_all(true)
+ expect(assigns(:blobs).map(&:expanded?)).to be_all(true)
end
end
end
diff --git a/spec/controllers/projects/templates_controller_spec.rb b/spec/controllers/projects/templates_controller_spec.rb
index fe282baf769..bd299efb5b5 100644
--- a/spec/controllers/projects/templates_controller_spec.rb
+++ b/spec/controllers/projects/templates_controller_spec.rb
@@ -160,13 +160,28 @@ RSpec.describe Projects::TemplatesController do
end
shared_examples 'template names request' do
- it 'returns the template names' do
- get(:names, params: { namespace_id: project.namespace, template_type: template_type, project_id: project }, format: :json)
+ context 'when feature flag enabled' do
+ it 'returns the template names', :aggregate_failures do
+ get(:names, params: { namespace_id: project.namespace, template_type: template_type, project_id: project }, format: :json)
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response.size).to eq(2)
- expect(json_response.size).to eq(2)
- expect(json_response.map { |x| x.slice('name') }).to match(expected_template_names)
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['Project Templates'].size).to eq(2)
+ expect(json_response['Project Templates'].map { |x| x.slice('name') }).to match(expected_template_names)
+ end
+ end
+
+ context 'when feature flag disabled' do
+ before do
+ stub_feature_flags(inherited_issuable_templates: false)
+ end
+
+ it 'returns the template names', :aggregate_failures do
+ get(:names, params: { namespace_id: project.namespace, template_type: template_type, project_id: project }, format: :json)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.size).to eq(2)
+ expect(json_response.map { |x| x.slice('name') }).to match(expected_template_names)
+ end
end
it 'fails for user with no access' do
diff --git a/spec/controllers/projects/web_ide_schemas_controller_spec.rb b/spec/controllers/projects/web_ide_schemas_controller_spec.rb
index fbec941aecc..136edd2f7ad 100644
--- a/spec/controllers/projects/web_ide_schemas_controller_spec.rb
+++ b/spec/controllers/projects/web_ide_schemas_controller_spec.rb
@@ -53,13 +53,13 @@ RSpec.describe Projects::WebIdeSchemasController do
end
context 'when an error occurs parsing the schema' do
- let(:result) { { status: :error, message: 'Some error occured' } }
+ let(:result) { { status: :error, message: 'Some error occurred' } }
it 'returns 422 with the error' do
subject
expect(response).to have_gitlab_http_status(:unprocessable_entity)
- expect(response.body).to eq('{"status":"error","message":"Some error occured"}')
+ expect(response.body).to eq('{"status":"error","message":"Some error occurred"}')
end
end
end
diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb
index 1e4ec48b119..554487db8f2 100644
--- a/spec/controllers/projects_controller_spec.rb
+++ b/spec/controllers/projects_controller_spec.rb
@@ -221,6 +221,20 @@ RSpec.describe ProjectsController do
allow(controller).to receive(:record_experiment_user)
end
+ context 'when user can push to default branch' do
+ let(:user) { empty_project.owner }
+
+ it 'creates an "view_project_show" experiment tracking event', :snowplow do
+ allow_next_instance_of(ApplicationExperiment) do |e|
+ allow(e).to receive(:should_track?).and_return(true)
+ end
+
+ get :show, params: { namespace_id: empty_project.namespace, id: empty_project }
+
+ expect_snowplow_event(category: 'empty_repo_upload', action: 'view_project_show', context: [{ schema: 'iglu:com.gitlab/gitlab_experiment/jsonschema/0-3-0', data: anything }], property: 'empty')
+ end
+ end
+
User.project_views.keys.each do |project_view|
context "with #{project_view} view set" do
before do
@@ -416,7 +430,8 @@ RSpec.describe ProjectsController do
path: 'foo',
description: 'bar',
namespace_id: user.namespace.id,
- visibility_level: Gitlab::VisibilityLevel::PUBLIC
+ visibility_level: Gitlab::VisibilityLevel::PUBLIC,
+ initialize_with_readme: 1
}
end
@@ -425,9 +440,11 @@ RSpec.describe ProjectsController do
end
it 'tracks a created event for the new_project_readme experiment', :experiment do
- expect(experiment(:new_project_readme)).to track(:created, property: 'blank').on_any_instance.with_context(
- actor: user
- )
+ expect(experiment(:new_project_readme)).to track(
+ :created,
+ property: 'blank',
+ value: 1
+ ).on_any_instance.with_context(actor: user)
post :create, params: { project: project_params }
end
@@ -1345,6 +1362,14 @@ 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
+
+ it 'applies correct scope when throttling' do
+ expect(Gitlab::ApplicationRateLimiter)
+ .to receive(:throttled?)
+ .with(:project_download_export, scope: [user, project])
+
+ post action, params: { namespace_id: project.namespace, id: project }
+ end
end
end
end
diff --git a/spec/controllers/repositories/git_http_controller_spec.rb b/spec/controllers/repositories/git_http_controller_spec.rb
index d21f602f90c..4eede594bb9 100644
--- a/spec/controllers/repositories/git_http_controller_spec.rb
+++ b/spec/controllers/repositories/git_http_controller_spec.rb
@@ -54,14 +54,17 @@ RSpec.describe Repositories::GitHttpController do
}.from(0).to(1)
end
- it_behaves_like 'records an onboarding progress action', :git_read do
- let(:namespace) { project.namespace }
-
- subject { send_request }
+ describe 'recording the onboarding progress', :sidekiq_inline do
+ let_it_be(:namespace) { project.namespace }
before do
- stub_feature_flags(disable_git_http_fetch_writes: false)
+ OnboardingProgress.onboard(namespace)
+ send_request
end
+
+ subject { OnboardingProgress.completed?(namespace, :git_pull) }
+
+ it { is_expected.to be(true) }
end
context 'when disable_git_http_fetch_writes is enabled' do
@@ -75,12 +78,6 @@ RSpec.describe Repositories::GitHttpController do
send_request
end
-
- it 'does not record onboarding progress' do
- expect(OnboardingProgressService).not_to receive(:new)
-
- send_request
- end
end
end
end
diff --git a/spec/controllers/root_controller_spec.rb b/spec/controllers/root_controller_spec.rb
index 85f9ea66c5f..49841aa61d7 100644
--- a/spec/controllers/root_controller_spec.rb
+++ b/spec/controllers/root_controller_spec.rb
@@ -68,6 +68,18 @@ RSpec.describe RootController do
end
end
+ context 'who has customized their dashboard setting for followed user activities' do
+ before do
+ user.dashboard = 'followed_user_activity'
+ end
+
+ it 'redirects to the activity list' do
+ get :index
+
+ expect(response).to redirect_to activity_dashboard_path(filter: 'followed')
+ end
+ end
+
context 'who has customized their dashboard setting for groups' do
before do
user.dashboard = 'groups'
@@ -123,11 +135,7 @@ RSpec.describe RootController do
expect(response).to render_template 'dashboard/projects/index'
end
- context 'when experiment is enabled' do
- before do
- stub_experiment_for_subject(customize_homepage: true)
- end
-
+ context 'when customize_homepage is enabled' do
it 'renders the default dashboard' do
get :index
@@ -135,9 +143,9 @@ RSpec.describe RootController do
end
end
- context 'when experiment not enabled' do
+ context 'when customize_homepage is not enabled' do
before do
- stub_experiment(customize_homepage: false)
+ stub_feature_flags(customize_homepage: false)
end
it 'renders the default dashboard' do
diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb
index 95cea10f0d0..32ac83847aa 100644
--- a/spec/controllers/search_controller_spec.rb
+++ b/spec/controllers/search_controller_spec.rb
@@ -252,6 +252,14 @@ RSpec.describe SearchController do
get :count, params: { search: 'hello' }
end.to raise_error(ActionController::ParameterMissing)
end
+
+ it 'sets private cache control headers' do
+ get :count, params: { search: 'hello', scope: 'projects' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+
+ expect(response.headers['Cache-Control']).to include('max-age=60, private')
+ end
end
describe 'GET #autocomplete' do
@@ -261,23 +269,29 @@ RSpec.describe SearchController do
describe '#append_info_to_payload' do
it 'appends search metadata for logging' do
- last_payload = nil
- original_append_info_to_payload = controller.method(:append_info_to_payload)
-
- expect(controller).to receive(:append_info_to_payload) do |payload|
- original_append_info_to_payload.call(payload)
- last_payload = payload
+ expect(controller).to receive(:append_info_to_payload).and_wrap_original do |method, payload|
+ method.call(payload)
+
+ expect(payload[:metadata]['meta.search.group_id']).to eq('123')
+ expect(payload[:metadata]['meta.search.project_id']).to eq('456')
+ expect(payload[:metadata]).not_to have_key('meta.search.search')
+ expect(payload[:metadata]['meta.search.scope']).to eq('issues')
+ expect(payload[:metadata]['meta.search.force_search_results']).to eq('true')
+ expect(payload[:metadata]['meta.search.filters.confidential']).to eq('true')
+ expect(payload[:metadata]['meta.search.filters.state']).to eq('true')
end
get :show, params: { scope: 'issues', search: 'hello world', group_id: '123', project_id: '456', confidential: true, state: true, force_search_results: true }
+ end
+
+ it 'appends the default scope in meta.search.scope' do
+ expect(controller).to receive(:append_info_to_payload).and_wrap_original do |method, payload|
+ method.call(payload)
+
+ expect(payload[:metadata]['meta.search.scope']).to eq('projects')
+ end
- expect(last_payload[:metadata]['meta.search.group_id']).to eq('123')
- expect(last_payload[:metadata]['meta.search.project_id']).to eq('456')
- expect(last_payload[:metadata]).not_to have_key('meta.search.search')
- expect(last_payload[:metadata]['meta.search.scope']).to eq('issues')
- expect(last_payload[:metadata]['meta.search.force_search_results']).to eq('true')
- expect(last_payload[:metadata]['meta.search.filters.confidential']).to eq('true')
- expect(last_payload[:metadata]['meta.search.filters.state']).to eq('true')
+ get :show, params: { search: 'hello world', group_id: '123', project_id: '456' }
end
end
end
diff --git a/spec/controllers/snippets/notes_controller_spec.rb b/spec/controllers/snippets/notes_controller_spec.rb
index 487635169fc..558e68fbb8f 100644
--- a/spec/controllers/snippets/notes_controller_spec.rb
+++ b/spec/controllers/snippets/notes_controller_spec.rb
@@ -141,6 +141,11 @@ RSpec.describe Snippets::NotesController do
it 'creates the note' do
expect { post :create, params: request_params }.to change { Note.count }.by(1)
end
+
+ it_behaves_like 'request exceeding rate limit', :clean_gitlab_redis_cache do
+ let(:params) { request_params }
+ let(:request_full_path) { snippet_notes_path(public_snippet) }
+ end
end
context 'when a snippet is internal' do
@@ -164,6 +169,11 @@ RSpec.describe Snippets::NotesController do
it 'creates the note' do
expect { post :create, params: request_params }.to change { Note.count }.by(1)
end
+
+ it_behaves_like 'request exceeding rate limit', :clean_gitlab_redis_cache do
+ let(:params) { request_params }
+ let(:request_full_path) { snippet_notes_path(internal_snippet) }
+ end
end
context 'when a snippet is private' do
@@ -228,6 +238,12 @@ RSpec.describe Snippets::NotesController do
it 'creates the note' do
expect { post :create, params: request_params }.to change { Note.count }.by(1)
end
+
+ it_behaves_like 'request exceeding rate limit', :clean_gitlab_redis_cache do
+ let(:params) { request_params }
+ let(:request_full_path) { snippet_notes_path(private_snippet) }
+ let(:user) { private_snippet.author }
+ end
end
end
end
diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb
index b2c77a06f19..d292ba60a12 100644
--- a/spec/db/schema_spec.rb
+++ b/spec/db/schema_spec.rb
@@ -87,7 +87,8 @@ RSpec.describe 'Database schema' do
users_star_projects: %w[user_id],
vulnerability_identifiers: %w[external_id],
vulnerability_scanners: %w[external_id],
- web_hooks: %w[group_id]
+ web_hooks: %w[group_id],
+ web_hook_logs_part_0c5294f417: %w[web_hook_id]
}.with_indifferent_access.freeze
context 'for table' do
@@ -102,7 +103,12 @@ RSpec.describe 'Database schema' do
context 'all foreign keys' do
# for index to be effective, the FK constraint has to be at first place
it 'are indexed' do
- first_indexed_column = indexes.map(&:columns).map(&:first)
+ first_indexed_column = indexes.map(&:columns).map do |columns|
+ # In cases of complex composite indexes, a string is returned eg:
+ # "lower((extern_uid)::text), group_id"
+ columns = columns.split(',') if columns.is_a?(String)
+ columns.first.chomp
+ end
foreign_keys_columns = foreign_keys.map(&:column)
# Add the primary key column to the list of indexed columns because
diff --git a/spec/deprecation_toolkit_env.rb b/spec/deprecation_toolkit_env.rb
index 10eaaf13aaa..f4ead6d5f01 100644
--- a/spec/deprecation_toolkit_env.rb
+++ b/spec/deprecation_toolkit_env.rb
@@ -61,6 +61,7 @@ module DeprecationToolkitEnv
batch-loader-1.4.0/lib/batch_loader/graphql.rb
carrierwave-1.3.1/lib/carrierwave/sanitized_file.rb
activerecord-6.0.3.4/lib/active_record/relation.rb
+ selenium-webdriver-3.142.7/lib/selenium/webdriver/firefox/driver.rb
asciidoctor-2.0.12/lib/asciidoctor/extensions.rb
]
end
diff --git a/spec/experiments/application_experiment/cache_spec.rb b/spec/experiments/application_experiment/cache_spec.rb
deleted file mode 100644
index 4caa91e6ac4..00000000000
--- a/spec/experiments/application_experiment/cache_spec.rb
+++ /dev/null
@@ -1,54 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ApplicationExperiment::Cache do
- let(:key_name) { 'experiment_name' }
- let(:field_name) { 'abc123' }
- let(:key_field) { [key_name, field_name].join(':') }
- let(:shared_state) { Gitlab::Redis::SharedState }
-
- around do |example|
- shared_state.with { |r| r.del(key_name) }
- example.run
- shared_state.with { |r| r.del(key_name) }
- end
-
- it "allows reading, writing and deleting", :aggregate_failures do
- # we test them all together because they are largely interdependent
-
- expect(subject.read(key_field)).to be_nil
- expect(shared_state.with { |r| r.hget(key_name, field_name) }).to be_nil
-
- subject.write(key_field, 'value')
-
- expect(subject.read(key_field)).to eq('value')
- expect(shared_state.with { |r| r.hget(key_name, field_name) }).to eq('value')
-
- subject.delete(key_field)
-
- expect(subject.read(key_field)).to be_nil
- expect(shared_state.with { |r| r.hget(key_name, field_name) }).to be_nil
- end
-
- it "handles the fetch with a block behavior (which is read/write)" do
- expect(subject.fetch(key_field) { 'value1' }).to eq('value1') # rubocop:disable Style/RedundantFetchBlock
- expect(subject.fetch(key_field) { 'value2' }).to eq('value1') # rubocop:disable Style/RedundantFetchBlock
- end
-
- it "can clear a whole experiment cache key" do
- subject.write(key_field, 'value')
- subject.clear(key: key_field)
-
- expect(shared_state.with { |r| r.get(key_name) }).to be_nil
- end
-
- it "doesn't allow clearing a key from the cache that's not a hash (definitely not an experiment)" do
- shared_state.with { |r| r.set(key_name, 'value') }
-
- expect { subject.clear(key: key_name) }.to raise_error(
- ArgumentError,
- 'invalid call to clear a non-hash cache key'
- )
- end
-end
diff --git a/spec/experiments/application_experiment_spec.rb b/spec/experiments/application_experiment_spec.rb
index 501d344e920..2481ee5a806 100644
--- a/spec/experiments/application_experiment_spec.rb
+++ b/spec/experiments/application_experiment_spec.rb
@@ -58,30 +58,38 @@ RSpec.describe ApplicationExperiment, :experiment do
end
describe "publishing results" do
+ it "doesn't track or push data to the client if we shouldn't track", :snowplow do
+ allow(subject).to receive(:should_track?).and_return(false)
+ expect(Gon).not_to receive(:push)
+
+ subject.publish(:action)
+
+ expect_no_snowplow_event
+ end
+
it "tracks the assignment" do
expect(subject).to receive(:track).with(:assignment)
- subject.publish(nil)
+ subject.publish
end
- it "pushes the experiment knowledge into the client using Gon.global" do
- expect(Gon.global).to receive(:push).with(
- {
- experiment: {
- 'namespaced/stub' => { # string key because it can be namespaced
- experiment: 'namespaced/stub',
- key: '86208ac54ca798e11f127e8b23ec396a',
- variant: 'control'
- }
- }
- },
- true
- )
+ it "pushes the experiment knowledge into the client using Gon" do
+ expect(Gon).to receive(:push).with({ experiment: { 'namespaced/stub' => subject.signature } }, true)
+
+ subject.publish
+ end
- subject.publish(nil)
+ it "handles when Gon raises exceptions (like when it can't be pushed into)" do
+ expect(Gon).to receive(:push).and_raise(NoMethodError)
+
+ expect { subject.publish }.not_to raise_error
end
end
+ it "can exclude from within the block" do
+ expect(described_class.new('namespaced/stub') { |e| e.exclude! }).to be_excluded
+ end
+
describe "tracking events", :snowplow do
it "doesn't track if we shouldn't track" do
allow(subject).to receive(:should_track?).and_return(false)
@@ -115,91 +123,36 @@ RSpec.describe ApplicationExperiment, :experiment do
end
describe "variant resolution" do
- context "when using the default feature flag percentage rollout" do
- it "uses the default value as specified in the yaml" do
- expect(Feature).to receive(:enabled?).with('namespaced_stub', subject, type: :experiment, default_enabled: :yaml)
-
- expect(subject.variant.name).to eq('control')
- end
+ it "uses the default value as specified in the yaml" do
+ expect(Feature).to receive(:enabled?).with('namespaced_stub', subject, type: :experiment, default_enabled: :yaml)
- it "returns nil when not rolled out" do
- stub_feature_flags(namespaced_stub: false)
-
- expect(subject.variant.name).to eq('control')
- end
-
- context "when rolled out to 100%" do
- it "returns the first variant name" do
- subject.try(:variant1) {}
- subject.try(:variant2) {}
-
- expect(subject.variant.name).to eq('variant1')
- end
- end
+ expect(subject.variant.name).to eq('control')
end
- context "when using the round_robin strategy", :clean_gitlab_redis_shared_state do
- context "when variants aren't supplied" do
- subject :inheriting_class do
- Class.new(described_class) do
- def rollout_strategy
- :round_robin
- end
- end.new('namespaced/stub')
- end
-
- it "raises an error" do
- expect { inheriting_class.variants }.to raise_error(NotImplementedError)
- end
+ context "when rolled out to 100%" do
+ before do
+ stub_feature_flags(namespaced_stub: true)
end
- context "when variants are supplied" do
- let(:inheriting_class) do
- Class.new(described_class) do
- def rollout_strategy
- :round_robin
- end
-
- def variants
- %i[variant1 variant2 control]
- end
- end
- end
-
- it "proves out round robin in variant selection", :aggregate_failures do
- instance_1 = inheriting_class.new('namespaced/stub')
- allow(instance_1).to receive(:enabled?).and_return(true)
- instance_2 = inheriting_class.new('namespaced/stub')
- allow(instance_2).to receive(:enabled?).and_return(true)
- instance_3 = inheriting_class.new('namespaced/stub')
- allow(instance_3).to receive(:enabled?).and_return(true)
-
- instance_1.try {}
-
- expect(instance_1.variant.name).to eq('variant2')
+ it "returns the first variant name" do
+ subject.try(:variant1) {}
+ subject.try(:variant2) {}
- instance_2.try {}
-
- expect(instance_2.variant.name).to eq('control')
-
- instance_3.try {}
-
- expect(instance_3.variant.name).to eq('variant1')
- end
+ expect(subject.variant.name).to eq('variant1')
end
end
end
context "when caching" do
- let(:cache) { ApplicationExperiment::Cache.new }
+ let(:cache) { Gitlab::Experiment::Configuration.cache }
before do
+ allow(Gitlab::Experiment::Configuration).to receive(:cache).and_call_original
+
cache.clear(key: subject.name)
subject.use { } # setup the control
subject.try { } # setup the candidate
-
- allow(Gitlab::Experiment::Configuration).to receive(:cache).and_return(cache)
end
it "caches the variant determined by the variant resolver" do
@@ -207,7 +160,7 @@ RSpec.describe ApplicationExperiment, :experiment do
subject.run
- expect(cache.read(subject.cache_key)).to eq('candidate')
+ expect(subject.cache.read).to eq('candidate')
end
it "doesn't cache a variant if we don't explicitly provide one" do
@@ -222,7 +175,7 @@ RSpec.describe ApplicationExperiment, :experiment do
subject.run
- expect(cache.read(subject.cache_key)).to be_nil
+ expect(subject.cache.read).to be_nil
end
it "caches a control variant if we assign it specifically" do
@@ -232,7 +185,26 @@ RSpec.describe ApplicationExperiment, :experiment do
# write code that would specify a different variant.
subject.run(:control)
- expect(cache.read(subject.cache_key)).to eq('control')
+ expect(subject.cache.read).to eq('control')
+ end
+
+ context "arbitrary attributes" do
+ before do
+ subject.cache.store.clear(key: subject.name + '_attrs')
+ end
+
+ it "sets and gets attributes about an experiment" do
+ subject.cache.attr_set(:foo, :bar)
+
+ expect(subject.cache.attr_get(:foo)).to eq('bar')
+ end
+
+ it "increments a value for an experiment" do
+ expect(subject.cache.attr_get(:foo)).to be_nil
+
+ expect(subject.cache.attr_inc(:foo)).to eq(1)
+ expect(subject.cache.attr_inc(:foo)).to eq(2)
+ end
end
end
end
diff --git a/spec/experiments/members/invite_email_experiment_spec.rb b/spec/experiments/members/invite_email_experiment_spec.rb
index 4376c021385..539230e39b9 100644
--- a/spec/experiments/members/invite_email_experiment_spec.rb
+++ b/spec/experiments/members/invite_email_experiment_spec.rb
@@ -3,26 +3,14 @@
require 'spec_helper'
RSpec.describe Members::InviteEmailExperiment do
- subject :invite_email do
- experiment('members/invite_email', actor: double('Member', created_by: double('User', avatar_url: '_avatar_url_')))
- end
+ subject(:invite_email) { experiment('members/invite_email', **context) }
+
+ let(:context) { { actor: double('Member', created_by: double('User', avatar_url: '_avatar_url_')) } }
before do
allow(invite_email).to receive(:enabled?).and_return(true)
end
- describe "#rollout_strategy" do
- it "resolves to round_robin" do
- expect(invite_email.rollout_strategy).to eq(:round_robin)
- end
- end
-
- describe "#variants" do
- it "has all the expected variants" do
- expect(invite_email.variants).to match(%i[avatar permission_info control])
- end
- end
-
describe "exclusions", :experiment do
it "excludes when created by is nil" do
expect(experiment('members/invite_email')).to exclude(actor: double(created_by: nil))
@@ -34,4 +22,27 @@ RSpec.describe Members::InviteEmailExperiment do
expect(experiment('members/invite_email')).to exclude(actor: member_without_avatar_url)
end
end
+
+ describe "variant resolution", :clean_gitlab_redis_shared_state do
+ it "proves out round robin in variant selection", :aggregate_failures do
+ instance_1 = described_class.new('members/invite_email', **context)
+ allow(instance_1).to receive(:enabled?).and_return(true)
+ instance_2 = described_class.new('members/invite_email', **context)
+ allow(instance_2).to receive(:enabled?).and_return(true)
+ instance_3 = described_class.new('members/invite_email', **context)
+ allow(instance_3).to receive(:enabled?).and_return(true)
+
+ instance_1.try { }
+
+ expect(instance_1.variant.name).to eq('permission_info')
+
+ instance_2.try { }
+
+ expect(instance_2.variant.name).to eq('control')
+
+ instance_3.try { }
+
+ expect(instance_3.variant.name).to eq('avatar')
+ end
+ end
end
diff --git a/spec/factories/alert_management/alerts.rb b/spec/factories/alert_management/alerts.rb
index e36e4c38013..ee1225b9542 100644
--- a/spec/factories/alert_management/alerts.rb
+++ b/spec/factories/alert_management/alerts.rb
@@ -47,10 +47,6 @@ FactoryBot.define do
hosts { [FFaker::Internet.ip_v4_address] }
end
- trait :with_ended_at do
- ended_at { Time.current }
- end
-
trait :without_ended_at do
ended_at { nil }
end
@@ -67,7 +63,7 @@ FactoryBot.define do
trait :resolved do
status { AlertManagement::Alert.status_value(:resolved) }
- with_ended_at
+ ended_at { Time.current }
end
trait :ignored do
diff --git a/spec/factories/analytics/instance_statistics/measurement.rb b/spec/factories/analytics/usage_trends/measurement.rb
index f9398cd3061..ec80174e967 100644
--- a/spec/factories/analytics/instance_statistics/measurement.rb
+++ b/spec/factories/analytics/usage_trends/measurement.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
FactoryBot.define do
- factory :instance_statistics_measurement, class: 'Analytics::InstanceStatistics::Measurement' do
+ factory :usage_trends_measurement, class: 'Analytics::UsageTrends::Measurement' do
recorded_at { Time.now }
identifier { :projects }
count { 1_000 }
diff --git a/spec/factories/bulk_import/trackers.rb b/spec/factories/bulk_import/trackers.rb
index 7a1fa0849fc..03af5b41e0f 100644
--- a/spec/factories/bulk_import/trackers.rb
+++ b/spec/factories/bulk_import/trackers.rb
@@ -4,6 +4,7 @@ FactoryBot.define do
factory :bulk_import_tracker, class: 'BulkImports::Tracker' do
association :entity, factory: :bulk_import_entity
+ stage { 0 }
relation { :relation }
has_next_page { false }
end
diff --git a/spec/factories/ci/build_report_results.rb b/spec/factories/ci/build_report_results.rb
index 0685c0e5554..7d716d6d81a 100644
--- a/spec/factories/ci/build_report_results.rb
+++ b/spec/factories/ci/build_report_results.rb
@@ -4,10 +4,15 @@ FactoryBot.define do
factory :ci_build_report_result, class: 'Ci::BuildReportResult' do
build factory: :ci_build
project factory: :project
+
+ transient do
+ test_suite_name { "rspec" }
+ end
+
data do
{
tests: {
- name: "rspec",
+ name: test_suite_name,
duration: 0.42,
failed: 0,
errored: 2,
@@ -21,7 +26,7 @@ FactoryBot.define do
data do
{
tests: {
- name: "rspec",
+ name: test_suite_name,
duration: 0.42,
failed: 0,
errored: 0,
@@ -31,5 +36,25 @@ FactoryBot.define do
}
end
end
+
+ trait :with_junit_suite_error do
+ transient do
+ test_suite_error { "some error" }
+ end
+
+ data do
+ {
+ tests: {
+ name: test_suite_name,
+ duration: 0.42,
+ failed: 0,
+ errored: 0,
+ skipped: 0,
+ success: 2,
+ suite_error: test_suite_error
+ }
+ }
+ end
+ end
end
end
diff --git a/spec/factories/ci/build_trace_chunks.rb b/spec/factories/ci/build_trace_chunks.rb
index d996b41b648..115eb32111c 100644
--- a/spec/factories/ci/build_trace_chunks.rb
+++ b/spec/factories/ci/build_trace_chunks.rb
@@ -3,7 +3,7 @@
FactoryBot.define do
factory :ci_build_trace_chunk, class: 'Ci::BuildTraceChunk' do
build factory: :ci_build
- chunk_index { 0 }
+ chunk_index { generate(:iid) }
data_store { :redis }
trait :redis_with_data do
diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb
index c4f9a4ce82b..886be520668 100644
--- a/spec/factories/ci/builds.rb
+++ b/spec/factories/ci/builds.rb
@@ -475,7 +475,7 @@ FactoryBot.define do
trait :license_scanning do
options do
{
- artifacts: { reports: { license_management: 'gl-license-scanning-report.json' } }
+ artifacts: { reports: { license_scanning: 'gl-license-scanning-report.json' } }
}
end
end
diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb
index e0d7ad3c133..87b9a6c0e23 100644
--- a/spec/factories/ci/pipelines.rb
+++ b/spec/factories/ci/pipelines.rb
@@ -40,6 +40,10 @@ FactoryBot.define do
end
end
+ trait :created do
+ status { :created }
+ end
+
factory :ci_pipeline do
transient { ci_ref_presence { true } }
@@ -53,10 +57,6 @@ FactoryBot.define do
failure_reason { :config_error }
end
- trait :created do
- status { :created }
- end
-
trait :preparing do
status { :preparing }
end
diff --git a/spec/factories/clusters/agent_tokens.rb b/spec/factories/clusters/agent_tokens.rb
index 6f92f2217b3..c49d197c3cd 100644
--- a/spec/factories/clusters/agent_tokens.rb
+++ b/spec/factories/clusters/agent_tokens.rb
@@ -5,5 +5,7 @@ FactoryBot.define do
association :agent, factory: :cluster_agent
token_encrypted { Gitlab::CryptoHelper.aes256_gcm_encrypt(SecureRandom.hex(50)) }
+
+ sequence(:name) { |n| "agent-token-#{n}" }
end
end
diff --git a/spec/factories/custom_emoji.rb b/spec/factories/custom_emoji.rb
index ba1ae11c18d..88e50eafa7c 100644
--- a/spec/factories/custom_emoji.rb
+++ b/spec/factories/custom_emoji.rb
@@ -6,5 +6,6 @@ FactoryBot.define do
namespace
group
file { 'https://gitlab.com/images/partyparrot.png' }
+ creator { namespace.owner }
end
end
diff --git a/spec/factories/dependency_proxy.rb b/spec/factories/dependency_proxy.rb
index de95df19876..94a7986a8fa 100644
--- a/spec/factories/dependency_proxy.rb
+++ b/spec/factories/dependency_proxy.rb
@@ -10,7 +10,8 @@ FactoryBot.define do
factory :dependency_proxy_manifest, class: 'DependencyProxy::Manifest' do
group
file { fixture_file_upload('spec/fixtures/dependency_proxy/manifest') }
- digest { 'sha256:5ab5a6872b264fe4fd35d63991b9b7d8425f4bc79e7cf4d563c10956581170c9' }
+ digest { 'sha256:d0710affa17fad5f466a70159cc458227bd25d4afb39514ef662ead3e6c99515' }
file_name { 'alpine:latest.json' }
+ content_type { 'application/vnd.docker.distribution.manifest.v2+json' }
end
end
diff --git a/spec/factories/design_management/versions.rb b/spec/factories/design_management/versions.rb
index 0233a3b567d..247a385bd0e 100644
--- a/spec/factories/design_management/versions.rb
+++ b/spec/factories/design_management/versions.rb
@@ -13,11 +13,6 @@ FactoryBot.define do
deleted_designs { [] }
end
- # Warning: this will intentionally result in an invalid version!
- trait :empty do
- designs_count { 0 }
- end
-
trait :importing do
issue { nil }
diff --git a/spec/factories/environments.rb b/spec/factories/environments.rb
index 050cb8f8e6c..072a5f1f402 100644
--- a/spec/factories/environments.rb
+++ b/spec/factories/environments.rb
@@ -15,7 +15,25 @@ FactoryBot.define do
state { :stopped }
end
+ trait :production do
+ tier { :production }
+ end
+
+ trait :staging do
+ tier { :staging }
+ end
+
+ trait :testing do
+ tier { :testing }
+ end
+
+ trait :development do
+ tier { :development }
+ end
+
trait :with_review_app do |environment|
+ sequence(:name) { |n| "review/#{n}" }
+
transient do
ref { 'master' }
end
diff --git a/spec/factories/gitlab/database/background_migration/batched_jobs.rb b/spec/factories/gitlab/database/background_migration/batched_jobs.rb
new file mode 100644
index 00000000000..52bc04447da
--- /dev/null
+++ b/spec/factories/gitlab/database/background_migration/batched_jobs.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :batched_background_migration_job, class: '::Gitlab::Database::BackgroundMigration::BatchedJob' do
+ batched_migration factory: :batched_background_migration
+
+ min_value { 1 }
+ max_value { 10 }
+ batch_size { 5 }
+ sub_batch_size { 1 }
+ end
+end
diff --git a/spec/factories/gitlab/database/background_migration/batched_migrations.rb b/spec/factories/gitlab/database/background_migration/batched_migrations.rb
new file mode 100644
index 00000000000..b45f6ff037b
--- /dev/null
+++ b/spec/factories/gitlab/database/background_migration/batched_migrations.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :batched_background_migration, class: '::Gitlab::Database::BackgroundMigration::BatchedMigration' do
+ max_value { 10 }
+ batch_size { 5 }
+ sub_batch_size { 1 }
+ interval { 2.minutes }
+ job_class_name { 'CopyColumnUsingBackgroundMigrationJob' }
+ table_name { :events }
+ column_name { :id }
+ end
+end
diff --git a/spec/factories/go_module_commits.rb b/spec/factories/go_module_commits.rb
index e42ef6696d1..514a5559344 100644
--- a/spec/factories/go_module_commits.rb
+++ b/spec/factories/go_module_commits.rb
@@ -7,7 +7,12 @@ FactoryBot.define do
transient do
files { { 'foo.txt' => 'content' } }
message { 'Message' }
+ # rubocop: disable FactoryBot/InlineAssociation
+ # We need a persisted project so we can create commits and tags
+ # in `commit` otherwise linting this factory with `build` strategy
+ # will fail.
project { create(:project, :repository) }
+ # rubocop: enable FactoryBot/InlineAssociation
service do
Files::MultiService.new(
@@ -44,14 +49,13 @@ FactoryBot.define do
trait :files do
transient do
- files { raise ArgumentError.new("files is required") }
message { 'Add files' }
end
end
trait :package do
transient do
- path { raise ArgumentError.new("path is required") }
+ path { 'pkg' }
message { 'Add package' }
files { { "#{path}/b.go" => "package b\nfunc Bye() { println(\"Goodbye world!\") }\n" } }
end
@@ -64,7 +68,7 @@ FactoryBot.define do
host_prefix { "#{::Gitlab.config.gitlab.host}/#{project.path_with_namespace}" }
url { name ? "#{host_prefix}/#{name}" : host_prefix }
- path { name.to_s + '/' }
+ path { "#{name}/" }
files do
{
diff --git a/spec/factories/go_module_versions.rb b/spec/factories/go_module_versions.rb
index b0a96197350..145e6c95921 100644
--- a/spec/factories/go_module_versions.rb
+++ b/spec/factories/go_module_versions.rb
@@ -8,12 +8,12 @@ FactoryBot.define do
p = attributes[:params]
s = Packages::SemVer.parse(p.semver, prefixed: true)
- raise ArgumentError.new("invalid sematic version: '#{p.semver}''") if !s && p.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)
end
- mod { create :go_module }
+ mod { association(:go_module) }
type { :commit }
commit { mod.project.repository.head_commit }
name { nil }
@@ -33,45 +33,11 @@ FactoryBot.define do
mod.project.repository.tags
.filter { |t| Packages::SemVer.match?(t.name, prefixed: true) }
.map { |t| Packages::SemVer.parse(t.name, prefixed: true) }
- .max { |a, b| "#{a}" <=> "#{b}" }
+ .max_by(&:to_s)
.to_s
end
params { OpenStruct.new(mod: mod, type: :ref, commit: commit, semver: name, ref: ref) }
end
-
- trait :pseudo do
- transient do
- prefix do
- # This provides a sane default value, but in reality the caller should
- # specify `prefix:`
-
- # This does not take into account that `commit` may be before the
- # latest tag.
-
- # Find 'latest' semver tag (does not actually use semver precedence rules)
- v = mod.project.repository.tags
- .filter { |t| Packages::SemVer.match?(t.name, prefixed: true) }
- .map { |t| Packages::SemVer.parse(t.name, prefixed: true) }
- .max { |a, b| "#{a}" <=> "#{b}" }
-
- # Default if no semver tags exist
- next 'v0.0.0' unless v
-
- # Valid pseudo-versions are:
- # vX.0.0-yyyymmddhhmmss-sha1337beef0, when no earlier tagged commit exists for X
- # vX.Y.Z-pre.0.yyyymmddhhmmss-sha1337beef0, when most recent prior tag is vX.Y.Z-pre
- # vX.Y.(Z+1)-0.yyyymmddhhmmss-sha1337beef0, when most recent prior tag is vX.Y.Z
-
- v = v.with(patch: v.patch + 1) unless v.prerelease
- "#{v}.0"
- end
- end
-
- type { :pseudo }
- name { "#{prefix}#{commit.committed_date.strftime('%Y%m%d%H%M%S')}-#{commit.sha[0..11]}" }
-
- params { OpenStruct.new(mod: mod, type: :pseudo, commit: commit, name: name, semver: name) }
- end
end
end
diff --git a/spec/factories/go_modules.rb b/spec/factories/go_modules.rb
index fdbacf48d3b..ca7184a9194 100644
--- a/spec/factories/go_modules.rb
+++ b/spec/factories/go_modules.rb
@@ -5,7 +5,7 @@ FactoryBot.define do
initialize_with { new(attributes[:project], attributes[:name], attributes[:path]) }
skip_create
- project { create :project, :repository }
+ project { association(:project, :repository) }
path { '' }
name { "#{Settings.build_gitlab_go_url}/#{project.full_path}#{path.empty? ? '' : '/'}#{path}" }
diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb
index 17db69e4699..5d232a9d09a 100644
--- a/spec/factories/groups.rb
+++ b/spec/factories/groups.rb
@@ -15,7 +15,7 @@ FactoryBot.define do
raise "Don't set owner for groups, use `group.add_owner(user)` instead"
end
- create(:namespace_settings, namespace: group)
+ create(:namespace_settings, namespace: group) unless group.namespace_settings
end
trait :public do
@@ -61,5 +61,35 @@ FactoryBot.define do
trait :allow_descendants_override_disabled_shared_runners do
allow_descendants_override_disabled_shared_runners { true }
end
+
+ # Construct a hierarchy underneath the group.
+ # Each group will have `children` amount of children,
+ # and `depth` levels of descendants.
+ trait :with_hierarchy do
+ transient do
+ children { 4 }
+ depth { 4 }
+ end
+
+ after(:create) do |group, evaluator|
+ def create_graph(parent: nil, children: 4, depth: 4)
+ return unless depth > 1
+
+ children.times do
+ factory_name = parent.model_name.singular
+ child = FactoryBot.create(factory_name, parent: parent)
+ create_graph(parent: child, children: children, depth: depth - 1)
+ end
+
+ parent
+ end
+
+ create_graph(
+ parent: group,
+ children: evaluator.children,
+ depth: evaluator.depth
+ )
+ end
+ end
end
end
diff --git a/spec/factories/iterations.rb b/spec/factories/iterations.rb
deleted file mode 100644
index bd61cd469af..00000000000
--- a/spec/factories/iterations.rb
+++ /dev/null
@@ -1,66 +0,0 @@
-# frozen_string_literal: true
-
-FactoryBot.define do
- sequence(:sequential_date) do |n|
- n.days.from_now
- end
-
- factory :iteration do
- title
- start_date { generate(:sequential_date) }
- due_date { generate(:sequential_date) }
-
- transient do
- project { nil }
- group { nil }
- project_id { nil }
- group_id { nil }
- resource_parent { nil }
- end
-
- trait :upcoming do
- state_enum { Iteration::STATE_ENUM_MAP[:upcoming] }
- end
-
- trait :started do
- state_enum { Iteration::STATE_ENUM_MAP[:started] }
- end
-
- trait :closed do
- state_enum { Iteration::STATE_ENUM_MAP[:closed] }
- end
-
- trait(:skip_future_date_validation) do
- after(:stub, :build) do |iteration|
- iteration.skip_future_date_validation = true
- end
- end
-
- trait(:skip_project_validation) do
- after(:stub, :build) do |iteration|
- iteration.skip_project_validation = true
- end
- end
-
- after(:build, :stub) do |iteration, evaluator|
- if evaluator.group
- iteration.group = evaluator.group
- elsif evaluator.group_id
- iteration.group_id = evaluator.group_id
- elsif evaluator.project
- iteration.project = evaluator.project
- elsif evaluator.project_id
- iteration.project_id = evaluator.project_id
- elsif evaluator.resource_parent
- id = evaluator.resource_parent.id
- evaluator.resource_parent.is_a?(Group) ? evaluator.group_id = id : evaluator.project_id = id
- else
- iteration.group = create(:group)
- end
- end
-
- factory :upcoming_iteration, traits: [:upcoming]
- factory :started_iteration, traits: [:started]
- factory :closed_iteration, traits: [:closed]
- end
-end
diff --git a/spec/factories/namespaces.rb b/spec/factories/namespaces.rb
index 0ec977b8234..f4b57369678 100644
--- a/spec/factories/namespaces.rb
+++ b/spec/factories/namespaces.rb
@@ -23,44 +23,20 @@ FactoryBot.define do
end
trait :with_aggregation_schedule do
- association :aggregation_schedule, factory: :namespace_aggregation_schedules
+ after(:create) do |namespace|
+ create(:namespace_aggregation_schedules, namespace: namespace)
+ end
end
trait :with_root_storage_statistics do
- association :root_storage_statistics, factory: :namespace_root_storage_statistics
+ after(:create) do |namespace|
+ create(:namespace_root_storage_statistics, namespace: namespace)
+ end
end
trait :with_namespace_settings do
- association :namespace_settings, factory: :namespace_settings
- end
-
- # Construct a hierarchy underneath the namespace.
- # Each namespace will have `children` amount of children,
- # and `depth` levels of descendants.
- trait :with_hierarchy do
- transient do
- children { 4 }
- depth { 4 }
- end
-
- after(:create) do |namespace, evaluator|
- def create_graph(parent: nil, children: 4, depth: 4)
- return unless depth > 1
-
- children.times do
- factory_name = parent.model_name.singular
- child = FactoryBot.create(factory_name, parent: parent)
- create_graph(parent: child, children: children, depth: depth - 1)
- end
-
- parent
- end
-
- create_graph(
- parent: namespace,
- children: evaluator.children,
- depth: evaluator.depth
- )
+ after(:create) do |namespace|
+ create(:namespace_settings, namespace: namespace)
end
end
diff --git a/spec/factories/packages.rb b/spec/factories/packages.rb
index 2c64abefb01..882bac1daa9 100644
--- a/spec/factories/packages.rb
+++ b/spec/factories/packages.rb
@@ -277,6 +277,10 @@ FactoryBot.define do
factory :packages_dependency, class: 'Packages::Dependency' do
sequence(:name) { |n| "@test/package-#{n}"}
sequence(:version_pattern) { |n| "~6.2.#{n}" }
+
+ trait(:rubygems) do
+ sequence(:name) { |n| "gem-dependency-#{n}"}
+ end
end
factory :packages_dependency_link, class: 'Packages::DependencyLink' do
@@ -289,6 +293,11 @@ FactoryBot.define do
link.nuget_metadatum = build(:nuget_dependency_link_metadatum)
end
end
+
+ trait(:rubygems) do
+ package { association(:rubygems_package) }
+ dependency { association(:packages_dependency, :rubygems) }
+ end
end
factory :nuget_dependency_link_metadatum, class: 'Packages::Nuget::DependencyLinkMetadatum' do
diff --git a/spec/factories/project_repository_storage_moves.rb b/spec/factories/project_repository_storage_moves.rb
index 5df2b7c32d6..018b6cde32b 100644
--- a/spec/factories/project_repository_storage_moves.rb
+++ b/spec/factories/project_repository_storage_moves.rb
@@ -1,29 +1,29 @@
# frozen_string_literal: true
FactoryBot.define do
- factory :project_repository_storage_move, class: 'ProjectRepositoryStorageMove' do
+ factory :project_repository_storage_move, class: 'Projects::RepositoryStorageMove' do
container { association(:project) }
source_storage_name { 'default' }
trait :scheduled do
- state { ProjectRepositoryStorageMove.state_machines[:state].states[:scheduled].value }
+ state { Projects::RepositoryStorageMove.state_machines[:state].states[:scheduled].value }
end
trait :started do
- state { ProjectRepositoryStorageMove.state_machines[:state].states[:started].value }
+ state { Projects::RepositoryStorageMove.state_machines[:state].states[:started].value }
end
trait :replicated do
- state { ProjectRepositoryStorageMove.state_machines[:state].states[:replicated].value }
+ state { Projects::RepositoryStorageMove.state_machines[:state].states[:replicated].value }
end
trait :finished do
- state { ProjectRepositoryStorageMove.state_machines[:state].states[:finished].value }
+ state { Projects::RepositoryStorageMove.state_machines[:state].states[:finished].value }
end
trait :failed do
- state { ProjectRepositoryStorageMove.state_machines[:state].states[:failed].value }
+ state { Projects::RepositoryStorageMove.state_machines[:state].states[:failed].value }
end
end
end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index e8e0362fc62..80392a2fece 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -194,7 +194,7 @@ FactoryBot.define do
filename,
content,
message: "Automatically created file #{filename}",
- branch_name: 'master'
+ branch_name: project.default_branch_or_master
)
end
end
diff --git a/spec/factories/prometheus_alert_event.rb b/spec/factories/prometheus_alert_event.rb
index 281fbacabe2..7771a8d5cb7 100644
--- a/spec/factories/prometheus_alert_event.rb
+++ b/spec/factories/prometheus_alert_event.rb
@@ -13,10 +13,5 @@ FactoryBot.define do
ended_at { Time.now }
payload_key { nil }
end
-
- trait :none do
- status { nil }
- started_at { nil }
- end
end
end
diff --git a/spec/factories/self_managed_prometheus_alert_event.rb b/spec/factories/self_managed_prometheus_alert_event.rb
index 238942e2c46..3a48aba5f54 100644
--- a/spec/factories/self_managed_prometheus_alert_event.rb
+++ b/spec/factories/self_managed_prometheus_alert_event.rb
@@ -8,16 +8,5 @@ FactoryBot.define do
title { 'alert' }
query_expression { 'vector(2)' }
started_at { Time.now }
-
- trait :resolved do
- status { SelfManagedPrometheusAlertEvent.status_value_for(:resolved) }
- ended_at { Time.now }
- payload_key { nil }
- end
-
- trait :none do
- status { nil }
- started_at { nil }
- end
end
end
diff --git a/spec/factories/snippet_repository_storage_moves.rb b/spec/factories/snippet_repository_storage_moves.rb
index ed65dc5374f..dd82ec5cfcb 100644
--- a/spec/factories/snippet_repository_storage_moves.rb
+++ b/spec/factories/snippet_repository_storage_moves.rb
@@ -1,29 +1,29 @@
# frozen_string_literal: true
FactoryBot.define do
- factory :snippet_repository_storage_move, class: 'SnippetRepositoryStorageMove' do
+ factory :snippet_repository_storage_move, class: 'Snippets::RepositoryStorageMove' do
container { association(:snippet) }
source_storage_name { 'default' }
trait :scheduled do
- state { SnippetRepositoryStorageMove.state_machines[:state].states[:scheduled].value }
+ state { Snippets::RepositoryStorageMove.state_machines[:state].states[:scheduled].value }
end
trait :started do
- state { SnippetRepositoryStorageMove.state_machines[:state].states[:started].value }
+ state { Snippets::RepositoryStorageMove.state_machines[:state].states[:started].value }
end
trait :replicated do
- state { SnippetRepositoryStorageMove.state_machines[:state].states[:replicated].value }
+ state { Snippets::RepositoryStorageMove.state_machines[:state].states[:replicated].value }
end
trait :finished do
- state { SnippetRepositoryStorageMove.state_machines[:state].states[:finished].value }
+ state { Snippets::RepositoryStorageMove.state_machines[:state].states[:finished].value }
end
trait :failed do
- state { SnippetRepositoryStorageMove.state_machines[:state].states[:failed].value }
+ state { Snippets::RepositoryStorageMove.state_machines[:state].states[:failed].value }
end
end
end
diff --git a/spec/factories_spec.rb b/spec/factories_spec.rb
index 38ade20de28..56d643d0cc9 100644
--- a/spec/factories_spec.rb
+++ b/spec/factories_spec.rb
@@ -5,6 +5,37 @@ require 'spec_helper'
RSpec.describe 'factories' do
include Database::DatabaseHelpers
+ # https://gitlab.com/groups/gitlab-org/-/epics/5464 tracks the remaining
+ # skipped traits.
+ #
+ # Consider adding a code comment if a trait cannot produce a valid object.
+ def skipped_traits
+ [
+ [:audit_event, :unauthenticated],
+ [:ci_build_trace_chunk, :fog_with_data],
+ [:ci_job_artifact, :remote_store],
+ [:ci_job_artifact, :raw],
+ [:ci_job_artifact, :gzip],
+ [:ci_job_artifact, :correct_checksum],
+ [:environment, :non_playable],
+ [:composer_cache_file, :object_storage],
+ [:debian_project_component_file, :object_storage],
+ [:debian_project_distribution, :object_storage],
+ [:debian_file_metadatum, :unknown],
+ [:package_file, :object_storage],
+ [:pages_domain, :without_certificate],
+ [:pages_domain, :without_key],
+ [:pages_domain, :with_missing_chain],
+ [:pages_domain, :with_trusted_chain],
+ [:pages_domain, :with_trusted_expired_chain],
+ [:pages_domain, :explicit_ecdsa],
+ [:project_member, :blocked],
+ [:project, :remote_mirror],
+ [:remote_mirror, :ssh],
+ [:user_preference, :only_comments]
+ ]
+ end
+
shared_examples 'factory' do |factory|
describe "#{factory.name} factory" do
it 'does not raise error when built' do
@@ -16,8 +47,10 @@ RSpec.describe 'factories' do
end
factory.definition.defined_traits.map(&:name).each do |trait_name|
- describe "linting #{trait_name} trait" do
- skip 'does not raise error when created' do
+ describe "linting :#{trait_name} trait" do
+ it 'does not raise error when created' do
+ pending("Trait skipped linting due to legacy error") if skipped_traits.include?([factory.name, trait_name.to_sym])
+
expect { create(factory.name, trait_name) }.not_to raise_error
end
end
@@ -29,9 +62,21 @@ RSpec.describe 'factories' do
# and reuse them in other factories.
#
# However, for some factories we cannot use FactoryDefault because the
- # associations must be unique and cannot be reused.
+ # associations must be unique and cannot be reused, or the factory default
+ # is being mutated.
skip_factory_defaults = %i[
fork_network_member
+ group_member
+ import_state
+ namespace
+ project_broken_repo
+ prometheus_alert
+ prometheus_alert_event
+ prometheus_metric
+ self_managed_prometheus_alert_event
+ users_star_project
+ wiki_page
+ wiki_page_meta
].to_set.freeze
# Some factories and their corresponding models are based on
@@ -46,9 +91,9 @@ RSpec.describe 'factories' do
.partition { |factory| skip_factory_defaults.include?(factory.name) }
context 'with factory defaults', factory_default: :keep do
- let_it_be(:namespace) { create_default(:namespace) }
- let_it_be(:project) { create_default(:project, :repository) }
- let_it_be(:user) { create_default(:user) }
+ let_it_be(:namespace) { create_default(:namespace).freeze }
+ let_it_be(:project) { create_default(:project, :repository).freeze }
+ let_it_be(:user) { create_default(:user).freeze }
before do
factories_based_on_view.each do |factory|
diff --git a/spec/features/admin/admin_projects_spec.rb b/spec/features/admin/admin_projects_spec.rb
index aab2e6d7cef..bf280595ec7 100644
--- a/spec/features/admin/admin_projects_spec.rb
+++ b/spec/features/admin/admin_projects_spec.rb
@@ -92,97 +92,46 @@ RSpec.describe "Admin::Projects" do
end
end
- context 'when `vue_project_members_list` feature flag is enabled', :js do
- describe 'admin adds themselves to the project' do
- before do
- project.add_maintainer(user)
- stub_feature_flags(invite_members_group_modal: false)
- end
-
- it 'adds admin to the project as developer', :js do
- visit project_project_members_path(project)
-
- page.within '.invite-users-form' do
- select2(current_user.id, from: '#user_ids', multiple: true)
- select 'Developer', from: 'access_level'
- end
-
- click_button 'Invite'
-
- expect(find_member_row(current_user)).to have_content('Developer')
- end
+ describe 'admin adds themselves to the project', :js do
+ before do
+ project.add_maintainer(user)
+ stub_feature_flags(invite_members_group_modal: false)
end
- describe 'admin removes themselves from the project' do
- before do
- project.add_maintainer(user)
- project.add_developer(current_user)
- end
-
- it 'removes admin from the project' do
- visit project_project_members_path(project)
-
- expect(find_member_row(current_user)).to have_content('Developer')
+ it 'adds admin to the project as developer' do
+ visit project_project_members_path(project)
- page.within find_member_row(current_user) do
- click_button 'Leave'
- end
+ page.within '.invite-users-form' do
+ select2(current_user.id, from: '#user_ids', multiple: true)
+ select 'Developer', from: 'access_level'
+ end
- page.within('[role="dialog"]') do
- click_button('Leave')
- end
+ click_button 'Invite'
- expect(current_path).to match dashboard_projects_path
- end
+ expect(find_member_row(current_user)).to have_content('Developer')
end
end
- context 'when `vue_project_members_list` feature flag is disabled' do
+ describe 'admin removes themselves from the project', :js do
before do
- stub_feature_flags(vue_project_members_list: false)
+ project.add_maintainer(user)
+ project.add_developer(current_user)
end
- describe 'admin adds themselves to the project' do
- before do
- project.add_maintainer(user)
- stub_feature_flags(invite_members_group_modal: false)
- end
-
- it 'adds admin to the project as developer', :js do
- visit project_project_members_path(project)
-
- page.within '.invite-users-form' do
- select2(current_user.id, from: '#user_ids', multiple: true)
- select 'Developer', from: 'access_level'
- end
+ it 'removes admin from the project' do
+ visit project_project_members_path(project)
- click_button 'Invite'
+ expect(find_member_row(current_user)).to have_content('Developer')
- page.within '.content-list' do
- expect(page).to have_content(current_user.name)
- expect(page).to have_content('Developer')
- end
+ page.within find_member_row(current_user) do
+ click_button 'Leave'
end
- end
- describe 'admin removes themselves from the project' do
- before do
- project.add_maintainer(user)
- project.add_developer(current_user)
+ page.within('[role="dialog"]') do
+ click_button('Leave')
end
- it 'removes admin from the project' do
- visit project_project_members_path(project)
-
- page.within '.content-list' do
- expect(page).to have_content(current_user.name)
- expect(page).to have_content('Developer')
- end
-
- find(:css, '.content-list li', text: current_user.name).find(:css, 'a.btn-danger').click
-
- expect(page).not_to have_selector(:css, '.content-list')
- end
+ expect(current_path).to match dashboard_projects_path
end
end
end
diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb
index 52f39f65bd0..249621f5835 100644
--- a/spec/features/admin/admin_settings_spec.rb
+++ b/spec/features/admin/admin_settings_spec.rb
@@ -384,7 +384,20 @@ RSpec.describe 'Admin updates settings' do
click_button 'Save changes'
end
- expect(current_settings.repository_storages_weighted_default).to be 50
+ expect(current_settings.repository_storages_weighted).to eq('default' => 50)
+ end
+
+ it 'still saves when settings are outdated' do
+ current_settings.update_attribute :repository_storages_weighted, { 'default' => 100, 'outdated' => 100 }
+
+ visit repository_admin_application_settings_path
+
+ page.within('.as-repository-storage') do
+ fill_in 'application_setting_repository_storages_weighted_default', with: 50
+ click_button 'Save changes'
+ end
+
+ expect(current_settings.repository_storages_weighted).to eq('default' => 50)
end
end
diff --git a/spec/features/admin/dashboard_spec.rb b/spec/features/admin/dashboard_spec.rb
index c040811ada1..618fae3e46b 100644
--- a/spec/features/admin/dashboard_spec.rb
+++ b/spec/features/admin/dashboard_spec.rb
@@ -30,7 +30,6 @@ RSpec.describe 'admin visits dashboard' do
describe 'Users statistic' do
let_it_be(:users_statistics) { create(:users_statistics) }
- let_it_be(:users_count_label) { Gitlab.ee? ? 'Billable users 71' : 'Active users 71' }
it 'shows correct amounts of users', :aggregate_failures do
visit admin_dashboard_stats_path
@@ -42,9 +41,16 @@ RSpec.describe 'admin visits dashboard' do
expect(page).to have_content('Users with highest role Maintainer 6')
expect(page).to have_content('Users with highest role Owner 5')
expect(page).to have_content('Bots 2')
+
+ if Gitlab.ee?
+ expect(page).to have_content('Billable users 69')
+ else
+ expect(page).not_to have_content('Billable users 69')
+ end
+
expect(page).to have_content('Blocked users 7')
expect(page).to have_content('Total users 78')
- expect(page).to have_content(users_count_label)
+ expect(page).to have_content('Active users 71')
end
end
end
diff --git a/spec/features/alerts_settings/user_views_alerts_settings_spec.rb b/spec/features/alerts_settings/user_views_alerts_settings_spec.rb
index 07c87f98eb6..60f2f776595 100644
--- a/spec/features/alerts_settings/user_views_alerts_settings_spec.rb
+++ b/spec/features/alerts_settings/user_views_alerts_settings_spec.rb
@@ -19,7 +19,6 @@ RSpec.describe 'Alert integrations settings form', :js do
describe 'when viewing alert integrations as a maintainer' do
context 'with the default page permissions' do
before do
- stub_feature_flags(multiple_http_integrations_custom_mapping: false)
visit project_settings_operations_path(project, anchor: 'js-alert-management-settings')
wait_for_requests
end
@@ -30,8 +29,8 @@ RSpec.describe 'Alert integrations settings form', :js do
end
end
- it 'shows the new alerts setting form' do
- expect(page).to have_content('1. Select integration type')
+ it 'shows the integrations list title' do
+ expect(page).to have_content('Current integrations')
end
end
end
@@ -44,7 +43,7 @@ RSpec.describe 'Alert integrations settings form', :js do
wait_for_requests
end
- it 'shows the old alerts setting form' do
+ it 'does not have rights to access the setting form' do
expect(page).not_to have_selector('.incident-management-list')
expect(page).not_to have_selector('#js-alert-management-settings')
end
diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb
index 2d6b669f28b..2392f9d2f8a 100644
--- a/spec/features/boards/boards_spec.rb
+++ b/spec/features/boards/boards_spec.rb
@@ -13,8 +13,6 @@ RSpec.describe 'Issue Boards', :js do
let_it_be(:user2) { create(:user) }
before do
- stub_feature_flags(board_new_list: false)
-
project.add_maintainer(user)
project.add_maintainer(user2)
@@ -68,6 +66,8 @@ RSpec.describe 'Issue Boards', :js do
let_it_be(:issue10) { create(:labeled_issue, project: project, title: 'issue +', description: 'A+ great issue', labels: [a_plus]) }
before do
+ stub_feature_flags(board_new_list: false)
+
visit project_board_path(project, board)
wait_for_requests
@@ -168,19 +168,6 @@ RSpec.describe 'Issue Boards', :js do
expect(page).to have_selector('.board', count: 3)
end
- it 'removes checkmark in new list dropdown after deleting' do
- click_button 'Add list'
- wait_for_requests
-
- find('.js-new-board-list').click
-
- remove_list
-
- wait_for_requests
-
- expect(page).to have_selector('.board', count: 3)
- end
-
it 'infinite scrolls list' do
create_list(:labeled_issue, 50, project: project, labels: [planning])
@@ -311,7 +298,7 @@ RSpec.describe 'Issue Boards', :js do
it 'shows issue count on the list' do
page.within(find(".board:nth-child(2)")) do
- expect(page.find('.js-issue-size')).to have_text(total_planning_issues)
+ expect(page.find('[data-testid="board-items-count"]')).to have_text(total_planning_issues)
expect(page).not_to have_selector('.js-max-issue-size')
end
end
@@ -321,6 +308,7 @@ RSpec.describe 'Issue Boards', :js do
context 'new list' do
it 'shows all labels in new list dropdown' do
click_button 'Add list'
+
wait_for_requests
page.within('.dropdown-menu-issues-board-new') do
diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb
index 08bc70d7116..c79bf2abff1 100644
--- a/spec/features/boards/sidebar_spec.rb
+++ b/spec/features/boards/sidebar_spec.rb
@@ -72,36 +72,6 @@ RSpec.describe 'Issue Boards', :js do
end
end
- it 'removes card from board when clicking' do
- click_card(card)
-
- page.within('.issue-boards-sidebar') do
- click_button 'Remove from board'
- end
-
- wait_for_requests
-
- page.within(find('.board:nth-child(2)')) do
- expect(page).to have_selector('.board-card', count: 1)
- end
- end
-
- it 'does not show remove button for backlog or closed issues' do
- create(:issue, project: project)
- create(:issue, :closed, project: project)
-
- visit project_board_path(project, board)
- wait_for_requests
-
- click_card(find('.board:nth-child(1)').first('.board-card'))
-
- expect(find('.issue-boards-sidebar')).not_to have_button 'Remove from board'
-
- click_card(find('.board:nth-child(3)').first('.board-card'))
-
- expect(find('.issue-boards-sidebar')).not_to have_button 'Remove from board'
- end
-
context 'assignee' do
it 'updates the issues assignee' do
click_card(card)
diff --git a/spec/features/boards/user_adds_lists_to_board_spec.rb b/spec/features/boards/user_adds_lists_to_board_spec.rb
new file mode 100644
index 00000000000..b9945207bb2
--- /dev/null
+++ b/spec/features/boards/user_adds_lists_to_board_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'User adds lists', :js do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:group) { create(:group, :nested) }
+ let_it_be(:project) { create(:project, :public, namespace: group) }
+ let_it_be(:group_board) { create(:board, group: group) }
+ let_it_be(:project_board) { create(:board, project: project) }
+ let_it_be(:user) { create(:user) }
+
+ let_it_be(:milestone) { create(:milestone, project: project) }
+
+ let_it_be(:group_label) { create(:group_label, group: group) }
+ let_it_be(:project_label) { create(:label, project: project) }
+ let_it_be(:group_backlog_list) { create(:backlog_list, board: group_board) }
+ let_it_be(:project_backlog_list) { create(:backlog_list, board: project_board) }
+
+ let_it_be(:issue) { create(:labeled_issue, project: project, labels: [group_label, project_label]) }
+
+ before_all do
+ project.add_maintainer(user)
+ group.add_owner(user)
+ end
+
+ where(:board_type, :graphql_board_lists_enabled, :board_new_list_enabled) do
+ :project | true | true
+ :project | false | true
+ :project | true | false
+ :project | false | false
+ :group | true | true
+ :group | false | true
+ :group | true | false
+ :group | false | false
+ end
+
+ with_them do
+ before do
+ sign_in(user)
+
+ set_cookie('sidebar_collapsed', 'true')
+
+ stub_feature_flags(
+ graphql_board_lists: graphql_board_lists_enabled,
+ board_new_list: board_new_list_enabled
+ )
+
+ if board_type == :project
+ visit project_board_path(project, project_board)
+ elsif board_type == :group
+ visit group_board_path(group, group_board)
+ end
+
+ wait_for_all_requests
+ end
+
+ it 'creates new column for label containing labeled issue' do
+ click_button button_text(board_new_list_enabled)
+ wait_for_all_requests
+
+ select_label(board_new_list_enabled, group_label)
+
+ wait_for_all_requests
+
+ expect(page).to have_selector('.board', text: group_label.title)
+ expect(find('.board:nth-child(2) .board-card')).to have_content(issue.title)
+ end
+ end
+
+ def select_label(board_new_list_enabled, label)
+ if board_new_list_enabled
+ page.within('.board-add-new-list') do
+ find('label', text: label.title).click
+ click_button 'Add'
+ end
+ else
+ page.within('.dropdown-menu-issues-board-new') do
+ click_link label.title
+ end
+ end
+ end
+
+ def button_text(board_new_list_enabled)
+ if board_new_list_enabled
+ 'Create list'
+ else
+ 'Add list'
+ end
+ end
+end
diff --git a/spec/features/commit_spec.rb b/spec/features/commit_spec.rb
index 02754cc803e..80a30ab01b2 100644
--- a/spec/features/commit_spec.rb
+++ b/spec/features/commit_spec.rb
@@ -35,9 +35,8 @@ RSpec.describe 'Commit' do
end
end
- context "pagination enabled" do
+ describe "pagination" do
before do
- stub_feature_flags(paginate_commit_view: true)
stub_const("Projects::CommitController::COMMIT_DIFFS_PER_PAGE", 1)
visit project_commit_path(project, commit)
@@ -61,18 +60,5 @@ RSpec.describe 'Commit' do
expect(page).to have_selector(".files ##{files[1].file_hash}")
end
end
-
- context "pagination disabled" do
- before do
- stub_feature_flags(paginate_commit_view: false)
-
- visit project_commit_path(project, commit)
- end
-
- it "shows both diffs on the page" do
- expect(page).to have_selector(".files ##{files[0].file_hash}")
- expect(page).to have_selector(".files ##{files[1].file_hash}")
- end
- end
end
end
diff --git a/spec/features/dashboard/group_spec.rb b/spec/features/dashboard/group_spec.rb
index 0b99fed2a2d..bc6f449edc5 100644
--- a/spec/features/dashboard/group_spec.rb
+++ b/spec/features/dashboard/group_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe 'Dashboard Group' do
it 'creates new group', :js do
visit dashboard_groups_path
- find('.btn-success').click
+ find('[data-testid="new-group-button"]').click
new_name = 'Samurai'
fill_in 'group_name', with: new_name
diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb
index 8705c22c41a..d7330b5267b 100644
--- a/spec/features/dashboard/projects_spec.rb
+++ b/spec/features/dashboard/projects_spec.rb
@@ -198,14 +198,6 @@ RSpec.describe 'Dashboard Projects' do
it_behaves_like 'hidden pipeline status'
end
- context 'when dashboard_pipeline_status is disabled' do
- before do
- stub_feature_flags(dashboard_pipeline_status: false)
- end
-
- it_behaves_like 'hidden pipeline status'
- end
-
context "when last_pipeline is missing" do
before do
project.last_pipeline.delete
diff --git a/spec/features/discussion_comments/commit_spec.rb b/spec/features/discussion_comments/commit_spec.rb
index 32c0ba2a9a7..261e9fb9f3b 100644
--- a/spec/features/discussion_comments/commit_spec.rb
+++ b/spec/features/discussion_comments/commit_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe 'Thread Comments Commit', :js do
visit project_commit_path(project, sample_commit.id)
end
- it_behaves_like 'thread comments', 'commit'
+ it_behaves_like 'thread comments for commit and snippet', 'commit'
it 'has class .js-note-emoji' do
expect(page).to have_css('.js-note-emoji')
diff --git a/spec/features/discussion_comments/issue_spec.rb b/spec/features/discussion_comments/issue_spec.rb
index 86743e31fbd..ebb57b37918 100644
--- a/spec/features/discussion_comments/issue_spec.rb
+++ b/spec/features/discussion_comments/issue_spec.rb
@@ -8,13 +8,11 @@ RSpec.describe 'Thread Comments Issue', :js do
let(:issue) { create(:issue, project: project) }
before do
- stub_feature_flags(remove_comment_close_reopen: false)
-
project.add_maintainer(user)
sign_in(user)
visit project_issue_path(project, issue)
end
- it_behaves_like 'thread comments', 'issue'
+ it_behaves_like 'thread comments for issue, epic and merge request', 'issue'
end
diff --git a/spec/features/discussion_comments/merge_request_spec.rb b/spec/features/discussion_comments/merge_request_spec.rb
index 82dcdf9f918..f60d7da6a30 100644
--- a/spec/features/discussion_comments/merge_request_spec.rb
+++ b/spec/features/discussion_comments/merge_request_spec.rb
@@ -9,7 +9,6 @@ RSpec.describe 'Thread Comments Merge Request', :js do
before do
stub_feature_flags(remove_resolve_note: false)
- stub_feature_flags(remove_comment_close_reopen: false)
project.add_maintainer(user)
sign_in(user)
@@ -20,5 +19,5 @@ RSpec.describe 'Thread Comments Merge Request', :js do
wait_for_requests
end
- it_behaves_like 'thread comments', 'merge request'
+ it_behaves_like 'thread comments for issue, epic and merge request', 'merge request'
end
diff --git a/spec/features/discussion_comments/snippets_spec.rb b/spec/features/discussion_comments/snippets_spec.rb
index 42053e571e9..ca0a6d6e1c5 100644
--- a/spec/features/discussion_comments/snippets_spec.rb
+++ b/spec/features/discussion_comments/snippets_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe 'Thread Comments Snippet', :js do
visit project_snippet_path(project, snippet)
end
- it_behaves_like 'thread comments', 'snippet'
+ it_behaves_like 'thread comments for commit and snippet', 'snippet'
end
context 'with personal snippets' do
@@ -32,6 +32,6 @@ RSpec.describe 'Thread Comments Snippet', :js do
visit snippet_path(snippet)
end
- it_behaves_like 'thread comments', 'snippet'
+ it_behaves_like 'thread comments for commit and snippet', 'snippet'
end
end
diff --git a/spec/features/expand_collapse_diffs_spec.rb b/spec/features/expand_collapse_diffs_spec.rb
index 55bdf4c244e..cbd1ae628d1 100644
--- a/spec/features/expand_collapse_diffs_spec.rb
+++ b/spec/features/expand_collapse_diffs_spec.rb
@@ -17,7 +17,6 @@ RSpec.describe 'Expand and collapse diffs', :js do
# Ensure that undiffable.md is in .gitattributes
project.repository.copy_gitattributes(branch)
visit project_commit_path(project, project.commit(branch))
- execute_script('window.ajaxUris = []; $(document).ajaxSend(function(event, xhr, settings) { ajaxUris.push(settings.url) });')
end
def file_container(filename)
@@ -191,10 +190,6 @@ RSpec.describe 'Expand and collapse diffs', :js do
expect(small_diff).to have_selector('.code')
expect(small_diff).not_to have_selector('.nothing-here-block')
end
-
- it 'does not make a new HTTP request' do
- expect(evaluate_script('ajaxUris')).not_to include(a_string_matching('small_diff.md'))
- end
end
end
@@ -264,7 +259,6 @@ RSpec.describe 'Expand and collapse diffs', :js do
find('.note-textarea')
wait_for_requests
- execute_script('window.ajaxUris = []; $(document).ajaxSend(function(event, xhr, settings) { ajaxUris.push(settings.url) });')
end
it 'reloads the page with all diffs expanded' do
@@ -300,10 +294,6 @@ RSpec.describe 'Expand and collapse diffs', :js do
expect(small_diff).to have_selector('.code')
expect(small_diff).not_to have_selector('.nothing-here-block')
end
-
- it 'does not make a new HTTP request' do
- expect(evaluate_script('ajaxUris')).not_to include(a_string_matching('small_diff.md'))
- end
end
end
end
diff --git a/spec/features/groups/container_registry_spec.rb b/spec/features/groups/container_registry_spec.rb
index cacabdda22d..65374263f45 100644
--- a/spec/features/groups/container_registry_spec.rb
+++ b/spec/features/groups/container_registry_spec.rb
@@ -67,7 +67,13 @@ RSpec.describe 'Container Registry', :js do
end
it 'shows the image title' do
- expect(page).to have_content 'my/image tags'
+ expect(page).to have_content 'my/image'
+ end
+
+ it 'shows the image tags' do
+ expect(page).to have_content 'Image tags'
+ first_tag = first('[data-testid="name"]')
+ expect(first_tag).to have_content 'latest'
end
it 'user removes a specific tag from container repository' do
diff --git a/spec/features/groups/members/list_members_spec.rb b/spec/features/groups/members/list_members_spec.rb
index b0a896ec8cb..b81949da85d 100644
--- a/spec/features/groups/members/list_members_spec.rb
+++ b/spec/features/groups/members/list_members_spec.rb
@@ -47,4 +47,46 @@ RSpec.describe 'Groups > Members > List members', :js do
expect(first_row).to have_selector('gl-emoji[data-name="smirk"]')
end
end
+
+ describe 'when user has 2FA enabled' do
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:user_with_2fa) { create(:user, :two_factor_via_otp) }
+
+ before do
+ group.add_guest(user_with_2fa)
+ end
+
+ it 'shows 2FA badge to user with "Owner" access level' do
+ group.add_owner(user1)
+
+ visit group_group_members_path(group)
+
+ expect(find_member_row(user_with_2fa)).to have_content('2FA')
+ end
+
+ it 'shows 2FA badge to admins' do
+ sign_in(admin)
+ gitlab_enable_admin_mode_sign_in(admin)
+
+ visit group_group_members_path(group)
+
+ expect(find_member_row(user_with_2fa)).to have_content('2FA')
+ end
+
+ it 'does not show 2FA badge to users with access level below "Owner"' do
+ group.add_maintainer(user1)
+
+ visit group_group_members_path(group)
+
+ expect(find_member_row(user_with_2fa)).not_to have_content('2FA')
+ end
+
+ it 'shows 2FA badge to themselves' do
+ sign_in(user_with_2fa)
+
+ visit group_group_members_path(group)
+
+ expect(find_member_row(user_with_2fa)).to have_content('2FA')
+ end
+ end
end
diff --git a/spec/features/groups/members/manage_members_spec.rb b/spec/features/groups/members/manage_members_spec.rb
index c27d0afba6f..3b637a10abe 100644
--- a/spec/features/groups/members/manage_members_spec.rb
+++ b/spec/features/groups/members/manage_members_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe 'Groups > Members > Manage members' do
sign_in(user1)
end
- shared_examples 'includes the correct Invite Members link' do |should_include, should_not_include|
+ shared_examples 'includes the correct Invite link' do |should_include, should_not_include|
it 'includes either the form or the modal trigger' do
group.add_owner(user1)
@@ -31,15 +31,13 @@ RSpec.describe 'Groups > Members > Manage members' do
stub_feature_flags(invite_members_group_modal: true)
end
- it_behaves_like 'includes the correct Invite Members link', '.js-invite-members-trigger', '.invite-users-form'
+ it_behaves_like 'includes the correct Invite link', '.js-invite-members-trigger', '.invite-users-form'
+ it_behaves_like 'includes the correct Invite link', '.js-invite-group-trigger', '.invite-group-form'
end
context 'when Invite Members modal is disabled' do
- before do
- stub_feature_flags(invite_members_group_modal: false)
- end
-
- it_behaves_like 'includes the correct Invite Members link', '.invite-users-form', '.js-invite-members-trigger'
+ it_behaves_like 'includes the correct Invite link', '.invite-users-form', '.js-invite-members-trigger'
+ it_behaves_like 'includes the correct Invite link', '.invite-group-form', '.js-invite-group-trigger'
end
it 'update user to owner level', :js do
diff --git a/spec/features/groups/settings/user_searches_in_settings_spec.rb b/spec/features/groups/settings/user_searches_in_settings_spec.rb
new file mode 100644
index 00000000000..819d0c4faba
--- /dev/null
+++ b/spec/features/groups/settings/user_searches_in_settings_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'User searches group settings', :js do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, :repository, namespace: group) }
+
+ before do
+ group.add_owner(user)
+ sign_in(user)
+ end
+
+ context 'in general settings page' do
+ let(:visit_path) { edit_group_path(group) }
+
+ it_behaves_like 'can search settings with feature flag check', 'Naming', 'Permissions'
+ end
+
+ context 'in Repository page' do
+ before do
+ visit group_settings_repository_path(group)
+ end
+
+ it_behaves_like 'can search settings', 'Deploy tokens', 'Default initial branch name'
+ end
+
+ context 'in CI/CD page' do
+ before do
+ visit group_settings_ci_cd_path(group)
+ end
+
+ it_behaves_like 'can search settings', 'Variables', 'Runners'
+ end
+end
diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb
index 5067f11be67..4bcba4c21ed 100644
--- a/spec/features/groups/show_spec.rb
+++ b/spec/features/groups/show_spec.rb
@@ -163,7 +163,6 @@ RSpec.describe 'Group show page' do
let!(:project) { create(:project, namespace: group) }
before do
- stub_feature_flags(vue_notification_dropdown: false)
group.add_maintainer(maintainer)
sign_in(maintainer)
end
@@ -171,14 +170,14 @@ RSpec.describe 'Group show page' do
it 'is enabled by default' do
visit path
- expect(page).to have_selector('.notifications-btn:not(.disabled)', visible: true)
+ expect(page).to have_selector('[data-testid="notification-dropdown"] button:not(.disabled)')
end
it 'is disabled if emails are disabled' do
group.update_attribute(:emails_disabled, true)
visit path
- expect(page).to have_selector('.notifications-btn.disabled', visible: true)
+ expect(page).to have_selector('[data-testid="notification-dropdown"] .disabled')
end
end
diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb
index c9a0844932a..28b22860f0a 100644
--- a/spec/features/groups_spec.rb
+++ b/spec/features/groups_spec.rb
@@ -143,7 +143,7 @@ RSpec.describe 'Group' do
end
end
- describe 'create a nested group', :js do
+ describe 'create a nested group' do
let_it_be(:group) { create(:group, path: 'foo') }
context 'as admin' do
@@ -153,13 +153,21 @@ RSpec.describe 'Group' do
visit new_group_path(group, parent_id: group.id)
end
- it 'creates a nested group' do
- fill_in 'Group name', with: 'bar'
- fill_in 'Group URL', with: 'bar'
- click_button 'Create group'
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'creates a nested group' do
+ fill_in 'Group name', with: 'bar'
+ fill_in 'Group URL', with: 'bar'
+ click_button 'Create group'
- expect(current_path).to eq(group_path('foo/bar'))
- expect(page).to have_content("Group 'bar' was successfully created.")
+ expect(current_path).to eq(group_path('foo/bar'))
+ expect(page).to have_content("Group 'bar' was successfully created.")
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it 'is not allowed' do
+ expect(page).to have_gitlab_http_status(:not_found)
+ end
end
end
diff --git a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb
index d773126e00c..a4e9df604a9 100644
--- a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb
+++ b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb
@@ -89,6 +89,8 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j
before do
page.within '.mr-widget-body' do
page.click_link 'Resolve all threads in new issue', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
+
+ wait_for_all_requests
end
end
diff --git a/spec/features/issues/csv_spec.rb b/spec/features/issues/csv_spec.rb
index c93693ec40a..d41a41c4383 100644
--- a/spec/features/issues/csv_spec.rb
+++ b/spec/features/issues/csv_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Issues csv' do
+RSpec.describe 'Issues csv', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:milestone) { create(:milestone, title: 'v1.0', project: project) }
@@ -17,7 +17,7 @@ RSpec.describe 'Issues csv' do
def request_csv(params = {})
visit project_issues_path(project, params)
page.within('.nav-controls') do
- click_on 'Export as CSV'
+ find('[data-testid="export-csv-button"]').click
end
click_on 'Export issues'
end
diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb
index e2087868035..e6ebc37ba59 100644
--- a/spec/features/issues/gfm_autocomplete_spec.rb
+++ b/spec/features/issues/gfm_autocomplete_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe 'GFM autocomplete', :js do
let_it_be(:user_xss_title) { 'eve <img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;' }
let_it_be(:user_xss) { create(:user, name: user_xss_title, username: 'xss.user') }
let_it_be(:user) { create(:user, name: '💃speciąl someone💃', username: 'someone.special') }
+ let_it_be(:user2) { create(:user, name: 'Marge Simpson', username: 'msimpson') }
let_it_be(:group) { create(:group, name: 'Ancestor') }
let_it_be(:child_group) { create(:group, parent: group, name: 'My group') }
let_it_be(:project) { create(:project, group: child_group) }
@@ -16,6 +17,7 @@ RSpec.describe 'GFM autocomplete', :js do
before_all do
project.add_maintainer(user)
project.add_maintainer(user_xss)
+ project.add_maintainer(user2)
end
describe 'when tribute_autocomplete feature flag is off' do
@@ -29,289 +31,218 @@ RSpec.describe 'GFM autocomplete', :js do
end
it 'updates issue description with GFM reference' do
- find('.js-issuable-edit').click
+ click_button 'Edit title and description'
wait_for_requests
- simulate_input('#issue-description', "@#{user.name[0...3]}")
+ fill_in 'Description', with: "@#{user.name[0...3]}"
wait_for_requests
- find('.atwho-view .cur').click
+ find_highlighted_autocomplete_item.click
click_button 'Save changes'
wait_for_requests
- expect(find('.description')).to have_content(user.to_reference)
+ expect(find('.description')).to have_text(user.to_reference)
end
it 'opens quick action autocomplete when updating description' do
- find('.js-issuable-edit').click
+ click_button 'Edit title and description'
- find('#issue-description').native.send_keys('/')
+ fill_in 'Description', with: '/'
- expect(page).to have_selector('.atwho-container')
+ expect(find_autocomplete_menu).to be_visible
end
it 'opens autocomplete menu when field starts with text' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('@')
- end
+ fill_in 'Comment', with: '@'
- expect(page).to have_selector('.atwho-container')
+ expect(find_autocomplete_menu).to be_visible
end
it 'opens autocomplete menu for Issues when field starts with text with item escaping HTML characters' do
issue_xss_title = 'This will execute alert<img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;'
create(:issue, project: project, title: issue_xss_title)
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('#')
- end
+ fill_in 'Comment', with: '#'
wait_for_requests
- expect(page).to have_selector('.atwho-container')
-
- page.within '.atwho-container #at-view-issues' do
- expect(page.all('li').first.text).to include(issue_xss_title)
- end
+ expect(find_autocomplete_menu).to have_text(issue_xss_title)
end
it 'opens autocomplete menu for Username when field starts with text with item escaping HTML characters' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('@ev')
- end
+ fill_in 'Comment', with: '@ev'
wait_for_requests
- expect(page).to have_selector('.atwho-container')
-
- page.within '.atwho-container #at-view-users' do
- expect(find('li').text).to have_content(user_xss.username)
- end
+ expect(find_highlighted_autocomplete_item).to have_text(user_xss.username)
end
it 'opens autocomplete menu for Milestone when field starts with text with item escaping HTML characters' do
milestone_xss_title = 'alert milestone &lt;img src=x onerror="alert(\'Hello xss\');" a'
create(:milestone, project: project, title: milestone_xss_title)
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('%')
- end
+ fill_in 'Comment', with: '%'
wait_for_requests
- expect(page).to have_selector('.atwho-container')
-
- page.within '.atwho-container #at-view-milestones' do
- expect(find('li').text).to have_content('alert milestone')
- end
+ expect(find_autocomplete_menu).to have_text('alert milestone')
end
it 'doesnt open autocomplete menu character is prefixed with text' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('testing')
- find('#note-body').native.send_keys('@')
- end
+ fill_in 'Comment', with: 'testing@'
- expect(page).not_to have_selector('.atwho-view')
+ expect(page).not_to have_css('.atwho-view')
end
it 'doesnt select the first item for non-assignee dropdowns' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys(':')
- end
-
- expect(page).to have_selector('.atwho-container')
+ fill_in 'Comment', with: ':'
wait_for_requests
- expect(find('#at-view-58')).not_to have_selector('.cur:first-of-type')
+ expect(find_autocomplete_menu).not_to have_css('.cur')
end
it 'does not open autocomplete menu when ":" is prefixed by a number and letters' do
- note = find('#note-body')
-
# Number.
- page.within '.timeline-content-form' do
- note.native.send_keys('7:')
- end
-
- expect(page).not_to have_selector('.atwho-view')
+ fill_in 'Comment', with: '7:'
+ expect(page).not_to have_css('.atwho-view')
# ASCII letter.
- page.within '.timeline-content-form' do
- note.set('')
- note.native.send_keys('w:')
- end
-
- expect(page).not_to have_selector('.atwho-view')
+ fill_in 'Comment', with: 'w:'
+ expect(page).not_to have_css('.atwho-view')
# Non-ASCII letter.
- page.within '.timeline-content-form' do
- note.set('')
- note.native.send_keys('Ё:')
- end
-
- expect(page).not_to have_selector('.atwho-view')
+ fill_in 'Comment', with: 'Ё:'
+ expect(page).not_to have_css('.atwho-view')
end
it 'selects the first item for assignee dropdowns' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('@')
- end
-
- expect(page).to have_selector('.atwho-container')
+ fill_in 'Comment', with: '@'
wait_for_requests
- expect(find('#at-view-users')).to have_selector('.cur:first-of-type')
+ expect(find_autocomplete_menu).to have_css('.cur:first-of-type')
end
it 'includes items for assignee dropdowns with non-ASCII characters in name' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('')
- simulate_input('#note-body', "@#{user.name[0...8]}")
- end
-
- expect(page).to have_selector('.atwho-container')
+ fill_in 'Comment', with: "@#{user.name[0...8]}"
wait_for_requests
- expect(find('#at-view-users')).to have_content(user.name)
+ expect(find_autocomplete_menu).to have_text(user.name)
end
it 'searches across full name for assignees' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('@speciąlsome')
- end
+ fill_in 'Comment', with: '@speciąlsome'
wait_for_requests
- expect(find('.atwho-view li', visible: true)).to have_content(user.name)
+ expect(find_highlighted_autocomplete_item).to have_text(user.name)
end
- it 'selects the first item for non-assignee dropdowns if a query is entered' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys(':1')
- end
+ it 'shows names that start with the query as the top result' do
+ fill_in 'Comment', with: '@mar'
+
+ wait_for_requests
+
+ expect(find_highlighted_autocomplete_item).to have_text(user2.name)
+ end
+
+ it 'shows usernames that start with the query as the top result' do
+ fill_in 'Comment', with: '@msi'
+
+ wait_for_requests
+
+ expect(find_highlighted_autocomplete_item).to have_text(user2.name)
+ end
+
+ # Regression test for https://gitlab.com/gitlab-org/gitlab/-/issues/321925
+ it 'shows username when pasting then pressing Enter' do
+ fill_in 'Comment', with: "@#{user.username}\n"
+
+ expect(find_field('Comment').value).to have_text "@#{user.username}"
+ end
- expect(page).to have_selector('.atwho-container')
+ it 'does not show `@undefined` when pressing `@` then Enter' do
+ fill_in 'Comment', with: "@\n"
+
+ expect(find_field('Comment').value).to have_text '@'
+ expect(find_field('Comment').value).not_to have_text '@undefined'
+ end
+
+ it 'selects the first item for non-assignee dropdowns if a query is entered' do
+ fill_in 'Comment', with: ':1'
wait_for_requests
- expect(find('#at-view-58')).to have_selector('.cur:first-of-type')
+ expect(find_autocomplete_menu).to have_css('.cur:first-of-type')
end
context 'if a selected value has special characters' do
it 'wraps the result in double quotes' do
- note = find('#note-body')
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('')
- simulate_input('#note-body', "~#{label.title[0]}")
- end
+ fill_in 'Comment', with: "~#{label.title[0]}"
- label_item = find('.atwho-view li', text: label.title)
+ find_highlighted_autocomplete_item.click
- expect_to_wrap(true, label_item, note, label.title)
+ expect(find_field('Comment').value).to have_text("~\"#{label.title}\"")
end
it "shows dropdown after a new line" do
- note = find('#note-body')
- page.within '.timeline-content-form' do
- note.native.send_keys('test')
- note.native.send_keys(:enter)
- note.native.send_keys(:enter)
- note.native.send_keys('@')
- end
+ fill_in 'Comment', with: "test\n\n@"
- expect(page).to have_selector('.atwho-container')
+ expect(find_autocomplete_menu).to be_visible
end
it "does not show dropdown when preceded with a special character" do
- note = find('#note-body')
- page.within '.timeline-content-form' do
- note.native.send_keys("@")
- end
-
- expect(page).to have_selector('.atwho-container')
-
- page.within '.timeline-content-form' do
- note.native.send_keys("@")
- end
+ fill_in 'Comment', with: '@@'
- expect(page).to have_selector('.atwho-container', visible: false)
- end
-
- it "does not throw an error if no labels exist" do
- note = find('#note-body')
- page.within '.timeline-content-form' do
- note.native.send_keys('~')
- end
-
- expect(page).to have_selector('.atwho-container', visible: false)
+ expect(page).not_to have_css('.atwho-view')
end
it 'doesn\'t wrap for assignee values' do
- note = find('#note-body')
- page.within '.timeline-content-form' do
- note.native.send_keys("@#{user.username[0]}")
- end
+ fill_in 'Comment', with: "@#{user.username[0]}"
- user_item = find('.atwho-view li', text: user.username)
+ find_highlighted_autocomplete_item.click
- expect_to_wrap(false, user_item, note, user.username)
+ expect(find_field('Comment').value).to have_text("@#{user.username}")
end
it 'doesn\'t wrap for emoji values' do
- note = find('#note-body')
- page.within '.timeline-content-form' do
- note.native.send_keys(":cartwheel_")
- end
+ fill_in 'Comment', with: ':cartwheel_'
- emoji_item = find('.atwho-view li', text: 'cartwheel_tone1')
+ find_highlighted_autocomplete_item.click
- expect_to_wrap(false, emoji_item, note, 'cartwheel_tone1')
+ expect(find_field('Comment').value).to have_text('cartwheel_tone1')
end
it 'doesn\'t open autocomplete after non-word character' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys("@#{user.username[0..2]}!")
- end
+ fill_in 'Comment', with: "@#{user.username[0..2]}!"
- expect(page).not_to have_selector('.atwho-view')
+ expect(page).not_to have_css('.atwho-view')
end
it 'doesn\'t open autocomplete if there is no space before' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys("hello:#{user.username[0..2]}")
- end
+ fill_in 'Comment', with: "hello:#{user.username[0..2]}"
- expect(page).not_to have_selector('.atwho-view')
+ expect(page).not_to have_css('.atwho-view')
end
it 'triggers autocomplete after selecting a quick action' do
- note = find('#note-body')
- page.within '.timeline-content-form' do
- note.native.send_keys('/as')
- end
+ fill_in 'Comment', with: '/as'
- find('.atwho-view li', text: '/assign')
- note.native.send_keys(:tab)
+ find_highlighted_autocomplete_item.click
- user_item = find('.atwho-view li', text: user.username)
- expect(user_item).to have_content(user.username)
+ expect(find_autocomplete_menu).to have_text(user.username)
end
it 'does not limit quick actions autocomplete list to 5' do
- note = find('#note-body')
- page.within '.timeline-content-form' do
- note.native.send_keys('/')
- end
+ fill_in 'Comment', with: '/'
- expect(page).to have_selector('.atwho-view li', minimum: 6, visible: true)
+ expect(find_autocomplete_menu).to have_css('li', minimum: 6)
end
end
@@ -328,30 +259,23 @@ RSpec.describe 'GFM autocomplete', :js do
it 'lists users who are currently not assigned to the issue when using /assign' do
visit project_issue_path(project, issue_assignee)
- note = find('#note-body')
- page.within '.timeline-content-form' do
- note.native.send_keys('/as')
- end
-
- find('.atwho-view li', text: '/assign')
- note.native.send_keys(:tab)
+ fill_in 'Comment', with: '/as'
- wait_for_requests
+ find_highlighted_autocomplete_item.click
- expect(find('#at-view-users .atwho-view-ul')).not_to have_content(user.username)
- expect(find('#at-view-users .atwho-view-ul')).to have_content(unassigned_user.username)
+ expect(find_autocomplete_menu).not_to have_text(user.username)
+ expect(find_autocomplete_menu).to have_text(unassigned_user.username)
end
it 'shows dropdown on new issue form' do
visit new_project_issue_path(project)
- textarea = find('#issue_description')
- textarea.native.send_keys('/ass')
- find('.atwho-view li', text: '/assign')
- textarea.native.send_keys(:tab)
+ fill_in 'Description', with: '/ass'
- expect(find('#at-view-users .atwho-view-ul')).to have_content(unassigned_user.username)
- expect(find('#at-view-users .atwho-view-ul')).to have_content(user.username)
+ find_highlighted_autocomplete_item.click
+
+ expect(find_autocomplete_menu).to have_text(unassigned_user.username)
+ expect(find_autocomplete_menu).to have_text(user.username)
end
end
@@ -360,80 +284,62 @@ RSpec.describe 'GFM autocomplete', :js do
label_xss_title = 'alert label &lt;img src=x onerror="alert(\'Hello xss\');" a'
create(:label, project: project, title: label_xss_title)
- note = find('#note-body')
-
- # It should show all the labels on "~".
- type(note, '~')
+ fill_in 'Comment', with: '~'
wait_for_requests
- page.within '.atwho-container #at-view-labels' do
- expect(find('.atwho-view-ul').text).to have_content('alert label')
- end
+ expect(find_autocomplete_menu).to have_text('alert label')
end
it 'allows colons when autocompleting scoped labels' do
create(:label, project: project, title: 'scoped:label')
- note = find('#note-body')
- type(note, '~scoped:')
+ fill_in 'Comment', with: '~scoped:'
wait_for_requests
- page.within '.atwho-container #at-view-labels' do
- expect(find('.atwho-view-ul').text).to have_content('scoped:label')
- end
+ expect(find_autocomplete_menu).to have_text('scoped:label')
end
it 'allows colons when autocompleting scoped labels with double colons' do
create(:label, project: project, title: 'scoped::label')
- note = find('#note-body')
- type(note, '~scoped::')
+ fill_in 'Comment', with: '~scoped::'
wait_for_requests
- page.within '.atwho-container #at-view-labels' do
- expect(find('.atwho-view-ul').text).to have_content('scoped::label')
- end
+ expect(find_autocomplete_menu).to have_text('scoped::label')
end
it 'allows spaces when autocompleting multi-word labels' do
create(:label, project: project, title: 'Accepting merge requests')
- note = find('#note-body')
- type(note, '~Accepting merge')
+ fill_in 'Comment', with: '~Accepting merge'
wait_for_requests
- page.within '.atwho-container #at-view-labels' do
- expect(find('.atwho-view-ul').text).to have_content('Accepting merge requests')
- end
+ expect(find_autocomplete_menu).to have_text('Accepting merge requests')
end
it 'only autocompletes the latest label' do
create(:label, project: project, title: 'Accepting merge requests')
create(:label, project: project, title: 'Accepting job applicants')
- note = find('#note-body')
- type(note, '~Accepting merge requests foo bar ~Accepting job')
+ fill_in 'Comment', with: '~Accepting merge requests foo bar ~Accepting job'
wait_for_requests
- page.within '.atwho-container #at-view-labels' do
- expect(find('.atwho-view-ul').text).to have_content('Accepting job applicants')
- end
+ expect(find_autocomplete_menu).to have_text('Accepting job applicants')
end
it 'does not autocomplete labels if no tilde is typed' do
create(:label, project: project, title: 'Accepting merge requests')
- note = find('#note-body')
- type(note, 'Accepting merge')
+ fill_in 'Comment', with: 'Accepting merge'
wait_for_requests
- expect(page).not_to have_css('.atwho-container #at-view-labels')
+ expect(page).not_to have_css('.atwho-view')
end
end
@@ -443,7 +349,7 @@ RSpec.describe 'GFM autocomplete', :js do
# This is meant to protect against this issue https://gitlab.com/gitlab-org/gitlab/-/issues/228729
it 'keeps autocomplete key listeners' do
visit project_issue_path(project, issue)
- note = find('#note-body')
+ note = find_field('Comment')
start_comment_with_emoji(note, '.atwho-view li')
@@ -459,17 +365,11 @@ RSpec.describe 'GFM autocomplete', :js do
shared_examples 'autocomplete suggestions' do
it 'suggests objects correctly' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys(object.class.reference_prefix)
- end
-
- page.within '.atwho-container' do
- expect(page).to have_content(object.title)
+ fill_in 'Comment', with: object.class.reference_prefix
- find('ul li').click
- end
+ find_autocomplete_menu.find('li').click
- expect(find('.new-note #note-body').value).to include(expected_body)
+ expect(find_field('Comment').value).to have_text(expected_body)
end
end
@@ -502,10 +402,40 @@ RSpec.describe 'GFM autocomplete', :js do
end
context 'milestone' do
- let!(:object) { create(:milestone, project: project) }
- let(:expected_body) { object.to_reference }
+ let_it_be(:milestone_expired) { create(:milestone, project: project, due_date: 5.days.ago) }
+ let_it_be(:milestone_no_duedate) { create(:milestone, project: project, title: 'Foo - No due date') }
+ let_it_be(:milestone1) { create(:milestone, project: project, title: 'Milestone-1', due_date: 20.days.from_now) }
+ let_it_be(:milestone2) { create(:milestone, project: project, title: 'Milestone-2', due_date: 15.days.from_now) }
+ let_it_be(:milestone3) { create(:milestone, project: project, title: 'Milestone-3', due_date: 10.days.from_now) }
- it_behaves_like 'autocomplete suggestions'
+ before do
+ fill_in 'Comment', with: '/milestone %'
+
+ wait_for_requests
+ end
+
+ it 'shows milestons list in the autocomplete menu' do
+ page.within(find_autocomplete_menu) do
+ expect(page).to have_selector('li', count: 5)
+ end
+ end
+
+ it 'shows expired milestone at the bottom of the list' do
+ page.within(find_autocomplete_menu) do
+ expect(page.find('li:last-child')).to have_content milestone_expired.title
+ end
+ end
+
+ it 'shows milestone due earliest at the top of the list' do
+ page.within(find_autocomplete_menu) do
+ aggregate_failures do
+ expect(page.all('li')[0]).to have_content milestone3.title
+ expect(page.all('li')[1]).to have_content milestone2.title
+ expect(page.all('li')[2]).to have_content milestone1.title
+ expect(page.all('li')[3]).to have_content milestone_no_duedate.title
+ end
+ end
+ end
end
end
@@ -520,237 +450,160 @@ RSpec.describe 'GFM autocomplete', :js do
end
it 'updates issue description with GFM reference' do
- find('.js-issuable-edit').click
+ click_button 'Edit title and description'
wait_for_requests
- simulate_input('#issue-description', "@#{user.name[0...3]}")
+ fill_in 'Description', with: "@#{user.name[0...3]}"
wait_for_requests
- find('.tribute-container .highlight', visible: true).click
+ find_highlighted_tribute_autocomplete_menu.click
click_button 'Save changes'
wait_for_requests
- expect(find('.description')).to have_content(user.to_reference)
+ expect(find('.description')).to have_text(user.to_reference)
end
it 'opens autocomplete menu when field starts with text' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('@')
- end
+ fill_in 'Comment', with: '@'
- expect(page).to have_selector('.tribute-container', visible: true)
+ expect(find_tribute_autocomplete_menu).to be_visible
end
it 'opens autocomplete menu for Issues when field starts with text with item escaping HTML characters' do
issue_xss_title = 'This will execute alert<img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;'
create(:issue, project: project, title: issue_xss_title)
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('#')
- end
+ fill_in 'Comment', with: '#'
wait_for_requests
- expect(page).to have_selector('.tribute-container', visible: true)
-
- page.within '.tribute-container ul' do
- expect(page.all('li').first.text).to include(issue_xss_title)
- end
+ expect(find_tribute_autocomplete_menu).to have_text(issue_xss_title)
end
it 'opens autocomplete menu for Username when field starts with text with item escaping HTML characters' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('@ev')
- end
+ fill_in 'Comment', with: '@ev'
wait_for_requests
- expect(page).to have_selector('.tribute-container', visible: true)
-
- expect(find('.tribute-container ul', visible: true)).to have_text(user_xss.username)
+ expect(find_tribute_autocomplete_menu).to have_text(user_xss.username)
end
it 'opens autocomplete menu for Milestone when field starts with text with item escaping HTML characters' do
milestone_xss_title = 'alert milestone &lt;img src=x onerror="alert(\'Hello xss\');" a'
create(:milestone, project: project, title: milestone_xss_title)
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('%')
- end
+ fill_in 'Comment', with: '%'
wait_for_requests
- expect(page).to have_selector('.tribute-container', visible: true)
-
- expect(find('.tribute-container ul', visible: true)).to have_text('alert milestone')
+ expect(find_tribute_autocomplete_menu).to have_text('alert milestone')
end
it 'does not open autocomplete menu when trigger character is prefixed with text' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('testing')
- find('#note-body').native.send_keys('@')
- end
+ fill_in 'Comment', with: 'testing@'
- expect(page).not_to have_selector('.tribute-container', visible: true)
+ expect(page).not_to have_css('.tribute-container')
end
it 'does not open autocomplete menu when ":" is prefixed by a number and letters' do
- note = find('#note-body')
-
# Number.
- page.within '.timeline-content-form' do
- note.native.send_keys('7:')
- end
-
- expect(page).not_to have_selector('.tribute-container', visible: true)
+ fill_in 'Comment', with: '7:'
+ expect(page).not_to have_css('.tribute-container')
# ASCII letter.
- page.within '.timeline-content-form' do
- note.set('')
- note.native.send_keys('w:')
- end
-
- expect(page).not_to have_selector('.tribute-container', visible: true)
+ fill_in 'Comment', with: 'w:'
+ expect(page).not_to have_css('.tribute-container')
# Non-ASCII letter.
- page.within '.timeline-content-form' do
- note.set('')
- note.native.send_keys('Ё:')
- end
-
- expect(page).not_to have_selector('.tribute-container', visible: true)
+ fill_in 'Comment', with: 'Ё:'
+ expect(page).not_to have_css('.tribute-container')
end
it 'selects the first item for assignee dropdowns' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('@')
- end
-
- expect(page).to have_selector('.tribute-container', visible: true)
+ fill_in 'Comment', with: '@'
wait_for_requests
- expect(find('.tribute-container ul', visible: true)).to have_selector('.highlight:first-of-type')
+ expect(find_tribute_autocomplete_menu).to have_css('.highlight:first-of-type')
end
it 'includes items for assignee dropdowns with non-ASCII characters in name' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('')
- simulate_input('#note-body', "@#{user.name[0...8]}")
- end
-
- expect(page).to have_selector('.tribute-container', visible: true)
+ fill_in 'Comment', with: "@#{user.name[0...8]}"
wait_for_requests
- expect(find('.tribute-container ul', visible: true)).to have_content(user.name)
+ expect(find_tribute_autocomplete_menu).to have_text(user.name)
end
it 'selects the first item for non-assignee dropdowns if a query is entered' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys(':1')
- end
+ fill_in 'Comment', with: ':1'
wait_for_requests
- expect(find('.tribute-container ul', visible: true)).to have_selector('.highlight:first-of-type')
+ expect(find_tribute_autocomplete_menu).to have_css('.highlight:first-of-type')
end
context 'when autocompleting for groups' do
it 'shows the group when searching for the name of the group' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('@mygroup')
- end
+ fill_in 'Comment', with: '@mygroup'
- expect(find('.tribute-container ul', visible: true)).to have_text('My group')
+ expect(find_tribute_autocomplete_menu).to have_text('My group')
end
it 'does not show the group when searching for the name of the parent of the group' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('@ancestor')
- end
+ fill_in 'Comment', with: '@ancestor'
- expect(find('.tribute-container ul', visible: true)).not_to have_text('My group')
+ expect(find_tribute_autocomplete_menu).not_to have_text('My group')
end
end
context 'if a selected value has special characters' do
it 'wraps the result in double quotes' do
- note = find('#note-body')
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys('')
- simulate_input('#note-body', "~#{label.title[0]}")
- end
+ fill_in 'Comment', with: "~#{label.title[0]}"
- label_item = find('.tribute-container ul', text: label.title, visible: true)
+ find_highlighted_tribute_autocomplete_menu.click
- expect_to_wrap(true, label_item, note, label.title)
+ expect(find_field('Comment').value).to have_text("~\"#{label.title}\"")
end
it "shows dropdown after a new line" do
- note = find('#note-body')
- page.within '.timeline-content-form' do
- note.native.send_keys('test')
- note.native.send_keys(:enter)
- note.native.send_keys(:enter)
- note.native.send_keys('@')
- end
-
- expect(page).to have_selector('.tribute-container', visible: true)
- end
-
- it "does not throw an error if no labels exist" do
- note = find('#note-body')
- page.within '.timeline-content-form' do
- note.native.send_keys('~')
- end
+ fill_in 'Comment', with: "test\n\n@"
- expect(page).to have_selector('.tribute-container', visible: false)
+ expect(find_tribute_autocomplete_menu).to be_visible
end
it 'doesn\'t wrap for assignee values' do
- note = find('#note-body')
- page.within '.timeline-content-form' do
- note.native.send_keys("@#{user.username[0]}")
- end
+ fill_in 'Comment', with: "@#{user.username[0..2]}"
- user_item = find('.tribute-container ul', text: user.username, visible: true)
+ find_highlighted_tribute_autocomplete_menu.click
- expect_to_wrap(false, user_item, note, user.username)
+ expect(find_field('Comment').value).to have_text("@#{user.username}")
end
it 'does not wrap for emoji values' do
- note = find('#note-body')
- page.within '.timeline-content-form' do
- note.native.send_keys(":cartwheel_")
- end
+ fill_in 'Comment', with: ':cartwheel_'
- emoji_item = first('.tribute-container li', text: 'cartwheel_tone1', visible: true)
+ find_highlighted_tribute_autocomplete_menu.click
- expect_to_wrap(false, emoji_item, note, 'cartwheel_tone1')
+ expect(find_field('Comment').value).to have_text('cartwheel_tone1')
end
it 'does not open autocomplete if there is no space before' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys("hello:#{user.username[0..2]}")
- end
+ fill_in 'Comment', with: "hello:#{user.username[0..2]}"
- expect(page).not_to have_selector('.tribute-container')
+ expect(page).not_to have_css('.tribute-container')
end
it 'autocompletes for quick actions' do
- note = find('#note-body')
- page.within '.timeline-content-form' do
- note.native.send_keys('/as')
- wait_for_requests
- note.native.send_keys(:tab)
- end
+ fill_in 'Comment', with: '/as'
+
+ find_highlighted_tribute_autocomplete_menu.click
- expect(note.value).to have_text('/assign')
+ expect(find_field('Comment').value).to have_text('/assign')
end
end
@@ -767,37 +620,33 @@ RSpec.describe 'GFM autocomplete', :js do
it 'lists users who are currently not assigned to the issue when using /assign' do
visit project_issue_path(project, issue_assignee)
- note = find('#note-body')
- page.within '.timeline-content-form' do
- note.native.send_keys('/assign ')
- # The `/assign` ajax response might replace the one by `@` below causing a failed test
- # so we need to wait for the `/assign` ajax request to finish first
- wait_for_requests
- note.native.send_keys('@')
- wait_for_requests
- end
+ note = find_field('Comment')
+ note.native.send_keys('/assign ')
+ # The `/assign` ajax response might replace the one by `@` below causing a failed test
+ # so we need to wait for the `/assign` ajax request to finish first
+ wait_for_requests
+ note.native.send_keys('@')
+ wait_for_requests
- expect(find('.tribute-container ul', visible: true)).not_to have_content(user.username)
- expect(find('.tribute-container ul', visible: true)).to have_content(unassigned_user.username)
+ expect(find_tribute_autocomplete_menu).not_to have_text(user.username)
+ expect(find_tribute_autocomplete_menu).to have_text(unassigned_user.username)
end
it 'lists users who are currently not assigned to the issue when using /assign on the second line' do
visit project_issue_path(project, issue_assignee)
- note = find('#note-body')
- page.within '.timeline-content-form' do
- note.native.send_keys('/assign @user2')
- note.native.send_keys(:enter)
- note.native.send_keys('/assign ')
- # The `/assign` ajax response might replace the one by `@` below causing a failed test
- # so we need to wait for the `/assign` ajax request to finish first
- wait_for_requests
- note.native.send_keys('@')
- wait_for_requests
- end
+ note = find_field('Comment')
+ note.native.send_keys('/assign @user2')
+ note.native.send_keys(:enter)
+ note.native.send_keys('/assign ')
+ # The `/assign` ajax response might replace the one by `@` below causing a failed test
+ # so we need to wait for the `/assign` ajax request to finish first
+ wait_for_requests
+ note.native.send_keys('@')
+ wait_for_requests
- expect(find('.tribute-container ul', visible: true)).not_to have_content(user.username)
- expect(find('.tribute-container ul', visible: true)).to have_content(unassigned_user.username)
+ expect(find_tribute_autocomplete_menu).not_to have_text(user.username)
+ expect(find_tribute_autocomplete_menu).to have_text(unassigned_user.username)
end
end
@@ -806,72 +655,65 @@ RSpec.describe 'GFM autocomplete', :js do
label_xss_title = 'alert label &lt;img src=x onerror="alert(\'Hello xss\');" a'
create(:label, project: project, title: label_xss_title)
- note = find('#note-body')
-
- # It should show all the labels on "~".
- type(note, '~')
+ fill_in 'Comment', with: '~'
wait_for_requests
- expect(find('.tribute-container ul', visible: true).text).to have_content('alert label')
+ expect(find_tribute_autocomplete_menu).to have_text('alert label')
end
it 'allows colons when autocompleting scoped labels' do
create(:label, project: project, title: 'scoped:label')
- note = find('#note-body')
- type(note, '~scoped:')
+ fill_in 'Comment', with: '~scoped:'
wait_for_requests
- expect(find('.tribute-container ul', visible: true).text).to have_content('scoped:label')
+ expect(find_tribute_autocomplete_menu).to have_text('scoped:label')
end
it 'allows colons when autocompleting scoped labels with double colons' do
create(:label, project: project, title: 'scoped::label')
- note = find('#note-body')
- type(note, '~scoped::')
+ fill_in 'Comment', with: '~scoped::'
wait_for_requests
- expect(find('.tribute-container ul', visible: true).text).to have_content('scoped::label')
+ expect(find_tribute_autocomplete_menu).to have_text('scoped::label')
end
it 'autocompletes multi-word labels' do
create(:label, project: project, title: 'Accepting merge requests')
- note = find('#note-body')
- type(note, '~Acceptingmerge')
+ fill_in 'Comment', with: '~Acceptingmerge'
wait_for_requests
- expect(find('.tribute-container ul', visible: true).text).to have_content('Accepting merge requests')
+ expect(find_tribute_autocomplete_menu).to have_text('Accepting merge requests')
end
it 'only autocompletes the latest label' do
create(:label, project: project, title: 'documentation')
create(:label, project: project, title: 'feature')
- note = find('#note-body')
- type(note, '~documentation foo bar ~feat')
- note.native.send_keys(:right)
+ fill_in 'Comment', with: '~documentation foo bar ~feat'
+ # Invoke autocompletion
+ find_field('Comment').native.send_keys(:right)
wait_for_requests
- expect(find('.tribute-container ul', visible: true).text).to have_content('feature')
- expect(find('.tribute-container ul', visible: true).text).not_to have_content('documentation')
+ expect(find_tribute_autocomplete_menu).to have_text('feature')
+ expect(find_tribute_autocomplete_menu).not_to have_text('documentation')
end
it 'does not autocomplete labels if no tilde is typed' do
create(:label, project: project, title: 'documentation')
- note = find('#note-body')
- type(note, 'document')
+ fill_in 'Comment', with: 'document'
wait_for_requests
- expect(page).not_to have_selector('.tribute-container')
+ expect(page).not_to have_css('.tribute-container')
end
end
@@ -881,7 +723,7 @@ RSpec.describe 'GFM autocomplete', :js do
# This is meant to protect against this issue https://gitlab.com/gitlab-org/gitlab/-/issues/228729
it 'keeps autocomplete key listeners' do
visit project_issue_path(project, issue)
- note = find('#note-body')
+ note = find_field('Comment')
start_comment_with_emoji(note, '.tribute-container li')
@@ -897,17 +739,11 @@ RSpec.describe 'GFM autocomplete', :js do
shared_examples 'autocomplete suggestions' do
it 'suggests objects correctly' do
- page.within '.timeline-content-form' do
- find('#note-body').native.send_keys(object.class.reference_prefix)
- end
-
- page.within '.tribute-container' do
- expect(page).to have_content(object.title)
+ fill_in 'Comment', with: object.class.reference_prefix
- find('ul li').click
- end
+ find_tribute_autocomplete_menu.find('li').click
- expect(find('.new-note #note-body').value).to include(expected_body)
+ expect(find_field('Comment').value).to have_text(expected_body)
end
end
@@ -949,42 +785,6 @@ RSpec.describe 'GFM autocomplete', :js do
private
- def expect_to_wrap(should_wrap, item, note, value)
- expect(item).to have_content(value)
- expect(item).not_to have_content("\"#{value}\"")
-
- item.click
-
- if should_wrap
- expect(note.value).to include("\"#{value}\"")
- else
- expect(note.value).not_to include("\"#{value}\"")
- end
- end
-
- def expect_labels(shown: nil, not_shown: nil)
- page.within('.atwho-container') do
- if shown
- expect(page).to have_selector('.atwho-view li', count: shown.size)
- shown.each { |label| expect(page).to have_content(label.title) }
- end
-
- if not_shown
- expect(page).not_to have_selector('.atwho-view li') unless shown
- not_shown.each { |label| expect(page).not_to have_content(label.title) }
- end
- end
- end
-
- # `note` is a textarea where the given text should be typed.
- # We don't want to find it each time this function gets called.
- def type(note, text)
- page.within('.timeline-content-form') do
- note.set('')
- note.native.send_keys(text)
- end
- end
-
def start_comment_with_emoji(note, selector)
note.native.send_keys('Hello :10')
@@ -994,9 +794,7 @@ RSpec.describe 'GFM autocomplete', :js do
end
def start_and_cancel_discussion
- click_button('Reply...')
-
- fill_in('note_note', with: 'Whoops!')
+ fill_in('Reply to comment', with: 'Whoops!')
page.accept_alert 'Are you sure you want to cancel creating this comment?' do
click_button('Cancel')
@@ -1004,4 +802,20 @@ RSpec.describe 'GFM autocomplete', :js do
wait_for_requests
end
+
+ def find_autocomplete_menu
+ find('.atwho-view ul', visible: true)
+ end
+
+ def find_highlighted_autocomplete_item
+ find('.atwho-view li.cur', visible: true)
+ end
+
+ def find_tribute_autocomplete_menu
+ find('.tribute-container ul', visible: true)
+ end
+
+ def find_highlighted_tribute_autocomplete_menu
+ find('.tribute-container li.highlight', visible: true)
+ end
end
diff --git a/spec/features/issues/issue_state_spec.rb b/spec/features/issues/issue_state_spec.rb
index 409f498798b..d5a115433aa 100644
--- a/spec/features/issues/issue_state_spec.rb
+++ b/spec/features/issues/issue_state_spec.rb
@@ -42,15 +42,9 @@ RSpec.describe 'issue state', :js do
end
describe 'when open', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/297348' do
- let(:open_issue) { create(:issue, project: project) }
-
- it_behaves_like 'page with comment and close button', 'Close issue' do
- def setup
- visit project_issue_path(project, open_issue)
- end
- end
-
context 'when clicking the top `Close issue` button', :aggregate_failures do
+ let(:open_issue) { create(:issue, project: project) }
+
before do
visit project_issue_path(project, open_issue)
end
@@ -59,8 +53,9 @@ RSpec.describe 'issue state', :js do
end
context 'when clicking the bottom `Close issue` button', :aggregate_failures do
+ let(:open_issue) { create(:issue, project: project) }
+
before do
- stub_feature_flags(remove_comment_close_reopen: false)
visit project_issue_path(project, open_issue)
end
@@ -69,15 +64,9 @@ RSpec.describe 'issue state', :js do
end
describe 'when closed', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/297201' do
- let(:closed_issue) { create(:issue, project: project, state: 'closed') }
-
- it_behaves_like 'page with comment and close button', 'Reopen issue' do
- def setup
- visit project_issue_path(project, closed_issue)
- end
- end
-
context 'when clicking the top `Reopen issue` button', :aggregate_failures do
+ let(:closed_issue) { create(:issue, project: project, state: 'closed') }
+
before do
visit project_issue_path(project, closed_issue)
end
@@ -86,8 +75,9 @@ RSpec.describe 'issue state', :js do
end
context 'when clicking the bottom `Reopen issue` button', :aggregate_failures do
+ let(:closed_issue) { create(:issue, project: project, state: 'closed') }
+
before do
- stub_feature_flags(remove_comment_close_reopen: false)
visit project_issue_path(project, closed_issue)
end
diff --git a/spec/features/issues/service_desk_spec.rb b/spec/features/issues/service_desk_spec.rb
index 02804d84a21..75ea8c14f7f 100644
--- a/spec/features/issues/service_desk_spec.rb
+++ b/spec/features/issues/service_desk_spec.rb
@@ -49,8 +49,8 @@ RSpec.describe 'Service Desk Issue Tracker', :js do
aggregate_failures do
expect(page).to have_css('.empty-state')
expect(page).to have_text('Use Service Desk to connect with your users')
- expect(page).to have_link('Read more', href: help_page_path('user/project/service_desk'))
- expect(page).not_to have_link('Turn on Service Desk')
+ expect(page).to have_link('Learn more.', href: help_page_path('user/project/service_desk'))
+ expect(page).not_to have_link('Enable Service Desk')
expect(page).to have_content(project.service_desk_address)
end
end
@@ -68,8 +68,8 @@ RSpec.describe 'Service Desk Issue Tracker', :js do
aggregate_failures do
expect(page).to have_css('.empty-state')
expect(page).to have_text('Use Service Desk to connect with your users')
- expect(page).to have_link('Read more', href: help_page_path('user/project/service_desk'))
- expect(page).not_to have_link('Turn on Service Desk')
+ expect(page).to have_link('Learn more.', href: help_page_path('user/project/service_desk'))
+ expect(page).not_to have_link('Enable Service Desk')
expect(page).not_to have_content(project.service_desk_address)
end
end
@@ -91,8 +91,8 @@ RSpec.describe 'Service Desk Issue Tracker', :js do
it 'displays the small info box, documentation, a button to configure service desk, and the address' do
aggregate_failures do
expect(page).to have_css('.non-empty-state')
- expect(page).to have_link('Read more', href: help_page_path('user/project/service_desk'))
- expect(page).not_to have_link('Turn on Service Desk')
+ expect(page).to have_link('Learn more.', href: help_page_path('user/project/service_desk'))
+ expect(page).not_to have_link('Enable Service Desk')
expect(page).to have_content(project.service_desk_address)
end
end
@@ -156,8 +156,8 @@ RSpec.describe 'Service Desk Issue Tracker', :js do
aggregate_failures do
expect(page).to have_css('.empty-state')
expect(page).to have_text('Service Desk is not supported')
- expect(page).to have_text('In order to enable Service Desk for your instance, you must first set up incoming email.')
- expect(page).to have_link('More information', href: help_page_path('administration/incoming_email', anchor: 'set-it-up'))
+ expect(page).to have_text('To enable Service Desk on this instance, an instance administrator must first set up incoming email.')
+ expect(page).to have_link('Learn more.', href: help_page_path('administration/incoming_email', anchor: 'set-it-up'))
end
end
end
diff --git a/spec/features/issues/user_interacts_with_awards_spec.rb b/spec/features/issues/user_interacts_with_awards_spec.rb
index fec603e466a..1c7bc5f239f 100644
--- a/spec/features/issues/user_interacts_with_awards_spec.rb
+++ b/spec/features/issues/user_interacts_with_awards_spec.rb
@@ -135,11 +135,9 @@ RSpec.describe 'User interacts with awards' do
it 'allows adding a new emoji' do
page.within('.note-actions') do
- find('a.js-add-award').click
- end
- page.within('.emoji-menu-content') do
- find('gl-emoji[data-name="8ball"]').click
+ find('.note-emoji-button').click
end
+ find('gl-emoji[data-name="8ball"]').click
wait_for_requests
page.within('.note-awards') do
@@ -157,7 +155,7 @@ RSpec.describe 'User interacts with awards' do
end
page.within('.note-actions') do
- expect(page).not_to have_css('a.js-add-award')
+ expect(page).not_to have_css('.btn.js-add-award')
end
end
diff --git a/spec/features/labels_hierarchy_spec.rb b/spec/features/labels_hierarchy_spec.rb
index aeb42cc2edb..0a2f81986be 100644
--- a/spec/features/labels_hierarchy_spec.rb
+++ b/spec/features/labels_hierarchy_spec.rb
@@ -160,7 +160,7 @@ RSpec.describe 'Labels Hierarchy', :js do
find('a.label-item', text: parent_group_label.title).click
find('a.label-item', text: project_label_1.title).click
- find('.btn-success').click
+ find('.btn-confirm').click
expect(page.find('.issue-details h2.title')).to have_content('new created issue')
expect(page).to have_selector('span.gl-label-text', text: grandparent_group_label.title)
diff --git a/spec/features/markdown/markdown_spec.rb b/spec/features/markdown/markdown_spec.rb
index 8e28f89f49e..e84b300a748 100644
--- a/spec/features/markdown/markdown_spec.rb
+++ b/spec/features/markdown/markdown_spec.rb
@@ -290,7 +290,7 @@ RSpec.describe 'GitLab Markdown', :aggregate_failures do
path = 'images/example.jpg'
gitaly_wiki_file = Gitlab::GitalyClient::WikiFile.new(path: path)
- expect(@wiki).to receive(:find_file).with(path).and_return(Gitlab::Git::WikiFile.new(gitaly_wiki_file))
+ expect(@wiki).to receive(:find_file).with(path, load_content: false).and_return(Gitlab::Git::WikiFile.new(gitaly_wiki_file))
allow(@wiki).to receive(:wiki_base_path) { '/namespace1/gitlabhq/wikis' }
@html = markdown(@feat.raw_markdown, { pipeline: :wiki, wiki: @wiki, page_slug: @wiki_page.slug })
diff --git a/spec/features/markdown/math_spec.rb b/spec/features/markdown/math_spec.rb
index e5fb9131ce0..441cff7045f 100644
--- a/spec/features/markdown/math_spec.rb
+++ b/spec/features/markdown/math_spec.rb
@@ -39,4 +39,20 @@ RSpec.describe 'Math rendering', :js do
expect(page).to have_selector('.katex-html a', text: 'Gitlab')
end
end
+
+ it 'renders lazy load button' do
+ description = <<~MATH
+ ```math
+ \Huge \sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{\sqrt{}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}
+ ```
+ MATH
+
+ issue = create(:issue, project: project, description: description)
+
+ visit project_issue_path(project, issue)
+
+ page.within '.description > .md' do
+ expect(page).to have_selector('.js-lazy-render-math')
+ end
+ end
end
diff --git a/spec/features/merge_request/batch_comments_spec.rb b/spec/features/merge_request/batch_comments_spec.rb
index c8fc23bebf9..25f2707146d 100644
--- a/spec/features/merge_request/batch_comments_spec.rb
+++ b/spec/features/merge_request/batch_comments_spec.rb
@@ -223,7 +223,7 @@ end
def write_reply_to_discussion(button_text: 'Start a review', text: 'Line is wrong', resolve: false, unresolve: false)
page.within(first('.diff-files-holder .discussion-reply-holder')) do
- click_button('Reply...')
+ find_field('Reply…', match: :first).click
fill_in('note_note', with: text)
diff --git a/spec/features/merge_request/user_closes_reopens_merge_request_state_spec.rb b/spec/features/merge_request/user_closes_reopens_merge_request_state_spec.rb
index ab3ef7c1ac0..70951982c22 100644
--- a/spec/features/merge_request/user_closes_reopens_merge_request_state_spec.rb
+++ b/spec/features/merge_request/user_closes_reopens_merge_request_state_spec.rb
@@ -12,15 +12,9 @@ RSpec.describe 'User closes/reopens a merge request', :js, quarantine: 'https://
end
describe 'when open' do
- let(:open_merge_request) { create(:merge_request, source_project: project, target_project: project) }
-
- it_behaves_like 'page with comment and close button', 'Close merge request' do
- def setup
- visit merge_request_path(open_merge_request)
- end
- end
-
context 'when clicking the top `Close merge request` link', :aggregate_failures do
+ let(:open_merge_request) { create(:merge_request, source_project: project, target_project: project) }
+
before do
visit merge_request_path(open_merge_request)
end
@@ -40,8 +34,9 @@ RSpec.describe 'User closes/reopens a merge request', :js, quarantine: 'https://
end
context 'when clicking the bottom `Close merge request` button', :aggregate_failures do
+ let(:open_merge_request) { create(:merge_request, source_project: project, target_project: project) }
+
before do
- stub_feature_flags(remove_comment_close_reopen: false)
visit merge_request_path(open_merge_request)
end
@@ -61,22 +56,9 @@ RSpec.describe 'User closes/reopens a merge request', :js, quarantine: 'https://
end
describe 'when closed' do
- let(:closed_merge_request) { create(:merge_request, source_project: project, target_project: project, state: 'closed') }
-
- it_behaves_like 'page with comment and close button', 'Close merge request' do
- def setup
- visit merge_request_path(closed_merge_request)
-
- within '.detail-page-header' do
- click_button 'Toggle dropdown'
- click_link 'Reopen merge request'
- end
-
- wait_for_requests
- end
- end
-
context 'when clicking the top `Reopen merge request` link', :aggregate_failures do
+ let(:closed_merge_request) { create(:merge_request, source_project: project, target_project: project, state: 'closed') }
+
before do
visit merge_request_path(closed_merge_request)
end
@@ -96,8 +78,9 @@ RSpec.describe 'User closes/reopens a merge request', :js, quarantine: 'https://
end
context 'when clicking the bottom `Reopen merge request` button', :aggregate_failures do
+ let(:closed_merge_request) { create(:merge_request, source_project: project, target_project: project, state: 'closed') }
+
before do
- stub_feature_flags(remove_comment_close_reopen: false)
visit merge_request_path(closed_merge_request)
end
diff --git a/spec/features/merge_request/user_posts_diff_notes_spec.rb b/spec/features/merge_request/user_posts_diff_notes_spec.rb
index 794dfd7c8da..163ce10132e 100644
--- a/spec/features/merge_request/user_posts_diff_notes_spec.rb
+++ b/spec/features/merge_request/user_posts_diff_notes_spec.rb
@@ -192,7 +192,7 @@ RSpec.describe 'Merge request > User posts diff notes', :js do
it 'adds as discussion' do
should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]'), asset_form_reset: false)
expect(page).to have_css('.notes_holder .note.note-discussion', count: 1)
- expect(page).to have_button('Reply...')
+ expect(page).to have_field('Reply…')
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 e629bc0dc53..3099a893dc2 100644
--- a/spec/features/merge_request/user_posts_notes_spec.rb
+++ b/spec/features/merge_request/user_posts_notes_spec.rb
@@ -44,7 +44,10 @@ RSpec.describe 'Merge request > User posts notes', :js do
it 'has enable submit button, preview button and saves content to local storage' do
page.within('.js-main-target-form') do
- expect(page).not_to have_css('.js-comment-button[disabled]')
+ page.within('[data-testid="comment-button"]') do
+ expect(page).to have_css('.split-content-button')
+ expect(page).not_to have_css('.split-content-button[disabled]')
+ end
expect(page).to have_css('.js-md-preview-button', visible: true)
end
diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
index b86586d53e2..caa04059469 100644
--- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
+++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
@@ -149,7 +149,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
it 'allows user to comment' do
page.within '.diff-content' do
- click_button 'Reply...'
+ find_field('Reply…').click
find(".js-unresolve-checkbox").set false
find('.js-note-text').set 'testing'
@@ -179,7 +179,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
it 'allows user to comment & unresolve thread' do
page.within '.diff-content' do
- click_button 'Reply...'
+ find_field('Reply…').click
find('.js-note-text').set 'testing'
@@ -208,7 +208,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
it 'allows user to comment & resolve thread' do
page.within '.diff-content' do
- click_button 'Reply...'
+ find_field('Reply…').click
find('.js-note-text').set 'testing'
@@ -442,7 +442,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
it 'allows user to comment & resolve thread' do
page.within '.diff-content' do
- click_button 'Reply...'
+ find_field('Reply…').click
find('.js-note-text').set 'testing'
@@ -461,7 +461,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
page.within '.diff-content' do
click_button 'Resolve thread'
- click_button 'Reply...'
+ find_field('Reply…').click
find('.js-note-text').set 'testing'
diff --git a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb
index d15d5b3bc73..90cdc28d1bd 100644
--- a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb
+++ b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe 'Merge request > User sees avatars on diff notes', :js do
end
it 'does not render avatars after commenting on discussion tab' do
- click_button 'Reply...'
+ find_field('Reply…').click
page.within('.js-discussion-note-form') do
find('.note-textarea').native.send_keys('Test comment')
@@ -132,7 +132,7 @@ RSpec.describe 'Merge request > User sees avatars on diff notes', :js do
end
it 'adds avatar when commenting' do
- click_button 'Reply...'
+ find_field('Reply…', match: :first).click
page.within '.js-discussion-note-form' do
find('.js-note-text').native.send_keys('Test')
@@ -151,7 +151,7 @@ RSpec.describe 'Merge request > User sees avatars on diff notes', :js do
it 'adds multiple comments' do
3.times do
- click_button 'Reply...'
+ find_field('Reply…', match: :first).click
page.within '.js-discussion-note-form' do
find('.js-note-text').native.send_keys('Test')
diff --git a/spec/features/merge_request/user_sees_discussions_spec.rb b/spec/features/merge_request/user_sees_discussions_spec.rb
index 289c861739f..d79763ba5e0 100644
--- a/spec/features/merge_request/user_sees_discussions_spec.rb
+++ b/spec/features/merge_request/user_sees_discussions_spec.rb
@@ -60,7 +60,7 @@ RSpec.describe 'Merge request > User sees threads', :js do
it 'can be replied to' do
within(".discussion[data-discussion-id='#{discussion_id}']") do
- click_button 'Reply...'
+ find_field('Reply…').click
fill_in 'note[note]', with: 'Test!'
click_button 'Comment'
diff --git a/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb
index 708ce53b4fe..ad0e9b48903 100644
--- a/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb
+++ b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb
@@ -26,6 +26,7 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request',
end
before do
+ stub_feature_flags(new_pipelines_table: false)
stub_application_setting(auto_devops_enabled: false)
stub_ci_pipeline_yaml_file(YAML.dump(config))
project.add_maintainer(user)
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 0854a8b9fb7..05fa5459e06 100644
--- a/spec/features/merge_request/user_sees_merge_widget_spec.rb
+++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb
@@ -651,7 +651,6 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
within(".js-report-section-container") do
expect(page).to have_content('rspec found 1 failed out of 1 total test')
expect(page).to have_content('junit found no changed test results out of 1 total test')
- expect(page).not_to have_content('New')
expect(page).to have_content('Test#sum when a is 1 and b is 3 returns summary')
end
end
@@ -792,7 +791,6 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
within(".js-report-section-container") do
expect(page).to have_content('rspec found 1 error out of 1 total test')
expect(page).to have_content('junit found no changed test results out of 1 total test')
- expect(page).not_to have_content('New')
expect(page).to have_content('Test#sum when a is 4 and b is 4 returns summary')
end
end
diff --git a/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb b/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb
index 1ef6d2a1068..c0dc2ec3baf 100644
--- a/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb
+++ b/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb
@@ -9,166 +9,149 @@ RSpec.describe 'Merge request < User sees mini pipeline graph', :js do
let(:pipeline) { create(:ci_empty_pipeline, project: project, ref: 'master', status: 'running', sha: project.commit.id) }
let(:build) { create(:ci_build, pipeline: pipeline, stage: 'test') }
- shared_examples 'mini pipeline renders' do |ci_mini_pipeline_gl_dropdown_enabled|
- before do
- build.run
- build.trace.set('hello')
- sign_in(user)
- stub_feature_flags(ci_mini_pipeline_gl_dropdown: ci_mini_pipeline_gl_dropdown_enabled)
- visit_merge_request
- end
+ dropdown_selector = '[data-testid="mini-pipeline-graph-dropdown"]'
- let_it_be(:dropdown_toggle_selector) do
- if ci_mini_pipeline_gl_dropdown_enabled
- '[data-testid="mini-pipeline-graph-dropdown"] .dropdown-toggle'
- else
- '[data-testid="mini-pipeline-graph-dropdown-toggle"]'
- end
- end
+ before do
+ build.run
+ build.trace.set('hello')
+ sign_in(user)
+ visit_merge_request
+ end
- def visit_merge_request(format: :html, serializer: nil)
- visit project_merge_request_path(project, merge_request, format: format, serializer: serializer)
- end
+ def visit_merge_request(format: :html, serializer: nil)
+ visit project_merge_request_path(project, merge_request, format: format, serializer: serializer)
+ end
- it 'displays a mini pipeline graph' do
- expect(page).to have_selector('.mr-widget-pipeline-graph')
- end
+ it 'displays a mini pipeline graph' do
+ expect(page).to have_selector('.mr-widget-pipeline-graph')
+ end
- context 'as json' do
- let(:artifacts_file1) { fixture_file_upload(File.join('spec/fixtures/banana_sample.gif'), 'image/gif') }
- let(:artifacts_file2) { fixture_file_upload(File.join('spec/fixtures/dk.png'), 'image/png') }
+ context 'as json' do
+ let(:artifacts_file1) { fixture_file_upload(File.join('spec/fixtures/banana_sample.gif'), 'image/gif') }
+ let(:artifacts_file2) { fixture_file_upload(File.join('spec/fixtures/dk.png'), 'image/png') }
- before do
- job = create(:ci_build, :success, :trace_artifact, pipeline: pipeline)
- create(:ci_job_artifact, :archive, file: artifacts_file1, job: job)
- create(:ci_build, :manual, pipeline: pipeline, when: 'manual')
- end
+ before do
+ job = create(:ci_build, :success, :trace_artifact, pipeline: pipeline)
+ create(:ci_job_artifact, :archive, file: artifacts_file1, job: job)
+ create(:ci_build, :manual, pipeline: pipeline, when: 'manual')
+ end
- # TODO: https://gitlab.com/gitlab-org/gitlab-foss/issues/48034
- xit 'avoids repeated database queries' do
- before = ActiveRecord::QueryRecorder.new { visit_merge_request(format: :json, serializer: 'widget') }
+ # TODO: https://gitlab.com/gitlab-org/gitlab-foss/issues/48034
+ xit 'avoids repeated database queries' do
+ before = ActiveRecord::QueryRecorder.new { visit_merge_request(format: :json, serializer: 'widget') }
- job = create(:ci_build, :success, :trace_artifact, pipeline: pipeline)
- create(:ci_job_artifact, :archive, file: artifacts_file2, job: job)
- create(:ci_build, :manual, pipeline: pipeline, when: 'manual')
+ job = create(:ci_build, :success, :trace_artifact, pipeline: pipeline)
+ create(:ci_job_artifact, :archive, file: artifacts_file2, job: job)
+ create(:ci_build, :manual, pipeline: pipeline, when: 'manual')
- after = ActiveRecord::QueryRecorder.new { visit_merge_request(format: :json, serializer: 'widget') }
+ after = ActiveRecord::QueryRecorder.new { visit_merge_request(format: :json, serializer: 'widget') }
- expect(before.count).to eq(after.count)
- expect(before.cached_count).to eq(after.cached_count)
- end
+ expect(before.count).to eq(after.count)
+ expect(before.cached_count).to eq(after.cached_count)
end
+ end
- describe 'build list toggle' do
- let(:toggle) do
- find(dropdown_toggle_selector)
- first(dropdown_toggle_selector)
- end
+ describe 'build list toggle' do
+ let(:toggle) do
+ find(dropdown_selector)
+ first(dropdown_selector)
+ end
- # Status icon button styles should update as described in
- # https://gitlab.com/gitlab-org/gitlab-foss/issues/42769
- it 'has unique styles for default, :hover, :active, and :focus states' do
- default_background_color, default_foreground_color, default_box_shadow = get_toggle_colors(dropdown_toggle_selector)
+ # Status icon button styles should update as described in
+ # https://gitlab.com/gitlab-org/gitlab-foss/issues/42769
+ it 'has unique styles for default, :hover, :active, and :focus states' do
+ default_background_color, default_foreground_color, default_box_shadow = get_toggle_colors(dropdown_selector)
- toggle.hover
- hover_background_color, hover_foreground_color, hover_box_shadow = get_toggle_colors(dropdown_toggle_selector)
+ toggle.hover
+ hover_background_color, hover_foreground_color, hover_box_shadow = get_toggle_colors(dropdown_selector)
- page.driver.browser.action.click_and_hold(toggle.native).perform
- active_background_color, active_foreground_color, active_box_shadow = get_toggle_colors(dropdown_toggle_selector)
- page.driver.browser.action.release(toggle.native).perform
+ page.driver.browser.action.click_and_hold(toggle.native).perform
+ active_background_color, active_foreground_color, active_box_shadow = get_toggle_colors(dropdown_selector)
+ page.driver.browser.action.release(toggle.native).perform
- page.driver.browser.action.click(toggle.native).move_by(100, 100).perform
- focus_background_color, focus_foreground_color, focus_box_shadow = get_toggle_colors(dropdown_toggle_selector)
+ page.driver.browser.action.click(toggle.native).move_by(100, 100).perform
+ focus_background_color, focus_foreground_color, focus_box_shadow = get_toggle_colors(dropdown_selector)
- expect(default_background_color).not_to eq(hover_background_color)
- expect(hover_background_color).not_to eq(active_background_color)
- expect(default_background_color).not_to eq(active_background_color)
+ expect(default_background_color).not_to eq(hover_background_color)
+ expect(hover_background_color).not_to eq(active_background_color)
+ expect(default_background_color).not_to eq(active_background_color)
- expect(default_foreground_color).not_to eq(hover_foreground_color)
- expect(hover_foreground_color).not_to eq(active_foreground_color)
- expect(default_foreground_color).not_to eq(active_foreground_color)
+ expect(default_foreground_color).not_to eq(hover_foreground_color)
+ expect(hover_foreground_color).not_to eq(active_foreground_color)
+ expect(default_foreground_color).not_to eq(active_foreground_color)
- expect(focus_background_color).to eq(hover_background_color)
- expect(focus_foreground_color).to eq(hover_foreground_color)
+ expect(focus_background_color).to eq(hover_background_color)
+ expect(focus_foreground_color).to eq(hover_foreground_color)
- expect(default_box_shadow).to eq('none')
- expect(hover_box_shadow).to eq('none')
- expect(active_box_shadow).not_to eq('none')
- expect(focus_box_shadow).not_to eq('none')
- end
+ expect(default_box_shadow).to eq('none')
+ expect(hover_box_shadow).to eq('none')
+ expect(active_box_shadow).not_to eq('none')
+ expect(focus_box_shadow).not_to eq('none')
+ end
- it 'shows tooltip when hovered' do
- toggle.hover
+ it 'shows tooltip when hovered' do
+ toggle.hover
- expect(page).to have_selector('.tooltip')
- end
+ expect(page).to have_selector('.tooltip')
end
+ end
- describe 'builds list menu' do
- let(:toggle) do
- find(dropdown_toggle_selector)
- first(dropdown_toggle_selector)
- end
+ describe 'builds list menu' do
+ let(:toggle) do
+ find(dropdown_selector)
+ first(dropdown_selector)
+ end
- before do
- toggle.click
- wait_for_requests
- end
+ before do
+ toggle.click
+ wait_for_requests
+ end
- it 'pens when toggle is clicked' do
- expect(toggle.find(:xpath, '..')).to have_selector('.mini-pipeline-graph-dropdown-menu')
- end
+ it 'pens when toggle is clicked' do
+ expect(toggle.find(:xpath, '..')).to have_selector('.mini-pipeline-graph-dropdown-menu')
+ end
- it 'closes when toggle is clicked again' do
- toggle.click
+ it 'closes when toggle is clicked again' do
+ toggle.click
- expect(toggle.find(:xpath, '..')).not_to have_selector('.mini-pipeline-graph-dropdown-menu')
- end
+ expect(toggle.find(:xpath, '..')).not_to have_selector('.mini-pipeline-graph-dropdown-menu')
+ end
- it 'closes when clicking somewhere else' do
- find('body').click
+ it 'closes when clicking somewhere else' do
+ find('body').click
- expect(toggle.find(:xpath, '..')).not_to have_selector('.mini-pipeline-graph-dropdown-menu')
- end
+ expect(toggle.find(:xpath, '..')).not_to have_selector('.mini-pipeline-graph-dropdown-menu')
+ end
- describe 'build list build item' do
- let(:build_item) do
- find('.mini-pipeline-graph-dropdown-item')
- first('.mini-pipeline-graph-dropdown-item')
- end
+ describe 'build list build item' do
+ let(:build_item) do
+ find('.mini-pipeline-graph-dropdown-item')
+ first('.mini-pipeline-graph-dropdown-item')
+ end
- it 'visits the build page when clicked' do
- build_item.click
- find('.build-page')
+ it 'visits the build page when clicked' do
+ build_item.click
+ find('.build-page')
- expect(current_path).to eql(project_job_path(project, build))
- end
+ expect(current_path).to eql(project_job_path(project, build))
+ end
- it 'shows tooltip when hovered' do
- build_item.hover
+ it 'shows tooltip when hovered' do
+ build_item.hover
- expect(page).to have_selector('.tooltip')
- end
+ expect(page).to have_selector('.tooltip')
end
end
end
- context 'with ci_mini_pipeline_gl_dropdown disabled' do
- it_behaves_like "mini pipeline renders", false
- end
-
- context 'with ci_mini_pipeline_gl_dropdown enabled' do
- it_behaves_like "mini pipeline renders", true
- end
-
private
def get_toggle_colors(selector)
find(selector)
[
- evaluate_script("$('#{selector}:visible').css('background-color');"),
- evaluate_script("$('#{selector}:visible svg').css('fill');"),
- evaluate_script("$('#{selector}:visible').css('box-shadow');")
+ evaluate_script("$('#{selector} button:visible').css('background-color');"),
+ evaluate_script("$('#{selector} button:visible svg').css('fill');"),
+ evaluate_script("$('#{selector} button:visible').css('box-shadow');")
]
end
end
diff --git a/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb b/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb
index 20c45a1d652..ea46ae06329 100644
--- a/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb
+++ b/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe 'Merge request > User sees notes from forked project', :js do
expect(page).to have_content('A commit comment')
page.within('.discussion-notes') do
- find('.btn-text-field').click
+ find_field('Reply…').click
scroll_to(page.find('#note_note', visible: false))
find('#note_note').send_keys('A reply comment')
find('.js-comment-button').click
diff --git a/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb
index bf445de44ba..9850ca3f173 100644
--- a/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb
+++ b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb
@@ -121,14 +121,14 @@ RSpec.describe 'Merge request > User selects branches for new MR', :js do
click_link 'Changes'
- expect(page).to have_css('a.btn.active', text: 'Inline')
- expect(page).not_to have_css('a.btn.active', text: 'Side-by-side')
+ expect(page).to have_css('a.btn.selected', text: 'Inline')
+ expect(page).not_to have_css('a.btn.selected', text: 'Side-by-side')
click_link 'Side-by-side'
within '.merge-request' do
- expect(page).not_to have_css('a.btn.active', text: 'Inline')
- expect(page).to have_css('a.btn.active', text: 'Side-by-side')
+ expect(page).not_to have_css('a.btn.selected', text: 'Inline')
+ expect(page).to have_css('a.btn.selected', text: 'Side-by-side')
end
end
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 bbeb91bbd19..dbc88d0cce2 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
@@ -83,7 +83,7 @@ RSpec.describe 'User comments on a diff', :js do
wait_for_requests
- click_button 'Reply...'
+ find_field('Reply…', match: :first).click
find('.js-suggestion-btn').click
diff --git a/spec/features/merge_request/user_toggles_whitespace_changes_spec.rb b/spec/features/merge_request/user_toggles_whitespace_changes_spec.rb
index 05f4c16ef60..b72ac071ecb 100644
--- a/spec/features/merge_request/user_toggles_whitespace_changes_spec.rb
+++ b/spec/features/merge_request/user_toggles_whitespace_changes_spec.rb
@@ -21,13 +21,13 @@ RSpec.describe 'Merge request > User toggles whitespace changes', :js do
describe 'clicking "Hide whitespace changes" button' do
it 'toggles the "Hide whitespace changes" button' do
- find('#show-whitespace').click
+ find('[data-testid="show-whitespace"]').click
visit diffs_project_merge_request_path(project, merge_request)
find('.js-show-diff-settings').click
- expect(find('#show-whitespace')).not_to be_checked
+ expect(find('[data-testid="show-whitespace"]')).not_to be_checked
end
end
end
diff --git a/spec/features/merge_requests/user_exports_as_csv_spec.rb b/spec/features/merge_requests/user_exports_as_csv_spec.rb
index a86ff9d7335..725b8366d04 100644
--- a/spec/features/merge_requests/user_exports_as_csv_spec.rb
+++ b/spec/features/merge_requests/user_exports_as_csv_spec.rb
@@ -14,11 +14,13 @@ RSpec.describe 'Merge Requests > Exports as CSV', :js do
subject { page.find('.nav-controls') }
- it { is_expected.to have_button('Export as CSV') }
+ it { is_expected.to have_selector '[data-testid="export-csv-button"]' }
context 'button is clicked' do
before do
- click_button('Export as CSV')
+ page.within('.nav-controls') do
+ find('[data-testid="export-csv-button"]').click
+ end
end
it 'shows a success message' do
diff --git a/spec/features/participants_autocomplete_spec.rb b/spec/features/participants_autocomplete_spec.rb
index d6f23b21d65..b22778012a8 100644
--- a/spec/features/participants_autocomplete_spec.rb
+++ b/spec/features/participants_autocomplete_spec.rb
@@ -85,6 +85,7 @@ RSpec.describe 'Member autocomplete', :js do
let(:note) { create(:note_on_commit, project: project, commit_id: project.commit.id) }
before do
+ allow(User).to receive(:find_by_any_email).and_call_original
allow(User).to receive(:find_by_any_email)
.with(noteable.author_email.downcase, confirmed: true).and_return(author)
diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb
index 88bfc71cfbe..9e56ef087ae 100644
--- a/spec/features/profiles/personal_access_tokens_spec.rb
+++ b/spec/features/profiles/personal_access_tokens_spec.rb
@@ -138,4 +138,10 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
end
end
end
+
+ it 'pushes `personal_access_tokens_scoped_to_projects` feature flag to the frontend' do
+ visit profile_personal_access_tokens_path
+
+ expect(page).to have_pushed_frontend_feature_flags(personalAccessTokensScopedToProjects: true)
+ end
end
diff --git a/spec/features/profiles/user_visits_notifications_tab_spec.rb b/spec/features/profiles/user_visits_notifications_tab_spec.rb
index 289fbff0404..939e791c75d 100644
--- a/spec/features/profiles/user_visits_notifications_tab_spec.rb
+++ b/spec/features/profiles/user_visits_notifications_tab_spec.rb
@@ -7,7 +7,6 @@ RSpec.describe 'User visits the notifications tab', :js do
let(:user) { create(:user) }
before do
- stub_feature_flags(vue_notification_dropdown: false)
project.add_maintainer(user)
sign_in(user)
visit(profile_notifications_path)
@@ -16,17 +15,17 @@ RSpec.describe 'User visits the notifications tab', :js do
it 'changes the project notifications setting' do
expect(page).to have_content('Notifications')
- first('#notifications-button').click
- click_link('On mention')
+ first('[data-testid="notification-dropdown"]').click
+ click_button('On mention')
- expect(page).to have_selector('#notifications-button', text: 'On mention')
+ expect(page).to have_selector('[data-testid="notification-dropdown"]', text: 'On mention')
end
context 'when project emails are disabled' do
let(:project) { create(:project, emails_disabled: true) }
it 'notification button is disabled' do
- expect(page).to have_selector('.notifications-btn.disabled', visible: true)
+ expect(page).to have_selector('[data-testid="notification-dropdown"] .disabled')
end
end
end
diff --git a/spec/features/project_group_variables_spec.rb b/spec/features/project_group_variables_spec.rb
index d8eba20ac18..fc482261fb1 100644
--- a/spec/features/project_group_variables_spec.rb
+++ b/spec/features/project_group_variables_spec.rb
@@ -57,7 +57,7 @@ RSpec.describe 'Project group variables', :js do
wait_for_requests
- page.within('.ci-variable-table') do
+ page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:nth-child(1) [data-label="Key"]').text).to eq(key1)
end
end
diff --git a/spec/features/project_variables_spec.rb b/spec/features/project_variables_spec.rb
index a7f94f38d85..327d8133411 100644
--- a/spec/features/project_variables_spec.rb
+++ b/spec/features/project_variables_spec.rb
@@ -24,7 +24,6 @@ RSpec.describe 'Project variables', :js do
find('[data-qa-selector="ci_variable_key_field"] input').set('akey')
find('#ci-variable-value').set('akey_value')
find('[data-testid="environment-scope"]').click
- find_button('clear').click
find('[data-testid="ci-environment-search"]').set('review/*')
find('[data-testid="create-wildcard-button"]').click
@@ -33,7 +32,7 @@ RSpec.describe 'Project variables', :js do
wait_for_requests
- page.within('.ci-variable-table') do
+ page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:first-child [data-label="Environments"]').text).to eq('review/*')
end
end
diff --git a/spec/features/projects/active_tabs_spec.rb b/spec/features/projects/active_tabs_spec.rb
index 8001ce0f454..86fe59f003f 100644
--- a/spec/features/projects/active_tabs_spec.rb
+++ b/spec/features/projects/active_tabs_spec.rb
@@ -132,13 +132,13 @@ RSpec.describe 'Project active tab' do
it_behaves_like 'page has active sub tab', _('Value Stream')
end
- context 'on project Analytics/"CI / CD"' do
+ context 'on project Analytics/"CI/CD"' do
before do
- click_tab(_('CI / CD'))
+ click_tab(_('CI/CD'))
end
it_behaves_like 'page has active tab', _('Analytics')
- it_behaves_like 'page has active sub tab', _('CI / CD')
+ it_behaves_like 'page has active sub tab', _('CI/CD')
end
end
end
diff --git a/spec/features/projects/ci/lint_spec.rb b/spec/features/projects/ci/lint_spec.rb
index ccffe25f45e..353c8558185 100644
--- a/spec/features/projects/ci/lint_spec.rb
+++ b/spec/features/projects/ci/lint_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'CI Lint', :js do
+RSpec.describe 'CI Lint', :js, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/297782' do
include Spec::Support::Helpers::Features::EditorLiteSpecHelpers
let(:project) { create(:project, :repository) }
diff --git a/spec/features/projects/commit/mini_pipeline_graph_spec.rb b/spec/features/projects/commit/mini_pipeline_graph_spec.rb
index cf9b86f16bb..7d206f76031 100644
--- a/spec/features/projects/commit/mini_pipeline_graph_spec.rb
+++ b/spec/features/projects/commit/mini_pipeline_graph_spec.rb
@@ -7,37 +7,55 @@ RSpec.describe 'Mini Pipeline Graph in Commit View', :js do
context 'when commit has pipelines' do
let(:pipeline) do
- create(:ci_empty_pipeline,
+ create(:ci_pipeline,
+ status: :running,
project: project,
ref: project.default_branch,
sha: project.commit.sha)
end
- let(:build) { create(:ci_build, pipeline: pipeline) }
+ let(:build) { create(:ci_build, pipeline: pipeline, status: :running) }
- it 'display icon with status' do
- build.run
- visit project_commit_path(project, project.commit.id)
+ shared_examples 'shows ci icon and mini pipeline' do
+ before do
+ build.run
+ visit project_commit_path(project, project.commit.id)
+ end
- expect(page).to have_selector('.ci-status-icon-running')
- end
+ it 'display icon with status' do
+ expect(page).to have_selector('.ci-status-icon-running')
+ end
- it 'displays a mini pipeline graph' do
- build.run
- visit project_commit_path(project, project.commit.id)
+ it 'displays a mini pipeline graph' do
+ expect(page).to have_selector('.mr-widget-pipeline-graph')
- expect(page).to have_selector('.mr-widget-pipeline-graph')
+ first('.mini-pipeline-graph-dropdown-toggle').click
- first('.mini-pipeline-graph-dropdown-toggle').click
+ wait_for_requests
- wait_for_requests
+ page.within '.js-builds-dropdown-list' do
+ expect(page).to have_selector('.ci-status-icon-running')
+ expect(page).to have_content(build.stage)
+ end
- page.within '.js-builds-dropdown-list' do
- expect(page).to have_selector('.ci-status-icon-running')
- expect(page).to have_content(build.stage)
+ build.drop
+ end
+ end
+
+ context 'when ci_commit_pipeline_mini_graph_vue is disabled' do
+ before do
+ stub_feature_flags(ci_commit_pipeline_mini_graph_vue: false)
+ end
+
+ it_behaves_like 'shows ci icon and mini pipeline'
+ end
+
+ context 'when ci_commit_pipeline_mini_graph_vue is enabled' do
+ before do
+ stub_feature_flags(ci_commit_pipeline_mini_graph_vue: true)
end
- build.drop
+ it_behaves_like 'shows ci icon and mini pipeline'
end
end
diff --git a/spec/features/projects/container_registry_spec.rb b/spec/features/projects/container_registry_spec.rb
index d0ad6668c07..40d0260eafd 100644
--- a/spec/features/projects/container_registry_spec.rb
+++ b/spec/features/projects/container_registry_spec.rb
@@ -82,7 +82,13 @@ RSpec.describe 'Container Registry', :js do
end
it 'shows the image title' do
- expect(page).to have_content 'my/image tags'
+ expect(page).to have_content 'my/image'
+ end
+
+ it 'shows the image tags' do
+ expect(page).to have_content 'Image tags'
+ first_tag = first('[data-testid="name"]')
+ expect(first_tag).to have_content '1'
end
it 'user removes a specific tag from container repository' do
diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb
index 27167f95104..de7ff1c473d 100644
--- a/spec/features/projects/environments/environments_spec.rb
+++ b/spec/features/projects/environments/environments_spec.rb
@@ -429,37 +429,67 @@ RSpec.describe 'Environments page', :js do
end
describe 'environments folders' do
- before do
- create(:environment, :will_auto_stop,
- project: project,
- name: 'staging/review-1',
- state: :available)
- create(:environment, :will_auto_stop,
- project: project,
- name: 'staging/review-2',
- state: :available)
- end
+ describe 'available environments' do
+ before do
+ create(:environment, :will_auto_stop,
+ project: project,
+ name: 'staging/review-1',
+ state: :available)
+ create(:environment, :will_auto_stop,
+ project: project,
+ name: 'staging/review-2',
+ state: :available)
+ end
- it 'users unfurls an environment folder' do
- visit_environments(project)
+ it 'users unfurls an environment folder' do
+ visit_environments(project)
- expect(page).not_to have_content 'review-1'
- expect(page).not_to have_content 'review-2'
- expect(page).to have_content 'staging 2'
+ expect(page).not_to have_content 'review-1'
+ expect(page).not_to have_content 'review-2'
+ expect(page).to have_content 'staging 2'
- within('.folder-row') do
- find('.folder-name', text: 'staging').click
- end
+ within('.folder-row') do
+ find('.folder-name', text: 'staging').click
+ end
- expect(page).to have_content 'review-1'
- expect(page).to have_content 'review-2'
- within('.ci-table') do
- within('[data-qa-selector="environment_item"]', text: 'review-1') do
- expect(find('.js-auto-stop').text).not_to be_empty
+ expect(page).to have_content 'review-1'
+ expect(page).to have_content 'review-2'
+ within('.ci-table') do
+ within('[data-qa-selector="environment_item"]', text: 'review-1') do
+ expect(find('.js-auto-stop').text).not_to be_empty
+ end
+ within('[data-qa-selector="environment_item"]', text: 'review-2') do
+ expect(find('.js-auto-stop').text).not_to be_empty
+ end
end
- within('[data-qa-selector="environment_item"]', text: 'review-2') do
- expect(find('.js-auto-stop').text).not_to be_empty
+ end
+ end
+
+ describe 'stopped environments' do
+ before do
+ create(:environment, :will_auto_stop,
+ project: project,
+ name: 'staging/review-1',
+ state: :stopped)
+ create(:environment, :will_auto_stop,
+ project: project,
+ name: 'staging/review-2',
+ state: :stopped)
+ end
+
+ it 'users unfurls an environment folder' do
+ visit_environments(project, scope: 'stopped')
+
+ expect(page).not_to have_content 'review-1'
+ expect(page).not_to have_content 'review-2'
+ expect(page).to have_content 'staging 2'
+
+ within('.folder-row') do
+ find('.folder-name', text: 'staging').click
end
+
+ expect(page).to have_content 'review-1'
+ expect(page).to have_content 'review-2'
end
end
end
diff --git a/spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb b/spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb
index f5941d0ff15..50fc7bb0753 100644
--- a/spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb
+++ b/spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb
@@ -104,7 +104,7 @@ RSpec.describe 'User sees feature flag list', :js do
it 'shows empty page' do
expect(page).to have_text 'Get started with feature flags'
- expect(page).to have_selector('.btn-success', text: 'New feature flag')
+ expect(page).to have_selector('.btn-confirm', text: 'New feature flag')
expect(page).to have_selector('[data-qa-selector="configure_feature_flags_button"]', text: 'Configure')
end
end
diff --git a/spec/features/projects/files/gitlab_ci_syntax_yml_dropdown_spec.rb b/spec/features/projects/files/gitlab_ci_syntax_yml_dropdown_spec.rb
index ca6f03472dd..cd796d45aba 100644
--- a/spec/features/projects/files/gitlab_ci_syntax_yml_dropdown_spec.rb
+++ b/spec/features/projects/files/gitlab_ci_syntax_yml_dropdown_spec.rb
@@ -5,11 +5,13 @@ require 'spec_helper'
RSpec.describe 'Projects > Files > User wants to add a .gitlab-ci.yml file' do
include Spec::Support::Helpers::Features::EditorLiteSpecHelpers
+ let_it_be(:namespace) { create(:namespace) }
+ let(:project) { create(:project, :repository, namespace: namespace) }
+
before do
- project = create(:project, :repository)
sign_in project.owner
- stub_experiment(ci_syntax_templates: experiment_active)
- stub_experiment_for_subject(ci_syntax_templates: in_experiment_group)
+ stub_experiment(ci_syntax_templates_b: experiment_active)
+ stub_experiment_for_subject(ci_syntax_templates_b: in_experiment_group)
visit project_new_blob_path(project, 'master', file_name: '.gitlab-ci.yml')
end
@@ -23,35 +25,45 @@ RSpec.describe 'Projects > Files > User wants to add a .gitlab-ci.yml file' do
end
end
- context 'when experiment is active and the user is in the control group' do
+ context 'when experiment is active' do
let(:experiment_active) { true }
- let(:in_experiment_group) { false }
- it 'does not show the "Learn CI/CD syntax" template dropdown' do
- expect(page).not_to have_css('.gitlab-ci-syntax-yml-selector')
+ context 'when the user is in the control group' do
+ let(:in_experiment_group) { false }
+
+ it 'does not show the "Learn CI/CD syntax" template dropdown' do
+ expect(page).not_to have_css('.gitlab-ci-syntax-yml-selector')
+ end
end
- end
- context 'when experiment is active and the user is in the experimental group' do
- let(:experiment_active) { true }
- let(:in_experiment_group) { true }
+ context 'when the user is in the experimental group' do
+ let(:in_experiment_group) { true }
+
+ it 'allows the user to pick a "Learn CI/CD syntax" template from the dropdown', :js do
+ expect(page).to have_css('.gitlab-ci-syntax-yml-selector')
- it 'allows the user to pick a "Learn CI/CD syntax" template from the dropdown', :js do
- expect(page).to have_css('.gitlab-ci-syntax-yml-selector')
+ find('.js-gitlab-ci-syntax-yml-selector').click
- find('.js-gitlab-ci-syntax-yml-selector').click
+ wait_for_requests
- wait_for_requests
+ within '.gitlab-ci-syntax-yml-selector' do
+ find('.dropdown-input-field').set('Artifacts example')
+ find('.dropdown-content .is-focused', text: 'Artifacts example').click
+ end
- within '.gitlab-ci-syntax-yml-selector' do
- find('.dropdown-input-field').set('Artifacts example')
- find('.dropdown-content .is-focused', text: 'Artifacts example').click
+ wait_for_requests
+
+ expect(page).to have_css('.gitlab-ci-syntax-yml-selector .dropdown-toggle-text', text: 'Learn CI/CD syntax')
+ expect(editor_get_value).to have_content('You can use artifacts to pass data to jobs in later stages.')
end
- wait_for_requests
+ context 'when the group is created longer than 90 days ago' do
+ let(:namespace) { create(:namespace, created_at: 91.days.ago) }
- expect(page).to have_css('.gitlab-ci-syntax-yml-selector .dropdown-toggle-text', text: 'Learn CI/CD syntax')
- expect(editor_get_value).to have_content('You can use artifacts to pass data to jobs in later stages.')
+ it 'does not show the "Learn CI/CD syntax" template dropdown' do
+ expect(page).not_to have_css('.gitlab-ci-syntax-yml-selector')
+ end
+ end
end
end
end
diff --git a/spec/features/projects/fork_spec.rb b/spec/features/projects/fork_spec.rb
index 8d0500f5e13..7abbd207b24 100644
--- a/spec/features/projects/fork_spec.rb
+++ b/spec/features/projects/fork_spec.rb
@@ -12,45 +12,27 @@ RSpec.describe 'Project fork' do
sign_in(user)
end
- it 'allows user to fork project from the project page' do
- visit project_path(project)
-
- expect(page).not_to have_css('a.disabled', text: 'Fork')
- end
-
- context 'user has exceeded personal project limit' do
- before do
- user.update!(projects_limit: 0)
- end
-
- it 'disables fork button on project page' do
+ shared_examples 'fork button on project page' do
+ it 'allows user to fork project from the project page' do
visit project_path(project)
- expect(page).to have_css('a.disabled', text: 'Fork')
+ expect(page).not_to have_css('a.disabled', text: 'Fork')
end
- context 'with a group to fork to' do
- let!(:group) { create(:group).tap { |group| group.add_owner(user) } }
-
- it 'enables fork button on project page' do
- visit project_path(project)
-
- expect(page).not_to have_css('a.disabled', text: 'Fork')
+ context 'user has exceeded personal project limit' do
+ before do
+ user.update!(projects_limit: 0)
end
- it 'allows user to fork only to the group on fork page', :js do
- visit new_project_fork_path(project)
-
- to_personal_namespace = find('[data-qa-selector=fork_namespace_button].disabled')
- to_group = find(".fork-groups button[data-qa-name=#{group.name}]")
+ it 'disables fork button on project page' do
+ visit project_path(project)
- expect(to_personal_namespace).not_to be_nil
- expect(to_group).not_to be_disabled
+ expect(page).to have_css('a.disabled', text: 'Fork')
end
end
end
- context 'forking enabled / disabled in project settings' do
+ shared_examples 'create fork page' do |fork_page_text|
before do
project.project_feature.update_attribute(
:forking_access_level, forking_access_level)
@@ -70,7 +52,7 @@ RSpec.describe 'Project fork' do
visit new_project_fork_path(project)
expect(page.status_code).to eq(200)
- expect(page).to have_text(' Select a namespace to fork the project ')
+ expect(page).to have_text(fork_page_text)
end
end
@@ -127,92 +109,88 @@ RSpec.describe 'Project fork' do
visit new_project_fork_path(project)
expect(page.status_code).to eq(200)
- expect(page).to have_text(' Select a namespace to fork the project ')
+ expect(page).to have_text(fork_page_text)
end
end
end
end
- it 'forks the project', :sidekiq_might_not_need_inline do
- visit project_path(project)
-
- click_link 'Fork'
+ it_behaves_like 'fork button on project page'
+ it_behaves_like 'create fork page', 'Fork project'
- page.within '.fork-thumbnail-container' do
- click_link 'Select'
+ context 'with fork_project_form feature flag disabled' do
+ before do
+ stub_feature_flags(fork_project_form: false)
+ sign_in(user)
end
- expect(page).to have_content 'Forked from'
+ it_behaves_like 'fork button on project page'
- visit project_path(project)
+ context 'user has exceeded personal project limit' do
+ before do
+ user.update!(projects_limit: 0)
+ end
- expect(page).to have_content(/new merge request/i)
+ context 'with a group to fork to' do
+ let!(:group) { create(:group).tap { |group| group.add_owner(user) } }
- page.within '.nav-sidebar' do
- first(:link, 'Merge Requests').click
- end
+ it 'allows user to fork only to the group on fork page', :js do
+ visit new_project_fork_path(project)
- expect(page).to have_content(/new merge request/i)
+ to_personal_namespace = find('[data-qa-selector=fork_namespace_button].disabled')
+ to_group = find(".fork-groups button[data-qa-name=#{group.name}]")
- page.within '#content-body' do
- click_link('New merge request')
+ expect(to_personal_namespace).not_to be_nil
+ expect(to_group).not_to be_disabled
+ end
+ end
end
- expect(current_path).to have_content(/#{user.namespace.path}/i)
- end
+ it_behaves_like 'create fork page', ' Select a namespace to fork the project '
- it 'shows avatars when Gravatar is disabled' do
- stub_application_setting(gravatar_enabled: false)
+ it 'forks the project', :sidekiq_might_not_need_inline do
+ visit project_path(project)
- visit project_path(project)
+ click_link 'Fork'
- click_link 'Fork'
+ page.within '.fork-thumbnail-container' do
+ click_link 'Select'
+ end
- page.within('.fork-thumbnail-container') do
- expect(page).to have_css('div.identicon')
- end
- end
+ expect(page).to have_content 'Forked from'
- it 'shows the forked project on the list' do
- visit project_path(project)
+ visit project_path(project)
- click_link 'Fork'
+ expect(page).to have_content(/new merge request/i)
- page.within '.fork-thumbnail-container' do
- click_link 'Select'
- end
+ page.within '.nav-sidebar' do
+ first(:link, 'Merge Requests').click
+ end
- visit project_forks_path(project)
+ expect(page).to have_content(/new merge request/i)
- forked_project = user.fork_of(project.reload)
+ page.within '#content-body' do
+ click_link('New merge request')
+ end
- page.within('.js-projects-list-holder') do
- expect(page).to have_content("#{forked_project.namespace.human_name} / #{forked_project.name}")
+ expect(current_path).to have_content(/#{user.namespace.path}/i)
end
- forked_project.update!(path: 'test-crappy-path')
-
- visit project_forks_path(project)
+ it 'shows avatars when Gravatar is disabled' do
+ stub_application_setting(gravatar_enabled: false)
- page.within('.js-projects-list-holder') do
- expect(page).to have_content("#{forked_project.namespace.human_name} / #{forked_project.name}")
- end
- end
+ visit project_path(project)
- context 'when the project is private' do
- let(:project) { create(:project, :repository) }
- let(:another_user) { create(:user, name: 'Mike') }
+ click_link 'Fork'
- before do
- project.add_reporter(user)
- project.add_reporter(another_user)
+ page.within('.fork-thumbnail-container') do
+ expect(page).to have_css('div.identicon')
+ end
end
- it 'renders private forks of the project' do
+ it 'shows the forked project on the list' do
visit project_path(project)
- another_project_fork = Projects::ForkService.new(project, another_user).execute
-
click_link 'Fork'
page.within '.fork-thumbnail-container' do
@@ -221,79 +199,117 @@ RSpec.describe 'Project fork' do
visit project_forks_path(project)
+ forked_project = user.fork_of(project.reload)
+
page.within('.js-projects-list-holder') do
- user_project_fork = user.fork_of(project.reload)
- expect(page).to have_content("#{user_project_fork.namespace.human_name} / #{user_project_fork.name}")
+ expect(page).to have_content("#{forked_project.namespace.human_name} / #{forked_project.name}")
end
- expect(page).not_to have_content("#{another_project_fork.namespace.human_name} / #{another_project_fork.name}")
- end
- end
+ forked_project.update!(path: 'test-crappy-path')
- context 'when the user already forked the project' do
- before do
- create(:project, :repository, name: project.name, namespace: user.namespace)
- end
+ visit project_forks_path(project)
- it 'renders error' do
- visit project_path(project)
+ page.within('.js-projects-list-holder') do
+ expect(page).to have_content("#{forked_project.namespace.human_name} / #{forked_project.name}")
+ end
+ end
- click_link 'Fork'
+ context 'when the project is private' do
+ let(:project) { create(:project, :repository) }
+ let(:another_user) { create(:user, name: 'Mike') }
- page.within '.fork-thumbnail-container' do
- click_link 'Select'
+ before do
+ project.add_reporter(user)
+ project.add_reporter(another_user)
end
- expect(page).to have_content "Name has already been taken"
- end
- end
+ it 'renders private forks of the project' do
+ visit project_path(project)
- context 'maintainer in group' do
- let(:group) { create(:group) }
+ another_project_fork = Projects::ForkService.new(project, another_user).execute
- before do
- group.add_maintainer(user)
- end
+ click_link 'Fork'
- it 'allows user to fork project to group or to user namespace', :js do
- visit project_path(project)
- wait_for_requests
+ page.within '.fork-thumbnail-container' do
+ click_link 'Select'
+ end
- expect(page).not_to have_css('a.disabled', text: 'Fork')
+ visit project_forks_path(project)
- click_link 'Fork'
+ page.within('.js-projects-list-holder') do
+ user_project_fork = user.fork_of(project.reload)
+ expect(page).to have_content("#{user_project_fork.namespace.human_name} / #{user_project_fork.name}")
+ end
- expect(page).to have_css('.fork-thumbnail')
- expect(page).to have_css('.group-row')
- expect(page).not_to have_css('.fork-thumbnail.disabled')
+ expect(page).not_to have_content("#{another_project_fork.namespace.human_name} / #{another_project_fork.name}")
+ end
end
- it 'allows user to fork project to group and not user when exceeded project limit', :js do
- user.projects_limit = 0
- user.save!
+ context 'when the user already forked the project' do
+ before do
+ create(:project, :repository, name: project.name, namespace: user.namespace)
+ end
- visit project_path(project)
- wait_for_requests
+ it 'renders error' do
+ visit project_path(project)
- expect(page).not_to have_css('a.disabled', text: 'Fork')
+ click_link 'Fork'
- click_link 'Fork'
+ page.within '.fork-thumbnail-container' do
+ click_link 'Select'
+ end
- expect(page).to have_css('.fork-thumbnail.disabled')
- expect(page).to have_css('.group-row')
+ expect(page).to have_content "Name has already been taken"
+ end
end
- it 'links to the fork if the project was already forked within that namespace', :sidekiq_might_not_need_inline, :js do
- forked_project = fork_project(project, user, namespace: group, repository: true)
+ context 'maintainer in group' do
+ let(:group) { create(:group) }
+
+ before do
+ group.add_maintainer(user)
+ end
+
+ it 'allows user to fork project to group or to user namespace', :js do
+ visit project_path(project)
+ wait_for_requests
+
+ expect(page).not_to have_css('a.disabled', text: 'Fork')
+
+ click_link 'Fork'
+
+ expect(page).to have_css('.fork-thumbnail')
+ expect(page).to have_css('.group-row')
+ expect(page).not_to have_css('.fork-thumbnail.disabled')
+ end
+
+ it 'allows user to fork project to group and not user when exceeded project limit', :js do
+ user.projects_limit = 0
+ user.save!
+
+ visit project_path(project)
+ wait_for_requests
+
+ expect(page).not_to have_css('a.disabled', text: 'Fork')
- visit new_project_fork_path(project)
- wait_for_requests
+ click_link 'Fork'
- expect(page).to have_css('.group-row a.btn', text: 'Go to fork')
+ expect(page).to have_css('.fork-thumbnail.disabled')
+ expect(page).to have_css('.group-row')
+ end
+
+ it 'links to the fork if the project was already forked within that namespace', :sidekiq_might_not_need_inline, :js do
+ forked_project = fork_project(project, user, namespace: group, repository: true)
+
+ visit new_project_fork_path(project)
+ wait_for_requests
+
+ expect(page).to have_css('.group-row a.btn', text: 'Go to fork')
- click_link 'Go to fork'
+ click_link 'Go to fork'
- expect(current_path).to eq(project_path(forked_project))
+ expect(current_path).to eq(project_path(forked_project))
+ end
end
end
end
diff --git a/spec/features/projects/members/anonymous_user_sees_members_spec.rb b/spec/features/projects/members/anonymous_user_sees_members_spec.rb
index d710ecf6c88..6b92581d704 100644
--- a/spec/features/projects/members/anonymous_user_sees_members_spec.rb
+++ b/spec/features/projects/members/anonymous_user_sees_members_spec.rb
@@ -14,25 +14,9 @@ RSpec.describe 'Projects > Members > Anonymous user sees members' do
create(:project_group_link, project: project, group: group)
end
- context 'when `vue_project_members_list` feature flag is enabled', :js do
- it "anonymous user visits the project's members page and sees the list of members" do
- visit project_project_members_path(project)
+ it "anonymous user visits the project's members page and sees the list of members", :js do
+ visit project_project_members_path(project)
- expect(find_member_row(user)).to have_content(user.name)
- end
- end
-
- context 'when `vue_project_members_list` feature flag is disabled' do
- before do
- stub_feature_flags(vue_project_members_list: false)
- end
-
- it "anonymous user visits the project's members page and sees the list of members" do
- visit project_project_members_path(project)
-
- expect(current_path).to eq(
- project_project_members_path(project))
- expect(page).to have_content(user.name)
- end
+ expect(find_member_row(user)).to have_content(user.name)
end
end
diff --git a/spec/features/projects/members/group_members_spec.rb b/spec/features/projects/members/group_members_spec.rb
index 1abd00421ec..94ce18fef93 100644
--- a/spec/features/projects/members/group_members_spec.rb
+++ b/spec/features/projects/members/group_members_spec.rb
@@ -20,218 +20,96 @@ RSpec.describe 'Projects members', :js do
sign_in(user)
end
- context 'when `vue_project_members_list` feature flag is enabled' do
- context 'with a group invitee' do
- before do
- group_invitee
- visit project_project_members_path(project)
- end
-
- it 'does not appear in the project members page' do
- expect(members_table).not_to have_content('test2@abc.com')
- end
+ context 'with a group invitee' do
+ before do
+ group_invitee
+ visit project_project_members_path(project)
end
- context 'with a group' do
- it 'shows group and project members by default' do
- visit project_project_members_path(project)
-
- expect(members_table).to have_content(developer.name)
- expect(members_table).to have_content(user.name)
- expect(members_table).to have_content(group.name)
- end
-
- it 'shows project members only if requested' do
- visit project_project_members_path(project, with_inherited_permissions: 'exclude')
-
- expect(members_table).to have_content(developer.name)
- expect(members_table).not_to have_content(user.name)
- expect(members_table).not_to have_content(group.name)
- end
+ it 'does not appear in the project members page' do
+ expect(members_table).not_to have_content('test2@abc.com')
+ end
+ end
- it 'shows group members only if requested' do
- visit project_project_members_path(project, with_inherited_permissions: 'only')
+ context 'with a group' do
+ it 'shows group and project members by default' do
+ visit project_project_members_path(project)
- expect(members_table).not_to have_content(developer.name)
- expect(members_table).to have_content(user.name)
- expect(members_table).to have_content(group.name)
- end
+ expect(members_table).to have_content(developer.name)
+ expect(members_table).to have_content(user.name)
+ expect(members_table).to have_content(group.name)
end
- context 'with a group, a project invitee, and a project requester' do
- before do
- group.request_access(group_requester)
- project.request_access(project_requester)
- group_invitee
- project_invitee
- visit project_project_members_path(project)
- end
-
- it 'shows the group owner' do
- expect(members_table).to have_content(user.name)
- expect(members_table).to have_content(group.name)
- end
-
- it 'shows the project developer' do
- expect(members_table).to have_content(developer.name)
- end
-
- it 'shows the project invitee' do
- click_link 'Invited'
-
- expect(members_table).to have_content('test1@abc.com')
- expect(members_table).not_to have_content('test2@abc.com')
- end
-
- it 'shows the project requester' do
- click_link 'Access requests'
-
- expect(members_table).to have_content(project_requester.name)
- expect(members_table).not_to have_content(group_requester.name)
- end
- end
+ it 'shows project members only if requested' do
+ visit project_project_members_path(project, with_inherited_permissions: 'exclude')
- context 'with a group requester' do
- before do
- stub_feature_flags(invite_members_group_modal: false)
- group.request_access(group_requester)
- visit project_project_members_path(project)
- end
-
- it 'does not appear in the project members page' do
- expect(page).not_to have_link('Access requests')
- expect(members_table).not_to have_content(group_requester.name)
- end
+ expect(members_table).to have_content(developer.name)
+ expect(members_table).not_to have_content(user.name)
+ expect(members_table).not_to have_content(group.name)
end
- context 'showing status of members' do
- it 'shows the status' do
- create(:user_status, user: user, emoji: 'smirk', message: 'Authoring this object')
+ it 'shows group members only if requested' do
+ visit project_project_members_path(project, with_inherited_permissions: 'only')
- visit project_project_members_path(project)
-
- expect(first_row).to have_selector('gl-emoji[data-name="smirk"]')
- end
+ expect(members_table).not_to have_content(developer.name)
+ expect(members_table).to have_content(user.name)
+ expect(members_table).to have_content(group.name)
end
end
- context 'when `vue_project_members_list` feature flag is disabled' do
+ context 'with a group, a project invitee, and a project requester' do
before do
- stub_feature_flags(vue_project_members_list: false)
+ group.request_access(group_requester)
+ project.request_access(project_requester)
+ group_invitee
+ project_invitee
+ visit project_project_members_path(project)
end
- context 'with a group invitee' do
- before do
- group_invitee
- visit project_project_members_path(project)
- end
-
- it 'does not appear in the project members page' do
- page.within first('.content-list') do
- expect(page).not_to have_content('test2@abc.com')
- end
- end
+ it 'shows the group owner' do
+ expect(members_table).to have_content(user.name)
+ expect(members_table).to have_content(group.name)
end
- context 'with a group' do
- it 'shows group and project members by default' do
- visit project_project_members_path(project)
-
- page.within first('.content-list') do
- expect(page).to have_content(developer.name)
-
- expect(page).to have_content(user.name)
- expect(page).to have_content(group.name)
- end
- end
-
- it 'shows project members only if requested' do
- visit project_project_members_path(project, with_inherited_permissions: 'exclude')
-
- page.within first('.content-list') do
- expect(page).to have_content(developer.name)
+ it 'shows the project developer' do
+ expect(members_table).to have_content(developer.name)
+ end
- expect(page).not_to have_content(user.name)
- expect(page).not_to have_content(group.name)
- end
- end
+ it 'shows the project invitee' do
+ click_link 'Invited'
- it 'shows group members only if requested' do
- visit project_project_members_path(project, with_inherited_permissions: 'only')
+ expect(members_table).to have_content('test1@abc.com')
+ expect(members_table).not_to have_content('test2@abc.com')
+ end
- page.within first('.content-list') do
- expect(page).not_to have_content(developer.name)
+ it 'shows the project requester' do
+ click_link 'Access requests'
- expect(page).to have_content(user.name)
- expect(page).to have_content(group.name)
- end
- end
+ expect(members_table).to have_content(project_requester.name)
+ expect(members_table).not_to have_content(group_requester.name)
end
+ end
- context 'with a group, a project invitee, and a project requester' do
- before do
- group.request_access(group_requester)
- project.request_access(project_requester)
- group_invitee
- project_invitee
- visit project_project_members_path(project)
- end
-
- it 'shows the group owner' do
- page.within first('.content-list') do
- # Group owner
- expect(page).to have_content(user.name)
- expect(page).to have_content(group.name)
- end
- end
-
- it 'shows the project developer' do
- page.within first('.content-list') do
- # Project developer
- expect(page).to have_content(developer.name)
- end
- end
-
- it 'shows the project invitee' do
- click_link 'Invited'
-
- page.within first('.content-list') do
- expect(page).to have_content('test1@abc.com')
- expect(page).not_to have_content('test2@abc.com')
- end
- end
-
- it 'shows the project requester' do
- click_link 'Access requests'
-
- page.within first('.content-list') do
- expect(page).to have_content(project_requester.name)
- expect(page).not_to have_content(group_requester.name)
- end
- end
+ context 'with a group requester' do
+ before do
+ stub_feature_flags(invite_members_group_modal: false)
+ group.request_access(group_requester)
+ visit project_project_members_path(project)
end
- context 'with a group requester' do
- before do
- stub_feature_flags(invite_members_group_modal: false)
- group.request_access(group_requester)
- visit project_project_members_path(project)
- end
-
- it 'does not appear in the project members page' do
- expect(page).not_to have_link('Access requests')
- page.within first('.content-list') do
- expect(page).not_to have_content(group_requester.name)
- end
- end
+ it 'does not appear in the project members page' do
+ expect(page).not_to have_link('Access requests')
+ expect(members_table).not_to have_content(group_requester.name)
end
+ end
+
+ context 'showing status of members' do
+ it 'shows the status' do
+ create(:user_status, user: user, emoji: 'smirk', message: 'Authoring this object')
- context 'showing status of members' do
- it_behaves_like 'showing user status' do
- let(:user_with_status) { developer }
+ visit project_project_members_path(project)
- subject { visit project_project_members_path(project) }
- end
+ expect(first_row).to have_selector('gl-emoji[data-name="smirk"]')
end
end
end
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 9d087dfd5f6..6a1d26983b5 100644
--- a/spec/features/projects/members/groups_with_access_list_spec.rb
+++ b/spec/features/projects/members/groups_with_access_list_spec.rb
@@ -17,172 +17,80 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do
project.add_maintainer(user)
sign_in(user)
- end
-
- context 'when `vue_project_members_list` feature flag is enabled' do
- before do
- visit project_project_members_path(project)
- click_groups_tab
- end
-
- it 'updates group access level' do
- click_button group_link.human_access
- click_button 'Guest'
-
- wait_for_requests
-
- visit project_project_members_path(project)
- click_groups_tab
-
- expect(find_group_row(group)).to have_content('Guest')
- end
+ visit project_project_members_path(project)
+ click_groups_tab
+ end
- it 'updates expiry date' do
- page.within find_group_row(group) do
- fill_in 'Expiration date', with: 5.days.from_now.to_date
- find_field('Expiration date').native.send_keys :enter
+ it 'updates group access level' do
+ click_button group_link.human_access
+ click_button 'Guest'
- wait_for_requests
+ wait_for_requests
- expect(page).to have_content(/in \d days/)
- end
- end
+ visit project_project_members_path(project)
- context 'when link has expiry date set' do
- let(:additional_link_attrs) { { expires_at: 5.days.from_now.to_date } }
+ click_groups_tab
- it 'clears expiry date' do
- page.within find_group_row(group) do
- expect(page).to have_content(/in \d days/)
+ expect(find_group_row(group)).to have_content('Guest')
+ end
- find('[data-testid="clear-button"]').click
+ it 'updates expiry date' do
+ page.within find_group_row(group) do
+ fill_in 'Expiration date', with: 5.days.from_now.to_date
+ find_field('Expiration date').native.send_keys :enter
- wait_for_requests
+ wait_for_requests
- expect(page).to have_content('No expiration set')
- end
- end
+ expect(page).to have_content(/in \d days/)
end
+ end
- it 'deletes group link' do
- expect(page).to have_content(group.full_name)
+ context 'when link has expiry date set' do
+ let(:additional_link_attrs) { { expires_at: 5.days.from_now.to_date } }
+ it 'clears expiry date' do
page.within find_group_row(group) do
- click_button 'Remove group'
- end
-
- page.within('[role="dialog"]') do
- click_button('Remove group')
- end
-
- expect(page).not_to have_content(group.full_name)
- end
-
- context 'search in existing members' do
- it 'finds no results' do
- fill_in_filtered_search 'Search groups', with: 'testing 123'
-
- click_groups_tab
-
- expect(page).not_to have_content(group.full_name)
- end
+ expect(page).to have_content(/in \d days/)
- it 'finds results' do
- fill_in_filtered_search 'Search groups', with: group.full_name
+ find('[data-testid="clear-button"]').click
- click_groups_tab
+ wait_for_requests
- expect(members_table).to have_content(group.full_name)
+ expect(page).to have_content('No expiration set')
end
end
end
- context 'when `vue_project_members_list` feature flag is disabled' do
- before do
- stub_feature_flags(vue_project_members_list: false)
-
- visit project_project_members_path(project)
- click_groups_tab
- end
-
- it 'updates group access level' do
- click_button group_link.human_access
-
- page.within '.dropdown-menu' do
- click_link 'Guest'
- end
-
- wait_for_requests
-
- visit project_project_members_path(project)
-
- click_groups_tab
+ it 'deletes group link' do
+ expect(page).to have_content(group.full_name)
- expect(first('.group_member')).to have_content('Guest')
+ page.within find_group_row(group) do
+ click_button 'Remove group'
end
- it 'updates expiry date' do
- expires_at_field = "member_expires_at_#{group.id}"
- fill_in expires_at_field, with: 3.days.from_now.to_date
-
- find_field(expires_at_field).native.send_keys :enter
- wait_for_requests
-
- page.within(find('li.group_member')) do
- expect(page).to have_content('Expires in 3 days')
- end
+ page.within('[role="dialog"]') do
+ click_button('Remove group')
end
- context 'when link has expiry date set' do
- let(:additional_link_attrs) { { expires_at: 3.days.from_now.to_date } }
-
- it 'clears expiry date' do
- page.within(find('li.group_member')) do
- expect(page).to have_content('Expires in 3 days')
-
- page.within(find('.js-edit-member-form')) do
- find('.js-clear-input').click
- end
-
- wait_for_requests
+ expect(page).not_to have_content(group.full_name)
+ end
- expect(page).not_to have_content('Expires in')
- end
- end
- end
+ context 'search in existing members' do
+ it 'finds no results' do
+ fill_in_filtered_search 'Search groups', with: 'testing 123'
- it 'deletes group link' do
- page.within(first('.group_member')) do
- accept_confirm { find('.btn-danger').click }
- end
- wait_for_requests
+ click_groups_tab
- expect(page).not_to have_selector('.group_member')
+ expect(page).not_to have_content(group.full_name)
end
- context 'search in existing members' do
- it 'finds no results' do
- page.within '.user-search-form' do
- fill_in 'search_groups', with: 'testing 123'
- find('.user-search-btn').click
- end
-
- click_groups_tab
-
- expect(page).not_to have_selector('.group_member')
- end
-
- it 'finds results' do
- page.within '.user-search-form' do
- fill_in 'search_groups', with: group.name
- find('.user-search-btn').click
- end
+ it 'finds results' do
+ fill_in_filtered_search 'Search groups', with: group.full_name
- click_groups_tab
+ click_groups_tab
- expect(page).to have_selector('.group_member', count: 1)
- end
+ expect(members_table).to have_content(group.full_name)
end
end
diff --git a/spec/features/projects/members/invite_group_spec.rb b/spec/features/projects/members/invite_group_spec.rb
index f0d115fef1d..83ba2533a73 100644
--- a/spec/features/projects/members/invite_group_spec.rb
+++ b/spec/features/projects/members/invite_group_spec.rb
@@ -41,46 +41,20 @@ RSpec.describe 'Project > Members > Invite group', :js do
context 'when the group has "Share with group lock" disabled' do
it_behaves_like 'the project can be shared with groups'
- context 'when `vue_project_members_list` feature flag is enabled' do
- it 'the project can be shared with another group' do
- visit project_project_members_path(project)
+ it 'the project can be shared with another group' do
+ visit project_project_members_path(project)
- expect(page).not_to have_link 'Groups'
+ expect(page).not_to have_link 'Groups'
- click_on 'invite-group-tab'
+ click_on 'invite-group-tab'
- select2 group_to_share_with.id, from: '#link_group_id'
- page.find('body').click
- find('.btn-success').click
+ select2 group_to_share_with.id, from: '#link_group_id'
+ page.find('body').click
+ find('.btn-confirm').click
- click_link 'Groups'
+ click_link 'Groups'
- expect(members_table).to have_content(group_to_share_with.name)
- end
- end
-
- context 'when `vue_project_members_list` feature flag is disabled' do
- before do
- stub_feature_flags(vue_project_members_list: false)
- end
-
- it 'the project can be shared with another group' do
- visit project_project_members_path(project)
-
- expect(page).not_to have_link 'Groups'
-
- click_on 'invite-group-tab'
-
- select2 group_to_share_with.id, from: '#link_group_id'
- page.find('body').click
- find('.btn-success').click
-
- click_link 'Groups'
-
- page.within('[data-testid="project-member-groups"]') do
- expect(page).to have_content(group_to_share_with.name)
- end
- end
+ expect(members_table).to have_content(group_to_share_with.name)
end
end
@@ -159,36 +133,15 @@ RSpec.describe 'Project > Members > Invite group', :js do
fill_in 'expires_at_groups', with: 5.days.from_now.strftime('%Y-%m-%d')
click_on 'invite-group-tab'
- find('.btn-success').click
- end
-
- context 'when `vue_project_members_list` feature flag is enabled' do
- it 'the group link shows the expiration time with a warning class' do
- setup
- click_link 'Groups'
-
- expect(find_group_row(group)).to have_content(/in \d days/)
- expect(find_group_row(group)).to have_selector('.gl-text-orange-500')
- end
+ find('.btn-confirm').click
end
- context 'when `vue_project_members_list` feature flag is disabled' do
- before do
- stub_feature_flags(vue_project_members_list: false)
- end
-
- it 'the group link shows the expiration time with a warning class' do
- setup
- click_link 'Groups'
+ it 'the group link shows the expiration time with a warning class' do
+ setup
+ click_link 'Groups'
- page.within('[data-testid="project-member-groups"]') do
- # Using distance_of_time_in_words_to_now because it is not the same as
- # subtraction, and this way avoids time zone issues as well
- expires_in_text = distance_of_time_in_words_to_now(project.project_group_links.first.expires_at)
- expect(page).to have_content(expires_in_text)
- expect(page).to have_selector('.text-warning')
- end
- end
+ expect(find_group_row(group)).to have_content(/in \d days/)
+ expect(find_group_row(group)).to have_selector('.gl-text-orange-500')
end
end
diff --git a/spec/features/projects/members/list_spec.rb b/spec/features/projects/members/list_spec.rb
index b0fe5b9c48a..0830585da9b 100644
--- a/spec/features/projects/members/list_spec.rb
+++ b/spec/features/projects/members/list_spec.rb
@@ -2,232 +2,192 @@
require 'spec_helper'
-RSpec.describe 'Project members list' do
+RSpec.describe 'Project members list', :js do
include Select2Helper
+ include Spec::Support::Helpers::Features::MembersHelpers
let(:user1) { create(:user, name: 'John Doe') }
let(:user2) { create(:user, name: 'Mary Jane') }
let(:group) { create(:group) }
- let(:project) { create(:project, namespace: group) }
+ let(:project) { create(:project, :internal, namespace: group) }
before do
- stub_feature_flags(invite_members_group_modal: false)
+ stub_feature_flags(invite_members_group_modal: true)
sign_in(user1)
group.add_owner(user1)
end
- context 'when `vue_project_members_list` feature flag is enabled', :js do
- include Spec::Support::Helpers::Features::MembersHelpers
+ it 'show members from project and group' do
+ project.add_developer(user2)
- it 'pushes `vue_project_members_list` feature flag to the frontend' do
- visit_members_page
-
- expect(page).to have_pushed_frontend_feature_flags(vueProjectMembersList: true)
- end
+ visit_members_page
- it 'show members from project and group' do
- project.add_developer(user2)
-
- visit_members_page
-
- expect(first_row).to have_content(user1.name)
- expect(second_row).to have_content(user2.name)
- end
+ expect(first_row).to have_content(user1.name)
+ expect(second_row).to have_content(user2.name)
+ end
- it 'show user once if member of both group and project' do
- project.add_developer(user1)
+ it 'show user once if member of both group and project' do
+ project.add_developer(user1)
- visit_members_page
+ visit_members_page
- expect(first_row).to have_content(user1.name)
- expect(second_row).to be_blank
- end
+ expect(first_row).to have_content(user1.name)
+ expect(second_row).to be_blank
+ end
- it 'update user access level', :js do
- project.add_developer(user2)
+ it 'update user access level' do
+ project.add_developer(user2)
- visit_members_page
+ visit_members_page
- page.within find_member_row(user2) do
- click_button('Developer')
- click_button('Reporter')
+ page.within find_member_row(user2) do
+ click_button('Developer')
+ click_button('Reporter')
- expect(page).to have_button('Reporter')
- end
+ expect(page).to have_button('Reporter')
end
+ end
- it 'add user to project', :js do
- visit_members_page
+ it 'add user to project' do
+ visit_members_page
- add_user(user2.id, 'Reporter')
+ add_user(user2.name, 'Reporter')
- page.within find_member_row(user2) do
- expect(page).to have_button('Reporter')
- end
+ page.within find_member_row(user2) do
+ expect(page).to have_button('Reporter')
end
+ end
- it 'remove user from project', :js do
- other_user = create(:user)
- project.add_developer(other_user)
-
- visit_members_page
-
- # Open modal
- page.within find_member_row(other_user) do
- click_button 'Remove member'
- end
+ it 'uses ProjectMember access_level_roles for the invite members modal access option' do
+ visit_members_page
- page.within('[role="dialog"]') do
- expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests'
- click_button('Remove member')
- end
+ click_on 'Invite members'
- wait_for_requests
+ click_on 'Guest'
+ wait_for_requests
- expect(members_table).not_to have_content(other_user.name)
+ page.within '.dropdown-menu' do
+ expect(page).to have_button('Guest')
+ expect(page).to have_button('Reporter')
+ expect(page).to have_button('Developer')
+ expect(page).to have_button('Maintainer')
+ expect(page).not_to have_button('Owner')
end
+ end
- it 'invite user to project', :js do
- visit_members_page
+ it 'remove user from project' do
+ other_user = create(:user)
+ project.add_developer(other_user)
- add_user('test@example.com', 'Reporter')
+ visit_members_page
- click_link 'Invited'
+ # Open modal
+ page.within find_member_row(other_user) do
+ click_button 'Remove member'
+ end
- page.within find_invited_member_row('test@example.com') do
- expect(page).to have_button('Reporter')
- end
+ page.within('[role="dialog"]') do
+ expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests'
+ click_button('Remove member')
end
- context 'project bots' do
- let(:project_bot) { create(:user, :project_bot, name: 'project_bot') }
+ wait_for_requests
- before do
- project.add_maintainer(project_bot)
- end
+ expect(members_table).not_to have_content(other_user.name)
+ end
- it 'does not show form used to change roles and "Expiration date" or the remove user button' do
- visit_members_page
+ it 'invite user to project' do
+ visit_members_page
- page.within find_member_row(project_bot) do
- expect(page).not_to have_button('Maintainer')
- expect(page).to have_field('Expiration date', disabled: true)
- expect(page).not_to have_button('Remove member')
- end
- end
+ add_user('test@example.com', 'Reporter')
+
+ click_link 'Invited'
+
+ page.within find_invited_member_row('test@example.com') do
+ expect(page).to have_button('Reporter')
end
end
- context 'when `vue_project_members_list` feature flag is disabled' do
- include Spec::Support::Helpers::Features::ListRowsHelpers
+ context 'project bots' do
+ let(:project_bot) { create(:user, :project_bot, name: 'project_bot') }
before do
- stub_feature_flags(vue_project_members_list: false)
+ project.add_maintainer(project_bot)
end
- it 'show members from project and group' do
- project.add_developer(user2)
-
+ it 'does not show form used to change roles and "Expiration date" or the remove user button' do
visit_members_page
- expect(first_row.text).to include(user1.name)
- expect(second_row.text).to include(user2.name)
+ page.within find_member_row(project_bot) do
+ expect(page).not_to have_button('Maintainer')
+ expect(page).to have_field('Expiration date', disabled: true)
+ expect(page).not_to have_button('Remove member')
+ end
end
+ end
- it 'show user once if member of both group and project' do
- project.add_developer(user1)
-
- visit_members_page
+ describe 'when user has 2FA enabled' do
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:user_with_2fa) { create(:user, :two_factor_via_otp) }
- expect(first_row.text).to include(user1.name)
- expect(second_row).to be_blank
+ before do
+ project.add_guest(user_with_2fa)
end
- it 'update user access level', :js do
- project.add_developer(user2)
+ it 'shows 2FA badge to user with "Maintainer" access level' do
+ project.add_maintainer(user1)
visit_members_page
- page.within(second_row) do
- click_button('Developer')
- click_link('Reporter')
-
- expect(page).to have_button('Reporter')
- end
+ expect(find_member_row(user_with_2fa)).to have_content('2FA')
end
- it 'add user to project', :js do
- visit_members_page
+ it 'shows 2FA badge to admins' do
+ sign_in(admin)
+ gitlab_enable_admin_mode_sign_in(admin)
- add_user(user2.id, 'Reporter')
+ visit_members_page
- page.within(second_row) do
- expect(page).to have_content(user2.name)
- expect(page).to have_button('Reporter')
- end
+ expect(find_member_row(user_with_2fa)).to have_content('2FA')
end
- it 'remove user from project', :js do
- other_user = create(:user)
- project.add_developer(other_user)
+ it 'does not show 2FA badge to users with access level below "Maintainer"' do
+ group.add_developer(user1)
visit_members_page
- # Open modal
- find(:css, 'li.project_member', text: other_user.name).find(:css, 'button.btn-danger').click
-
- expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests'
-
- click_on('Remove member')
-
- wait_for_requests
-
- expect(page).not_to have_content(other_user.name)
- expect(project.users).not_to include(other_user)
+ expect(find_member_row(user_with_2fa)).not_to have_content('2FA')
end
- it 'invite user to project', :js do
- visit_members_page
-
- add_user('test@example.com', 'Reporter')
+ it 'shows 2FA badge to themselves' do
+ sign_in(user_with_2fa)
- click_link 'Invited'
+ visit_members_page
- page.within(first_row) do
- expect(page).to have_content('test@example.com')
- expect(page).to have_content('Invited')
- expect(page).to have_button('Reporter')
- end
+ expect(find_member_row(user_with_2fa)).to have_content('2FA')
end
+ end
- context 'project bots' do
- let(:project_bot) { create(:user, :project_bot, name: 'project_bot') }
-
- before do
- project.add_maintainer(project_bot)
- end
+ private
- it 'does not show form used to change roles and "Expiration date" or the remove user button' do
- project_member = project.project_members.find_by(user_id: project_bot.id)
+ def add_user(id, role)
+ click_on 'Invite members'
- visit_members_page
+ page.within '#invite-members-modal' do
+ fill_in 'Search for members to invite', with: id
- expect(page).not_to have_selector("#edit_project_member_#{project_member.id}")
- expect(page).to have_no_selector("#project_member_#{project_member.id} .btn-danger")
- end
- end
- end
+ wait_for_requests
+ click_button id
- private
+ click_button 'Guest'
+ wait_for_requests
+ click_button role
- def add_user(id, role)
- page.within ".invite-users-form" do
- select2(id, from: "#user_ids", multiple: true)
- select(role, from: "access_level")
+ click_button 'Invite'
end
- click_button "Invite"
+ page.refresh
end
def visit_members_page
diff --git a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb
index 1127c64e0c7..d22097a2f6f 100644
--- a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb
+++ b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb
@@ -18,107 +18,51 @@ RSpec.describe 'Projects > Members > Maintainer adds member with expiration date
sign_in(maintainer)
end
- context 'when `vue_project_members_list` feature flag is enabled' do
- it 'expiration date is displayed in the members list' do
- stub_feature_flags(invite_members_group_modal: false)
+ it 'expiration date is displayed in the members list' do
+ stub_feature_flags(invite_members_group_modal: false)
- visit project_project_members_path(project)
+ visit project_project_members_path(project)
- page.within '.invite-users-form' do
- select2(new_member.id, from: '#user_ids', multiple: true)
+ page.within '.invite-users-form' do
+ select2(new_member.id, from: '#user_ids', multiple: true)
- fill_in 'expires_at', with: 5.days.from_now.to_date
- find_field('expires_at').native.send_keys :enter
+ fill_in 'expires_at', with: 5.days.from_now.to_date
+ find_field('expires_at').native.send_keys :enter
- click_on 'Invite'
- end
-
- page.within find_member_row(new_member) do
- expect(page).to have_content(/in \d days/)
- end
- end
-
- it 'changes expiration date' do
- project.team.add_users([new_member.id], :developer, expires_at: 3.days.from_now.to_date)
- visit project_project_members_path(project)
-
- page.within find_member_row(new_member) do
- fill_in 'Expiration date', with: 5.days.from_now.to_date
- find_field('Expiration date').native.send_keys :enter
-
- wait_for_requests
-
- expect(page).to have_content(/in \d days/)
- end
+ click_on 'Invite'
end
- it 'clears expiration date' do
- project.team.add_users([new_member.id], :developer, expires_at: 5.days.from_now.to_date)
- visit project_project_members_path(project)
-
- page.within find_member_row(new_member) do
- expect(page).to have_content(/in \d days/)
-
- find('[data-testid="clear-button"]').click
-
- wait_for_requests
-
- expect(page).to have_content('No expiration set')
- end
+ page.within find_member_row(new_member) do
+ expect(page).to have_content(/in \d days/)
end
end
- context 'when `vue_project_members_list` feature flag is disabled' do
- before do
- stub_feature_flags(vue_project_members_list: false)
- end
-
- it 'expiration date is displayed in the members list' do
- stub_feature_flags(invite_members_group_modal: false)
-
- visit project_project_members_path(project)
-
- page.within '.invite-users-form' do
- select2(new_member.id, from: '#user_ids', multiple: true)
-
- fill_in 'expires_at', with: 3.days.from_now.to_date
- find_field('expires_at').native.send_keys :enter
+ it 'changes expiration date' do
+ project.team.add_users([new_member.id], :developer, expires_at: 3.days.from_now.to_date)
+ visit project_project_members_path(project)
- click_on 'Invite'
- end
+ page.within find_member_row(new_member) do
+ fill_in 'Expiration date', with: 5.days.from_now.to_date
+ find_field('Expiration date').native.send_keys :enter
- page.within "#project_member_#{project_member_id}" do
- expect(page).to have_content('Expires in 3 days')
- end
- end
-
- it 'changes expiration date' do
- project.team.add_users([new_member.id], :developer, expires_at: 1.day.from_now.to_date)
- visit project_project_members_path(project)
-
- page.within "#project_member_#{project_member_id}" do
- fill_in 'Expiration date', with: 3.days.from_now.to_date
- find_field('Expiration date').native.send_keys :enter
+ wait_for_requests
- wait_for_requests
-
- expect(page).to have_content('Expires in 3 days')
- end
+ expect(page).to have_content(/in \d days/)
end
+ end
- it 'clears expiration date' do
- project.team.add_users([new_member.id], :developer, expires_at: 3.days.from_now.to_date)
- visit project_project_members_path(project)
+ it 'clears expiration date' do
+ project.team.add_users([new_member.id], :developer, expires_at: 5.days.from_now.to_date)
+ visit project_project_members_path(project)
- page.within "#project_member_#{project_member_id}" do
- expect(page).to have_content('Expires in 3 days')
+ page.within find_member_row(new_member) do
+ expect(page).to have_content(/in \d days/)
- find('.js-clear-input').click
+ find('[data-testid="clear-button"]').click
- wait_for_requests
+ wait_for_requests
- expect(page).not_to have_content('Expires in')
- end
+ expect(page).to have_content('No expiration set')
end
end
diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb
index 3c132747bc4..653564d1566 100644
--- a/spec/features/projects/members/sorting_spec.rb
+++ b/spec/features/projects/members/sorting_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Projects > Members > Sorting' do
+RSpec.describe 'Projects > Members > Sorting', :js do
include Spec::Support::Helpers::Features::MembersHelpers
let(:maintainer) { create(:user, name: 'John Doe') }
@@ -15,165 +15,85 @@ RSpec.describe 'Projects > Members > Sorting' do
sign_in(maintainer)
end
- context 'when `vue_project_members_list` feature flag is enabled', :js do
- it 'sorts by account by default' do
- visit_members_list(sort: nil)
+ it 'sorts by account by default' do
+ visit_members_list(sort: nil)
- expect(first_row).to have_content(maintainer.name)
- expect(second_row).to have_content(developer.name)
+ expect(first_row).to have_content(maintainer.name)
+ expect(second_row).to have_content(developer.name)
- expect_sort_by('Account', :asc)
- end
-
- it 'sorts by max role ascending' do
- visit_members_list(sort: :access_level_asc)
-
- expect(first_row).to have_content(developer.name)
- expect(second_row).to have_content(maintainer.name)
-
- expect_sort_by('Max role', :asc)
- end
-
- it 'sorts by max role descending' do
- visit_members_list(sort: :access_level_desc)
-
- expect(first_row).to have_content(maintainer.name)
- expect(second_row).to have_content(developer.name)
-
- expect_sort_by('Max role', :desc)
- end
-
- it 'sorts by access granted ascending' do
- visit_members_list(sort: :last_joined)
-
- expect(first_row).to have_content(maintainer.name)
- expect(second_row).to have_content(developer.name)
-
- expect_sort_by('Access granted', :asc)
- end
-
- it 'sorts by access granted descending' do
- visit_members_list(sort: :oldest_joined)
-
- expect(first_row).to have_content(developer.name)
- expect(second_row).to have_content(maintainer.name)
-
- expect_sort_by('Access granted', :desc)
- end
-
- it 'sorts by account ascending' do
- visit_members_list(sort: :name_asc)
-
- expect(first_row).to have_content(maintainer.name)
- expect(second_row).to have_content(developer.name)
-
- expect_sort_by('Account', :asc)
- end
-
- it 'sorts by account descending' do
- visit_members_list(sort: :name_desc)
-
- expect(first_row).to have_content(developer.name)
- expect(second_row).to have_content(maintainer.name)
-
- expect_sort_by('Account', :desc)
- end
+ expect_sort_by('Account', :asc)
+ end
- it 'sorts by last sign-in ascending', :clean_gitlab_redis_shared_state do
- visit_members_list(sort: :recent_sign_in)
+ it 'sorts by max role ascending' do
+ visit_members_list(sort: :access_level_asc)
- expect(first_row).to have_content(maintainer.name)
- expect(second_row).to have_content(developer.name)
+ expect(first_row).to have_content(developer.name)
+ expect(second_row).to have_content(maintainer.name)
- expect_sort_by('Last sign-in', :asc)
- end
+ expect_sort_by('Max role', :asc)
+ end
- it 'sorts by last sign-in descending', :clean_gitlab_redis_shared_state do
- visit_members_list(sort: :oldest_sign_in)
+ it 'sorts by max role descending' do
+ visit_members_list(sort: :access_level_desc)
- expect(first_row).to have_content(developer.name)
- expect(second_row).to have_content(maintainer.name)
+ expect(first_row).to have_content(maintainer.name)
+ expect(second_row).to have_content(developer.name)
- expect_sort_by('Last sign-in', :desc)
- end
+ expect_sort_by('Max role', :desc)
end
- context 'when `vue_project_members_list` feature flag is disabled' do
- before do
- stub_feature_flags(vue_project_members_list: false)
- end
-
- it 'sorts alphabetically by default' do
- visit_members_list(sort: nil)
+ it 'sorts by access granted ascending' do
+ visit_members_list(sort: :last_joined)
- expect(first_member).to include(maintainer.name)
- expect(second_member).to include(developer.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
- end
+ expect(first_row).to have_content(maintainer.name)
+ expect(second_row).to have_content(developer.name)
- it 'sorts by access level ascending' do
- visit_members_list(sort: :access_level_asc)
+ expect_sort_by('Access granted', :asc)
+ end
- expect(first_member).to include(developer.name)
- expect(second_member).to include(maintainer.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Access level, ascending')
- end
+ it 'sorts by access granted descending' do
+ visit_members_list(sort: :oldest_joined)
- it 'sorts by access level descending' do
- visit_members_list(sort: :access_level_desc)
+ expect(first_row).to have_content(developer.name)
+ expect(second_row).to have_content(maintainer.name)
- expect(first_member).to include(maintainer.name)
- expect(second_member).to include(developer.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Access level, descending')
- end
+ expect_sort_by('Access granted', :desc)
+ end
- it 'sorts by last joined' do
- visit_members_list(sort: :last_joined)
+ it 'sorts by account ascending' do
+ visit_members_list(sort: :name_asc)
- expect(first_member).to include(maintainer.name)
- expect(second_member).to include(developer.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Last joined')
- end
+ expect(first_row).to have_content(maintainer.name)
+ expect(second_row).to have_content(developer.name)
- it 'sorts by oldest joined' do
- visit_members_list(sort: :oldest_joined)
+ expect_sort_by('Account', :asc)
+ end
- expect(first_member).to include(developer.name)
- expect(second_member).to include(maintainer.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Oldest joined')
- end
+ it 'sorts by account descending' do
+ visit_members_list(sort: :name_desc)
- it 'sorts by name ascending' do
- visit_members_list(sort: :name_asc)
+ expect(first_row).to have_content(developer.name)
+ expect(second_row).to have_content(maintainer.name)
- expect(first_member).to include(maintainer.name)
- expect(second_member).to include(developer.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
- end
+ expect_sort_by('Account', :desc)
+ end
- it 'sorts by name descending' do
- visit_members_list(sort: :name_desc)
+ it 'sorts by last sign-in ascending', :clean_gitlab_redis_shared_state do
+ visit_members_list(sort: :recent_sign_in)
- expect(first_member).to include(developer.name)
- expect(second_member).to include(maintainer.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Name, descending')
- end
+ expect(first_row).to have_content(maintainer.name)
+ expect(second_row).to have_content(developer.name)
- it 'sorts by recent sign in', :clean_gitlab_redis_shared_state do
- visit_members_list(sort: :recent_sign_in)
+ expect_sort_by('Last sign-in', :asc)
+ end
- expect(first_member).to include(maintainer.name)
- expect(second_member).to include(developer.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in')
- end
+ it 'sorts by last sign-in descending', :clean_gitlab_redis_shared_state do
+ visit_members_list(sort: :oldest_sign_in)
- it 'sorts by oldest sign in', :clean_gitlab_redis_shared_state do
- visit_members_list(sort: :oldest_sign_in)
+ expect(first_row).to have_content(developer.name)
+ expect(second_row).to have_content(maintainer.name)
- expect(first_member).to include(developer.name)
- expect(second_member).to include(maintainer.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Oldest sign in')
- end
+ expect_sort_by('Last sign-in', :desc)
end
private
diff --git a/spec/features/projects/members/tabs_spec.rb b/spec/features/projects/members/tabs_spec.rb
index eef3395de91..471be26e126 100644
--- a/spec/features/projects/members/tabs_spec.rb
+++ b/spec/features/projects/members/tabs_spec.rb
@@ -14,6 +14,11 @@ RSpec.describe 'Projects > Members > Tabs' do
let_it_be(:invites) { create_list(:project_member, 2, :invited, project: project) }
let_it_be(:project_group_links) { create_list(:project_group_link, 2, project: project) }
+ before do
+ sign_in(user)
+ visit project_project_members_path(project)
+ end
+
shared_examples 'active "Members" tab' do
it 'displays "Members" tab' do
expect(page).to have_selector('.nav-link.active', text: 'Members')
@@ -21,11 +26,6 @@ RSpec.describe 'Projects > Members > Tabs' do
end
context 'tabs' do
- before do
- sign_in(user)
- visit project_project_members_path(project)
- end
-
where(:tab, :count) do
'Members' | 3
'Invited' | 2
@@ -44,69 +44,25 @@ RSpec.describe 'Projects > Members > Tabs' do
end
end
- context 'when `vue_project_members_list` feature flag is enabled' do
+ context 'when searching "Groups"', :js do
before do
- sign_in(user)
- visit project_project_members_path(project)
- end
-
- context 'when searching "Groups"', :js do
- before do
- click_link 'Groups'
-
- fill_in_filtered_search 'Search groups', with: 'group'
- end
-
- it 'displays "Groups" tab' do
- expect(page).to have_selector('.nav-link.active', text: 'Groups')
- end
+ click_link 'Groups'
- context 'and then searching "Members"' do
- before do
- click_link 'Members 3'
-
- fill_in_filtered_search 'Filter members', with: 'user'
- end
-
- it_behaves_like 'active "Members" tab'
- end
+ fill_in_filtered_search 'Search groups', with: 'group'
end
- end
-
- context 'when `vue_project_members_list` feature flag is disabled' do
- before do
- stub_feature_flags(vue_project_members_list: false)
- sign_in(user)
- visit project_project_members_path(project)
+ it 'displays "Groups" tab' do
+ expect(page).to have_selector('.nav-link.active', text: 'Groups')
end
- context 'when searching "Groups"', :js do
+ context 'and then searching "Members"' do
before do
- click_link 'Groups'
+ click_link 'Members 3'
- page.within '[data-testid="group-link-search-form"]' do
- fill_in 'search_groups', with: 'group'
- find('button[type="submit"]').click
- end
+ fill_in_filtered_search 'Filter members', with: 'user'
end
- it 'displays "Groups" tab' do
- expect(page).to have_selector('.nav-link.active', text: 'Groups')
- end
-
- context 'and then searching "Members"' do
- before do
- click_link 'Members 3'
-
- page.within '[data-testid="user-search-form"]' do
- fill_in 'search', with: 'user'
- find('button[type="submit"]').click
- end
- end
-
- it_behaves_like 'active "Members" tab'
- end
+ it_behaves_like 'active "Members" tab'
end
end
end
diff --git a/spec/features/projects/merge_request_button_spec.rb b/spec/features/projects/merge_request_button_spec.rb
index e3d8534ace9..9547ba8a390 100644
--- a/spec/features/projects/merge_request_button_spec.rb
+++ b/spec/features/projects/merge_request_button_spec.rb
@@ -3,11 +3,13 @@
require 'spec_helper'
RSpec.describe 'Merge Request button' do
- shared_examples 'Merge request button only shown when allowed' do
- let(:user) { create(:user) }
- let(:project) { create(:project, :public, :repository) }
- let(:forked_project) { create(:project, :public, :repository, forked_from_project: project) }
+ include ProjectForksHelper
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let(:forked_project) { fork_project(project, user, repository: true) }
+ shared_examples 'Merge request button only shown when allowed' do
context 'not logged in' do
it 'does not show Create merge request button' do
visit url
@@ -22,10 +24,16 @@ RSpec.describe 'Merge Request button' do
project.add_developer(user)
end
- it 'shows Create merge request button' do
- href = project_new_merge_request_path(project,
- merge_request: { source_branch: 'feature',
- target_branch: 'master' })
+ it 'shows Create merge request button', :js do
+ href = project_new_merge_request_path(
+ project,
+ merge_request: {
+ source_project_id: project.id,
+ source_branch: 'feature',
+ target_project_id: project.id,
+ target_branch: 'master'
+ }
+ )
visit url
@@ -75,12 +83,16 @@ RSpec.describe 'Merge Request button' do
end
context 'on own fork of project' do
- let(:user) { forked_project.owner }
-
- it 'shows Create merge request button' do
- href = project_new_merge_request_path(forked_project,
- merge_request: { source_branch: 'feature',
- target_branch: 'master' })
+ it 'shows Create merge request button', :js do
+ href = project_new_merge_request_path(
+ forked_project,
+ merge_request: {
+ source_project_id: forked_project.id,
+ source_branch: 'feature',
+ target_project_id: forked_project.id,
+ target_branch: 'master'
+ }
+ )
visit fork_url
@@ -101,11 +113,33 @@ RSpec.describe 'Merge Request button' do
end
context 'on compare page' do
+ let(:label) { 'Create merge request' }
+
it_behaves_like 'Merge request button only shown when allowed' do
- let(:label) { 'Create merge request' }
let(:url) { project_compare_path(project, from: 'master', to: 'feature') }
let(:fork_url) { project_compare_path(forked_project, from: 'master', to: 'feature') }
end
+
+ it 'shows the correct merge request button when viewing across forks', :js do
+ sign_in(user)
+ project.add_developer(user)
+
+ href = project_new_merge_request_path(
+ project,
+ merge_request: {
+ source_project_id: forked_project.id,
+ source_branch: 'feature',
+ target_project_id: project.id,
+ target_branch: 'master'
+ }
+ )
+
+ visit project_compare_path(forked_project, from: 'master', to: 'feature', from_project_id: project.id)
+
+ within("#content-body") do
+ expect(page).to have_link(label, href: href)
+ end
+ end
end
context 'on commits page' do
diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb
index 4aabf040655..ec34640bd00 100644
--- a/spec/features/projects/new_project_spec.rb
+++ b/spec/features/projects/new_project_spec.rb
@@ -86,7 +86,7 @@ RSpec.describe 'New project', :js do
visit new_project_path
find('[data-qa-selector="blank_project_link"]').click
- choose(s_(key))
+ choose(key)
click_button('Create project')
page.within('#blank-project-pane') do
expect(find_field("project_visibility_level_#{level}")).to be_checked
@@ -95,33 +95,55 @@ RSpec.describe 'New project', :js do
end
context 'when group visibility is private but default is internal' do
+ let_it_be(:group) { create(:group, visibility_level: Gitlab::VisibilityLevel::PRIVATE) }
+
before do
stub_application_setting(default_project_visibility: Gitlab::VisibilityLevel::INTERNAL)
end
- it 'has private selected' do
- group = create(:group, visibility_level: Gitlab::VisibilityLevel::PRIVATE)
- visit new_project_path(namespace_id: group.id)
- find('[data-qa-selector="blank_project_link"]').click
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'has private selected' do
+ visit new_project_path(namespace_id: group.id)
+ find('[data-qa-selector="blank_project_link"]').click
- page.within('#blank-project-pane') do
- expect(find_field("project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).to be_checked
+ page.within('#blank-project-pane') do
+ expect(find_field("project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).to be_checked
+ end
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it 'is not allowed' do
+ visit new_project_path(namespace_id: group.id)
+
+ expect(page).to have_content('Not Found')
end
end
end
context 'when group visibility is public but user requests private' do
+ let_it_be(:group) { create(:group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
+
before do
stub_application_setting(default_project_visibility: Gitlab::VisibilityLevel::INTERNAL)
end
- it 'has private selected' do
- group = create(:group, visibility_level: Gitlab::VisibilityLevel::PUBLIC)
- visit new_project_path(namespace_id: group.id, project: { visibility_level: Gitlab::VisibilityLevel::PRIVATE })
- find('[data-qa-selector="blank_project_link"]').click
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'has private selected' do
+ visit new_project_path(namespace_id: group.id, project: { visibility_level: Gitlab::VisibilityLevel::PRIVATE })
+ find('[data-qa-selector="blank_project_link"]').click
- page.within('#blank-project-pane') do
- expect(find_field("project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).to be_checked
+ page.within('#blank-project-pane') do
+ expect(find_field("project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).to be_checked
+ end
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it 'is not allowed' do
+ visit new_project_path(namespace_id: group.id, project: { visibility_level: Gitlab::VisibilityLevel::PRIVATE })
+
+ expect(page).to have_content('Not Found')
end
end
end
diff --git a/spec/features/projects/pages/user_edits_settings_spec.rb b/spec/features/projects/pages/user_edits_settings_spec.rb
index 3649fae17ce..6156b5243de 100644
--- a/spec/features/projects/pages/user_edits_settings_spec.rb
+++ b/spec/features/projects/pages/user_edits_settings_spec.rb
@@ -140,7 +140,7 @@ RSpec.describe 'Pages edits pages settings', :js do
before do
allow(Projects::UpdateService).to receive(:new).and_return(service)
- allow(service).to receive(:execute).and_return(status: :error, message: 'Some error has occured')
+ allow(service).to receive(:execute).and_return(status: :error, message: 'Some error has occurred')
end
it 'tries to change the setting' do
@@ -150,7 +150,7 @@ RSpec.describe 'Pages edits pages settings', :js do
click_button 'Save'
- expect(page).to have_text('Some error has occured')
+ expect(page).to have_text('Some error has occurred')
end
end
diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb
index 6421d3db2cd..9037aa5c9a8 100644
--- a/spec/features/projects/pipelines/pipelines_spec.rb
+++ b/spec/features/projects/pipelines/pipelines_spec.rb
@@ -14,6 +14,7 @@ RSpec.describe 'Pipelines', :js do
sign_in(user)
stub_feature_flags(graphql_pipeline_details: false)
stub_feature_flags(graphql_pipeline_details_users: false)
+ stub_feature_flags(new_pipelines_table: false)
project.add_developer(user)
project.update!(auto_devops_attributes: { enabled: false })
@@ -519,75 +520,58 @@ RSpec.describe 'Pipelines', :js do
end
end
- shared_examples 'mini pipeline renders' do |ci_mini_pipeline_gl_dropdown_enabled|
- context 'mini pipeline graph' do
- let!(:build) do
- create(:ci_build, :pending, pipeline: pipeline,
- stage: 'build',
- name: 'build')
- end
+ context 'mini pipeline graph' do
+ let!(:build) do
+ create(:ci_build, :pending, pipeline: pipeline,
+ stage: 'build',
+ name: 'build')
+ end
- before do
- stub_feature_flags(ci_mini_pipeline_gl_dropdown: ci_mini_pipeline_gl_dropdown_enabled)
- visit_project_pipelines
- end
+ dropdown_selector = '[data-testid="mini-pipeline-graph-dropdown"]'
- let_it_be(:dropdown_toggle_selector) do
- if ci_mini_pipeline_gl_dropdown_enabled
- '[data-testid="mini-pipeline-graph-dropdown"] .dropdown-toggle'
- else
- '[data-testid="mini-pipeline-graph-dropdown-toggle"]'
- end
- end
+ before do
+ visit_project_pipelines
+ end
- it 'renders a mini pipeline graph' do
- expect(page).to have_selector('[data-testid="widget-mini-pipeline-graph"]')
- expect(page).to have_selector(dropdown_toggle_selector)
- end
+ it 'renders a mini pipeline graph' do
+ expect(page).to have_selector('[data-testid="widget-mini-pipeline-graph"]')
+ expect(page).to have_selector(dropdown_selector)
+ end
- context 'when clicking a stage badge' do
- it 'opens a dropdown' do
- find(dropdown_toggle_selector).click
+ context 'when clicking a stage badge' do
+ it 'opens a dropdown' do
+ find(dropdown_selector).click
- expect(page).to have_link build.name
- end
+ expect(page).to have_link build.name
+ end
- it 'is possible to cancel pending build' do
- find(dropdown_toggle_selector).click
- find('.js-ci-action').click
- wait_for_requests
+ it 'is possible to cancel pending build' do
+ find(dropdown_selector).click
+ find('.js-ci-action').click
+ wait_for_requests
- expect(build.reload).to be_canceled
- end
+ expect(build.reload).to be_canceled
end
+ end
- context 'for a failed pipeline' do
- let!(:build) do
- create(:ci_build, :failed, pipeline: pipeline,
- stage: 'build',
- name: 'build')
- end
+ context 'for a failed pipeline' do
+ let!(:build) do
+ create(:ci_build, :failed, pipeline: pipeline,
+ stage: 'build',
+ name: 'build')
+ end
- it 'displays the failure reason' do
- find(dropdown_toggle_selector).click
+ it 'displays the failure reason' do
+ find(dropdown_selector).click
- within('.js-builds-dropdown-list') do
- build_element = page.find('.mini-pipeline-graph-dropdown-item')
- expect(build_element['title']).to eq('build - failed - (unknown failure)')
- end
+ within('.js-builds-dropdown-list') do
+ build_element = page.find('.mini-pipeline-graph-dropdown-item')
+ expect(build_element['title']).to eq('build - failed - (unknown failure)')
end
end
end
end
- context 'with ci_mini_pipeline_gl_dropdown disabled' do
- it_behaves_like "mini pipeline renders", false
- end
-
- context 'with ci_mini_pipeline_gl_dropdown enabled' do
- it_behaves_like "mini pipeline renders", true
- end
-
context 'with pagination' do
before do
allow(Ci::Pipeline).to receive(:default_per_page).and_return(1)
diff --git a/spec/features/projects/releases/user_creates_release_spec.rb b/spec/features/projects/releases/user_creates_release_spec.rb
index 2acdf983cf2..9e428a0623d 100644
--- a/spec/features/projects/releases/user_creates_release_spec.rb
+++ b/spec/features/projects/releases/user_creates_release_spec.rb
@@ -33,11 +33,11 @@ RSpec.describe 'User creates release', :js do
end
it 'defaults the "Create from" dropdown to the project\'s default branch' do
- expect(page.find('.ref-selector button')).to have_content(project.default_branch)
+ expect(page.find('[data-testid="create-from-field"] .ref-selector button')).to have_content(project.default_branch)
end
- context 'when the "Save release" button is clicked', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/297507' do
- let(:tag_name) { 'v1.0' }
+ context 'when the "Save release" button is clicked' do
+ let(:tag_name) { 'v2.0.31' }
let(:release_title) { 'A most magnificent release' }
let(:release_notes) { 'Best. Release. **Ever.** :rocket:' }
let(:link_1) { { url: 'https://gitlab.example.com/runbook', title: 'An example runbook', type: 'runbook' } }
@@ -47,7 +47,7 @@ RSpec.describe 'User creates release', :js do
fill_out_form_and_submit
end
- it 'creates a new release when "Create release" is clicked', :aggregate_failures do
+ it 'creates a new release when "Create release" is clicked and redirects to the release\'s dedicated page', :aggregate_failures do
release = project.releases.last
expect(release.tag).to eq(tag_name)
@@ -65,10 +65,6 @@ RSpec.describe 'User creates release', :js do
link = release.links.find { |l| l.link_type == link_2[:type] }
expect(link.url).to eq(link_2[:url])
expect(link.name).to eq(link_2[:title])
- end
-
- it 'redirects to the dedicated page for the newly created release' do
- release = project.releases.last
expect(page).to have_current_path(project_release_path(project, release))
end
@@ -116,30 +112,27 @@ RSpec.describe 'User creates release', :js do
end
def fill_out_form_and_submit
- fill_tag_name(tag_name)
+ select_new_tag_name(tag_name)
select_create_from(branch.name)
fill_release_title(release_title)
- select_milestone(milestone_1.title, and_tab: false)
+ select_milestone(milestone_1.title)
select_milestone(milestone_2.title)
- # Focus the "Release notes" field by clicking instead of tabbing
- # because tabbing to the field requires too many tabs
- # (see https://gitlab.com/gitlab-org/gitlab/-/issues/238619)
- find_field('Release notes').click
fill_release_notes(release_notes)
- # Tab past the "assets" documentation link
- focused_element.send_keys(:tab)
-
fill_asset_link(link_1)
add_another_asset_link
fill_asset_link(link_2)
- # Submit using the Control+Enter shortcut
- focused_element.send_keys([:control, :enter])
+ # Click on the body in order to trigger a `blur` event on the current field.
+ # This triggers the form's validation to run so that the
+ # "Create release" button is enabled and clickable.
+ page.find('body').click
+
+ click_button('Create release')
wait_for_all_requests
end
diff --git a/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb b/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb
index 32519b14d4e..88812fc188b 100644
--- a/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb
+++ b/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb
@@ -113,7 +113,7 @@ RSpec.describe 'Set up Mattermost slash commands', :js do
click_link 'Add to Mattermost'
- expect(page).to have_selector('.alert')
+ expect(page).to have_selector('.gl-alert')
expect(page).to have_content('test mattermost error message')
end
diff --git a/spec/features/projects/settings/operations_settings_spec.rb b/spec/features/projects/settings/operations_settings_spec.rb
index 1d9f256a819..fe0ee52e4fa 100644
--- a/spec/features/projects/settings/operations_settings_spec.rb
+++ b/spec/features/projects/settings/operations_settings_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do
describe 'Settings > Operations' do
describe 'Incidents' do
let(:create_issue) { 'Create an incident. Incidents are created for each alert triggered.' }
- let(:send_email) { 'Send a separate email notification to Developers.' }
+ let(:send_email) { 'Send a single email notification to Owners and Maintainers for new alerts.' }
before do
create(:project_incident_management_setting, send_email: true, project: project)
@@ -162,13 +162,13 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do
end
expect(page).to have_content('Grafana URL')
- expect(page).to have_content('API Token')
- expect(page).to have_button('Save Changes')
+ expect(page).to have_content('API token')
+ expect(page).to have_button('Save changes')
fill_in('grafana-url', with: 'http://gitlab-test.grafana.net')
fill_in('grafana-token', with: 'token')
- click_button('Save Changes')
+ click_button('Save changes')
wait_for_requests
diff --git a/spec/features/projects/settings/service_desk_setting_spec.rb b/spec/features/projects/settings/service_desk_setting_spec.rb
index d31913d2dcf..50451075db5 100644
--- a/spec/features/projects/settings/service_desk_setting_spec.rb
+++ b/spec/features/projects/settings/service_desk_setting_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Service Desk Setting', :js do
+RSpec.describe 'Service Desk Setting', :js, :clean_gitlab_redis_cache do
let(:project) { create(:project_empty_repo, :private, service_desk_enabled: false) }
let(:presenter) { project.present(current_user: user) }
let(:user) { create(:user) }
@@ -66,5 +66,48 @@ RSpec.describe 'Service Desk Setting', :js do
expect(find('[data-testid="incoming-email"]').value).to eq('address-suffix@example.com')
end
+
+ context 'issue description templates' do
+ let_it_be(:issuable_project_template_files) do
+ {
+ '.gitlab/issue_templates/project-issue-bar.md' => 'Project Issue Template Bar',
+ '.gitlab/issue_templates/project-issue-foo.md' => 'Project Issue Template Foo'
+ }
+ end
+
+ let_it_be(:issuable_group_template_files) do
+ {
+ '.gitlab/issue_templates/group-issue-bar.md' => 'Group Issue Template Bar',
+ '.gitlab/issue_templates/group-issue-foo.md' => 'Group Issue Template Foo'
+ }
+ end
+
+ let_it_be_with_reload(:group) { create(:group)}
+ let_it_be_with_reload(:project) { create(:project, :custom_repo, group: group, files: issuable_project_template_files) }
+ let_it_be(:group_template_repo) { create(:project, :custom_repo, group: group, files: issuable_group_template_files) }
+
+ before do
+ stub_licensed_features(custom_file_templates_for_namespace: false, custom_file_templates: false)
+ group.update_columns(file_template_project_id: group_template_repo.id)
+ end
+
+ context 'when inherited_issuable_templates enabled' do
+ before do
+ stub_feature_flags(inherited_issuable_templates: true)
+ visit edit_project_path(project)
+ end
+
+ it_behaves_like 'issue description templates from current project only'
+ end
+
+ context 'when inherited_issuable_templates disabled' do
+ before do
+ stub_feature_flags(inherited_issuable_templates: false)
+ visit edit_project_path(project)
+ end
+
+ it_behaves_like 'issue description templates from current project only'
+ end
+ end
end
end
diff --git a/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb b/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb
index e8e32d93f7b..397c334a2b8 100644
--- a/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb
+++ b/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb
@@ -133,7 +133,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do
it 'when unchecked sets :remove_source_branch_after_merge to false' do
uncheck('project_remove_source_branch_after_merge')
within('.merge-request-settings-form') do
- find('.qa-save-merge-request-changes')
+ find('.rspec-save-merge-request-changes')
click_on('Save changes')
end
@@ -157,7 +157,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do
choose('project_project_setting_attributes_squash_option_default_on')
within('.merge-request-settings-form') do
- find('.qa-save-merge-request-changes')
+ find('.rspec-save-merge-request-changes')
click_on('Save changes')
end
@@ -172,7 +172,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do
choose('project_project_setting_attributes_squash_option_always')
within('.merge-request-settings-form') do
- find('.qa-save-merge-request-changes')
+ find('.rspec-save-merge-request-changes')
click_on('Save changes')
end
@@ -187,7 +187,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do
choose('project_project_setting_attributes_squash_option_never')
within('.merge-request-settings-form') do
- find('.qa-save-merge-request-changes')
+ find('.rspec-save-merge-request-changes')
click_on('Save changes')
end
diff --git a/spec/features/projects/settings/user_manages_project_members_spec.rb b/spec/features/projects/settings/user_manages_project_members_spec.rb
index 0d22da34b91..b237e7e8ce7 100644
--- a/spec/features/projects/settings/user_manages_project_members_spec.rb
+++ b/spec/features/projects/settings/user_manages_project_members_spec.rb
@@ -19,123 +19,54 @@ RSpec.describe 'Projects > Settings > User manages project members' do
sign_in(user)
end
- context 'when `vue_project_members_list` feature flag is enabled' do
- it 'cancels a team member', :js do
- visit(project_project_members_path(project))
+ it 'cancels a team member', :js do
+ visit(project_project_members_path(project))
- page.within find_member_row(user_dmitriy) do
- click_button 'Remove member'
- end
-
- page.within('[role="dialog"]') do
- expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests'
- click_button('Remove member')
- end
-
- visit(project_project_members_path(project))
-
- expect(members_table).not_to have_content(user_dmitriy.name)
- expect(members_table).not_to have_content(user_dmitriy.username)
+ page.within find_member_row(user_dmitriy) do
+ click_button 'Remove member'
end
- it 'imports a team from another project', :js do
- stub_feature_flags(invite_members_group_modal: false)
-
- project2.add_maintainer(user)
- project2.add_reporter(user_mike)
-
- visit(project_project_members_path(project))
-
- page.within('.invite-users-form') do
- click_link('Import')
- end
-
- select2(project2.id, from: '#source_project_id')
- click_button('Import project members')
-
- expect(find_member_row(user_mike)).to have_content('Reporter')
+ page.within('[role="dialog"]') do
+ expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests'
+ click_button('Remove member')
end
- it 'shows all members of project shared group', :js do
- group.add_owner(user)
- group.add_developer(user_dmitriy)
-
- share_link = project.project_group_links.new(group_access: Gitlab::Access::MAINTAINER)
- share_link.group_id = group.id
- share_link.save!
-
- visit(project_project_members_path(project))
+ visit(project_project_members_path(project))
- click_link 'Groups'
-
- expect(find_group_row(group)).to have_content('Maintainer')
- end
+ expect(members_table).not_to have_content(user_dmitriy.name)
+ expect(members_table).not_to have_content(user_dmitriy.username)
end
- context 'when `vue_project_members_list` feature flag is disabled' do
- before do
- stub_feature_flags(vue_project_members_list: false)
- end
+ it 'imports a team from another project', :js do
+ stub_feature_flags(invite_members_group_modal: false)
- it 'cancels a team member', :js do
- visit(project_project_members_path(project))
+ project2.add_maintainer(user)
+ project2.add_reporter(user_mike)
- project_member = project.project_members.find_by(user_id: user_dmitriy.id)
+ visit(project_project_members_path(project))
- page.within("#project_member_#{project_member.id}") do
- # Open modal
- click_on('Remove user from project')
- end
-
- expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests'
-
- click_on('Remove member')
-
- visit(project_project_members_path(project))
-
- expect(page).not_to have_content(user_dmitriy.name)
- expect(page).not_to have_content(user_dmitriy.username)
+ page.within('.invite-users-form') do
+ click_link('Import')
end
- it 'imports a team from another project' do
- stub_feature_flags(invite_members_group_modal: false)
-
- project2.add_maintainer(user)
- project2.add_reporter(user_mike)
-
- visit(project_project_members_path(project))
+ select2(project2.id, from: '#source_project_id')
+ click_button('Import project members')
- page.within('.invite-users-form') do
- click_link('Import')
- end
-
- select(project2.full_name, from: 'source_project_id')
- click_button('Import')
-
- project_member = project.project_members.find_by(user_id: user_mike.id)
-
- page.within("#project_member_#{project_member.id}") do
- expect(page).to have_content('Mike')
- expect(page).to have_content('Reporter')
- end
- end
+ expect(find_member_row(user_mike)).to have_content('Reporter')
+ end
- it 'shows all members of project shared group', :js do
- group.add_owner(user)
- group.add_developer(user_dmitriy)
+ it 'shows all members of project shared group', :js do
+ group.add_owner(user)
+ group.add_developer(user_dmitriy)
- share_link = project.project_group_links.new(group_access: Gitlab::Access::MAINTAINER)
- share_link.group_id = group.id
- share_link.save!
+ share_link = project.project_group_links.new(group_access: Gitlab::Access::MAINTAINER)
+ share_link.group_id = group.id
+ share_link.save!
- visit(project_project_members_path(project))
+ visit(project_project_members_path(project))
- click_link 'Groups'
+ click_link 'Groups'
- page.within('[data-testid="project-member-groups"]') do
- expect(page).to have_content('OpenSource')
- expect(first('.group_member')).to have_content('Maintainer')
- end
- end
+ expect(find_group_row(group)).to have_content('Maintainer')
end
end
diff --git a/spec/features/projects/settings/user_searches_in_settings_spec.rb b/spec/features/projects/settings/user_searches_in_settings_spec.rb
new file mode 100644
index 00000000000..4c5b39d5282
--- /dev/null
+++ b/spec/features/projects/settings/user_searches_in_settings_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'User searches project settings', :js do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :repository, namespace: user.namespace) }
+
+ before do
+ sign_in(user)
+ end
+
+ context 'in general settings page' do
+ let(:visit_path) { edit_project_path(project) }
+
+ it_behaves_like 'can search settings with feature flag check', 'Naming', 'Visibility'
+ end
+
+ context 'in Repository page' do
+ before do
+ visit project_settings_repository_path(project)
+ end
+
+ it_behaves_like 'can search settings', 'Deploy keys', 'Mirroring repositories'
+ end
+
+ context 'in CI/CD page' do
+ before do
+ visit project_settings_ci_cd_path(project)
+ end
+
+ it_behaves_like 'can search settings', 'General pipelines', 'Auto DevOps'
+ end
+
+ context 'in Operations page' do
+ before do
+ visit project_settings_operations_path(project)
+ end
+
+ it_behaves_like 'can search settings', 'Alerts', 'Error tracking'
+ end
+end
diff --git a/spec/features/projects/show/user_manages_notifications_spec.rb b/spec/features/projects/show/user_manages_notifications_spec.rb
index 5f7d9b0963b..80dae4ec386 100644
--- a/spec/features/projects/show/user_manages_notifications_spec.rb
+++ b/spec/features/projects/show/user_manages_notifications_spec.rb
@@ -6,38 +6,36 @@ RSpec.describe 'Projects > Show > User manages notifications', :js do
let(:project) { create(:project, :public, :repository) }
before do
- stub_feature_flags(vue_notification_dropdown: false)
sign_in(project.owner)
end
def click_notifications_button
- first('.notifications-btn').click
+ first('[data-testid="notification-dropdown"]').click
end
it 'changes the notification setting' do
visit project_path(project)
click_notifications_button
- click_link 'On mention'
+ click_button 'On mention'
- page.within('.notification-dropdown') do
- expect(page).not_to have_css('.gl-spinner')
- end
+ wait_for_requests
click_notifications_button
- expect(find('.update-notification.is-active')).to have_content('On mention')
- expect(page).to have_css('.notifications-icon[data-testid="notifications-icon"]')
+
+ page.within first('[data-testid="notification-dropdown"]') do
+ expect(page.find('.gl-new-dropdown-item.is-active')).to have_content('On mention')
+ expect(page).to have_css('[data-testid="notifications-icon"]')
+ end
end
it 'changes the notification setting to disabled' do
visit project_path(project)
click_notifications_button
- click_link 'Disabled'
+ click_button 'Disabled'
- page.within('.notification-dropdown') do
- expect(page).not_to have_css('.gl-spinner')
+ page.within first('[data-testid="notification-dropdown"]') do
+ expect(page).to have_css('[data-testid="notifications-off-icon"]')
end
-
- expect(page).to have_css('.notifications-icon[data-testid="notifications-off-icon"]')
end
context 'custom notification settings' do
@@ -65,11 +63,13 @@ RSpec.describe 'Projects > Show > User manages notifications', :js do
it 'shows notification settings checkbox' do
visit project_path(project)
click_notifications_button
- page.find('a[data-notification-level="custom"]').click
+ click_button 'Custom'
+
+ wait_for_requests
- page.within('.custom-notifications-form') do
+ page.within('#custom-notifications-modal') do
email_events.each do |event_name|
- expect(page).to have_selector("input[name='notification_setting[#{event_name}]']")
+ expect(page).to have_selector("[data-testid='notification-setting-#{event_name}']")
end
end
end
@@ -80,7 +80,7 @@ RSpec.describe 'Projects > Show > User manages notifications', :js do
it 'is disabled' do
visit project_path(project)
- expect(page).to have_selector('.notifications-btn.disabled', visible: true)
+ expect(page).to have_selector('[data-testid="notification-dropdown"] .disabled', visible: true)
end
end
end
diff --git a/spec/features/projects/show/user_uploads_files_spec.rb b/spec/features/projects/show/user_uploads_files_spec.rb
index 053598a528e..2030c4d998a 100644
--- a/spec/features/projects/show/user_uploads_files_spec.rb
+++ b/spec/features/projects/show/user_uploads_files_spec.rb
@@ -33,4 +33,24 @@ RSpec.describe 'Projects > Show > User uploads files' do
include_examples 'it uploads and commit a new file to a forked project'
end
+
+ context 'when in the empty_repo_upload experiment' do
+ before do
+ stub_experiments(empty_repo_upload: :candidate)
+
+ visit(project_path(project))
+ end
+
+ context 'with an empty repo' do
+ let(:project) { create(:project, :empty_repo, creator: user) }
+
+ include_examples 'uploads and commits a new text file via "upload file" button'
+ end
+
+ context 'with a nonempty repo' do
+ let(:project) { create(:project, :repository, creator: user) }
+
+ include_examples 'uploads and commits a new text file via "upload file" button'
+ end
+ end
end
diff --git a/spec/features/projects/user_sees_sidebar_spec.rb b/spec/features/projects/user_sees_sidebar_spec.rb
index 616c5065c07..e5ba6b503cc 100644
--- a/spec/features/projects/user_sees_sidebar_spec.rb
+++ b/spec/features/projects/user_sees_sidebar_spec.rb
@@ -201,7 +201,7 @@ RSpec.describe 'Projects > User sees sidebar' do
expect(page).to have_content 'Operations'
expect(page).not_to have_content 'Repository'
- expect(page).not_to have_content 'CI / CD'
+ expect(page).not_to have_content 'CI/CD'
expect(page).not_to have_content 'Merge Requests'
end
end
@@ -213,7 +213,7 @@ RSpec.describe 'Projects > User sees sidebar' do
visit project_path(project)
within('.nav-sidebar') do
- expect(page).to have_content 'CI / CD'
+ expect(page).to have_content 'CI/CD'
end
end
diff --git a/spec/features/projects/user_uses_shortcuts_spec.rb b/spec/features/projects/user_uses_shortcuts_spec.rb
index 13ae035e8ef..f97c8d820e3 100644
--- a/spec/features/projects/user_uses_shortcuts_spec.rb
+++ b/spec/features/projects/user_uses_shortcuts_spec.rb
@@ -155,12 +155,12 @@ RSpec.describe 'User uses shortcuts', :js do
end
end
- context 'when navigating to the CI / CD pages' do
+ context 'when navigating to the CI/CD pages' do
it 'redirects to the Jobs page' do
find('body').native.send_key('g')
find('body').native.send_key('j')
- expect(page).to have_active_navigation('CI / CD')
+ expect(page).to have_active_navigation('CI/CD')
expect(page).to have_active_sub_navigation('Jobs')
end
end
diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb
index 618a256d4fb..4730679feb8 100644
--- a/spec/features/projects_spec.rb
+++ b/spec/features/projects_spec.rb
@@ -49,7 +49,7 @@ RSpec.describe 'Project' do
it 'shows the command in a popover', :js do
click_link 'Show command'
- expect(page).to have_css('.popover .push-to-create-popover #push_to_create_tip')
+ expect(page).to have_css('.popover #push-to-create-tip')
expect(page).to have_content 'Private projects can be created in your personal namespace with:'
end
end
diff --git a/spec/features/security/group/internal_access_spec.rb b/spec/features/security/group/internal_access_spec.rb
index c146ac1e8ee..755f170a93e 100644
--- a/spec/features/security/group/internal_access_spec.rb
+++ b/spec/features/security/group/internal_access_spec.rb
@@ -24,7 +24,12 @@ RSpec.describe 'Internal Group access' do
describe 'GET /groups/:path' do
subject { group_path(group) }
- it { is_expected.to be_allowed_for(:admin) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_allowed_for(:maintainer).of(group) }
it { is_expected.to be_allowed_for(:developer).of(group) }
@@ -39,7 +44,12 @@ RSpec.describe 'Internal Group access' do
describe 'GET /groups/:path/-/issues' do
subject { issues_group_path(group) }
- it { is_expected.to be_allowed_for(:admin) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_allowed_for(:maintainer).of(group) }
it { is_expected.to be_allowed_for(:developer).of(group) }
@@ -56,7 +66,12 @@ RSpec.describe 'Internal Group access' do
subject { merge_requests_group_path(group) }
- it { is_expected.to be_allowed_for(:admin) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_allowed_for(:maintainer).of(group) }
it { is_expected.to be_allowed_for(:developer).of(group) }
@@ -71,7 +86,12 @@ RSpec.describe 'Internal Group access' do
describe 'GET /groups/:path/-/group_members' do
subject { group_group_members_path(group) }
- it { is_expected.to be_allowed_for(:admin) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_allowed_for(:maintainer).of(group) }
it { is_expected.to be_allowed_for(:developer).of(group) }
@@ -86,7 +106,12 @@ RSpec.describe 'Internal Group access' do
describe 'GET /groups/:path/-/edit' do
subject { edit_group_path(group) }
- it { is_expected.to be_allowed_for(:admin) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_denied_for(:admin) }
+ end
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_denied_for(:maintainer).of(group) }
it { is_expected.to be_denied_for(:developer).of(group) }
diff --git a/spec/features/security/group/private_access_spec.rb b/spec/features/security/group/private_access_spec.rb
index de05b4d3d16..fc1fb3e3848 100644
--- a/spec/features/security/group/private_access_spec.rb
+++ b/spec/features/security/group/private_access_spec.rb
@@ -24,7 +24,12 @@ RSpec.describe 'Private Group access' do
describe 'GET /groups/:path' do
subject { group_path(group) }
- it { is_expected.to be_allowed_for(:admin) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_denied_for(:admin) }
+ end
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_allowed_for(:maintainer).of(group) }
it { is_expected.to be_allowed_for(:developer).of(group) }
@@ -39,7 +44,12 @@ RSpec.describe 'Private Group access' do
describe 'GET /groups/:path/-/issues' do
subject { issues_group_path(group) }
- it { is_expected.to be_allowed_for(:admin) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_denied_for(:admin) }
+ end
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_allowed_for(:maintainer).of(group) }
it { is_expected.to be_allowed_for(:developer).of(group) }
@@ -56,7 +66,12 @@ RSpec.describe 'Private Group access' do
subject { merge_requests_group_path(group) }
- it { is_expected.to be_allowed_for(:admin) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_denied_for(:admin) }
+ end
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_allowed_for(:maintainer).of(group) }
it { is_expected.to be_allowed_for(:developer).of(group) }
@@ -71,7 +86,12 @@ RSpec.describe 'Private Group access' do
describe 'GET /groups/:path/-/group_members' do
subject { group_group_members_path(group) }
- it { is_expected.to be_allowed_for(:admin) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_denied_for(:admin) }
+ end
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_allowed_for(:maintainer).of(group) }
it { is_expected.to be_allowed_for(:developer).of(group) }
@@ -86,7 +106,12 @@ RSpec.describe 'Private Group access' do
describe 'GET /groups/:path/-/edit' do
subject { edit_group_path(group) }
- it { is_expected.to be_allowed_for(:admin) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_denied_for(:admin) }
+ end
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_denied_for(:maintainer).of(group) }
it { is_expected.to be_denied_for(:developer).of(group) }
@@ -107,7 +132,12 @@ RSpec.describe 'Private Group access' do
subject { group_path(group) }
- it { is_expected.to be_allowed_for(:admin) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_denied_for(:admin) }
+ end
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_allowed_for(:maintainer).of(group) }
it { is_expected.to be_allowed_for(:developer).of(group) }
diff --git a/spec/features/security/group/public_access_spec.rb b/spec/features/security/group/public_access_spec.rb
index ee72b84616a..90de2b58044 100644
--- a/spec/features/security/group/public_access_spec.rb
+++ b/spec/features/security/group/public_access_spec.rb
@@ -24,7 +24,12 @@ RSpec.describe 'Public Group access' do
describe 'GET /groups/:path' do
subject { group_path(group) }
- it { is_expected.to be_allowed_for(:admin) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_allowed_for(:maintainer).of(group) }
it { is_expected.to be_allowed_for(:developer).of(group) }
@@ -39,7 +44,12 @@ RSpec.describe 'Public Group access' do
describe 'GET /groups/:path/-/issues' do
subject { issues_group_path(group) }
- it { is_expected.to be_allowed_for(:admin) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_allowed_for(:maintainer).of(group) }
it { is_expected.to be_allowed_for(:developer).of(group) }
@@ -56,7 +66,12 @@ RSpec.describe 'Public Group access' do
subject { merge_requests_group_path(group) }
- it { is_expected.to be_allowed_for(:admin) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_allowed_for(:maintainer).of(group) }
it { is_expected.to be_allowed_for(:developer).of(group) }
@@ -71,7 +86,12 @@ RSpec.describe 'Public Group access' do
describe 'GET /groups/:path/-/group_members' do
subject { group_group_members_path(group) }
- it { is_expected.to be_allowed_for(:admin) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_allowed_for(:maintainer).of(group) }
it { is_expected.to be_allowed_for(:developer).of(group) }
@@ -86,7 +106,12 @@ RSpec.describe 'Public Group access' do
describe 'GET /groups/:path/-/edit' do
subject { edit_group_path(group) }
- it { is_expected.to be_allowed_for(:admin) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed_for(:admin) }
+ end
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_denied_for(:admin) }
+ end
it { is_expected.to be_allowed_for(:owner).of(group) }
it { is_expected.to be_denied_for(:maintainer).of(group) }
it { is_expected.to be_denied_for(:developer).of(group) }
diff --git a/spec/features/sentry_js_spec.rb b/spec/features/sentry_js_spec.rb
index aa0ad17340a..1d277ba7b3c 100644
--- a/spec/features/sentry_js_spec.rb
+++ b/spec/features/sentry_js_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe 'Sentry' do
expect(has_requested_sentry).to eq(false)
end
- xit 'loads sentry if sentry is enabled' do
+ it 'loads sentry if sentry is enabled' do
stub_sentry_settings
visit new_user_session_path
diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb
index e17521e1d02..0f8daaf8e15 100644
--- a/spec/features/task_lists_spec.rb
+++ b/spec/features/task_lists_spec.rb
@@ -69,13 +69,7 @@ RSpec.describe 'Task Lists', :js do
wait_for_requests
expect(page).to have_selector(".md .task-list .task-list-item .task-list-item-checkbox")
- end
-
- it_behaves_like 'page with comment and close button', 'Close issue' do
- def setup
- visit_issue(project, issue)
- wait_for_requests
- end
+ expect(page).to have_selector('.btn-close')
end
it 'is only editable by author' do
diff --git a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
index 31e29810c65..900cd72c17f 100644
--- a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
+++ b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe 'User uploads avatar to profile' do
expect(user.reload.avatar.file).to exist
end
- it 'their new avatar is immediately visible in the header', :js do
+ it 'their new avatar is immediately visible in the header and setting sidebar', :js do
find('.js-user-avatar-input', visible: false).set(avatar_file_path)
click_button 'Set new profile picture'
@@ -33,5 +33,6 @@ RSpec.describe 'User uploads avatar to profile' do
data_uri = find('.avatar-image .avatar')['src']
expect(page.find('.header-user-avatar')['src']).to eq data_uri
+ expect(page.find('[data-testid="sidebar-user-avatar"]')['src']).to eq data_uri
end
end
diff --git a/spec/features/user_can_display_performance_bar_spec.rb b/spec/features/user_can_display_performance_bar_spec.rb
index 9c67523f88f..b8f41925156 100644
--- a/spec/features/user_can_display_performance_bar_spec.rb
+++ b/spec/features/user_can_display_performance_bar_spec.rb
@@ -49,6 +49,10 @@ RSpec.describe 'User can display performance bar', :js do
let(:group) { create(:group) }
+ before do
+ allow(GitlabPerformanceBarStatsWorker).to receive(:perform_in)
+ end
+
context 'when user is logged-out' do
before do
visit root_path
@@ -97,6 +101,26 @@ RSpec.describe 'User can display performance bar', :js do
it_behaves_like 'performance bar is enabled by default in development'
it_behaves_like 'performance bar can be displayed'
+
+ it 'does not show Stats link by default' do
+ find('body').native.send_keys('pb')
+
+ expect(page).not_to have_link('Stats', visible: :all)
+ end
+
+ context 'when GITLAB_PERFORMANCE_BAR_STATS_URL environment variable is set' do
+ let(:stats_url) { 'https://log.gprd.gitlab.net/app/dashboards#/view/' }
+
+ before do
+ stub_env('GITLAB_PERFORMANCE_BAR_STATS_URL', stats_url)
+ end
+
+ it 'shows Stats link' do
+ find('body').native.send_keys('pb')
+
+ expect(page).to have_link('Stats', href: stats_url, visible: :all)
+ end
+ end
end
end
end
diff --git a/spec/finders/admin/plans_finder_spec.rb b/spec/finders/admin/plans_finder_spec.rb
new file mode 100644
index 00000000000..9ea5944147c
--- /dev/null
+++ b/spec/finders/admin/plans_finder_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::PlansFinder do
+ let_it_be(:plan1) { create(:plan, name: 'plan1') }
+ let_it_be(:plan2) { create(:plan, name: 'plan2') }
+
+ describe '#execute' do
+ context 'with no params' do
+ it 'returns all plans' do
+ found = described_class.new.execute
+
+ expect(found).to match_array([plan1, plan2])
+ end
+ end
+
+ context 'with missing name in params' do
+ before do
+ @params = { title: 'plan2' }
+ end
+
+ it 'returns all plans' do
+ found = described_class.new(@params).execute
+
+ expect(found).to match_array([plan1, plan2])
+ end
+ end
+
+ context 'with existing name in params' do
+ before do
+ @params = { name: 'plan2' }
+ end
+
+ it 'returns the plan' do
+ found = described_class.new(@params).execute
+
+ expect(found).to match(plan2)
+ end
+ end
+
+ context 'with non-existing name in params' do
+ before do
+ @params = { name: 'non-existing-plan' }
+ end
+
+ it 'returns nil' do
+ found = described_class.new(@params).execute
+
+ expect(found).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/services/boards/list_service_spec.rb b/spec/finders/boards/boards_finder_spec.rb
index 7c94332a78d..2249c69df1b 100644
--- a/spec/services/boards/list_service_spec.rb
+++ b/spec/finders/boards/boards_finder_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Boards::ListService do
+RSpec.describe Boards::BoardsFinder do
describe '#execute' do
context 'when board parent is a project' do
let(:parent) { create(:project) }
diff --git a/spec/finders/ci/daily_build_group_report_results_finder_spec.rb b/spec/finders/ci/daily_build_group_report_results_finder_spec.rb
index 2a6e44673e3..cf15a00323b 100644
--- a/spec/finders/ci/daily_build_group_report_results_finder_spec.rb
+++ b/spec/finders/ci/daily_build_group_report_results_finder_spec.rb
@@ -5,10 +5,14 @@ require 'spec_helper'
RSpec.describe Ci::DailyBuildGroupReportResultsFinder do
describe '#execute' do
let_it_be(:project) { create(:project, :private) }
- let_it_be(:current_user) { project.owner }
+ let(:user_without_permission) { create(:user) }
+ let_it_be(:user_with_permission) { project.owner }
let_it_be(:ref_path) { 'refs/heads/master' }
let(:limit) { nil }
let_it_be(:default_branch) { false }
+ let(:start_date) { '2020-03-09' }
+ let(:end_date) { '2020-03-10' }
+ let(:sort) { true }
let_it_be(:rspec_coverage_1) { create_daily_coverage('rspec', 79.0, '2020-03-09') }
let_it_be(:karma_coverage_1) { create_daily_coverage('karma', 89.0, '2020-03-09') }
@@ -17,24 +21,35 @@ RSpec.describe Ci::DailyBuildGroupReportResultsFinder do
let_it_be(:rspec_coverage_3) { create_daily_coverage('rspec', 97.0, '2020-03-11') }
let_it_be(:karma_coverage_3) { create_daily_coverage('karma', 99.0, '2020-03-11') }
- let(:attributes) do
+ let(:finder) { described_class.new(params: params, current_user: current_user) }
+
+ let(:params) do
{
- current_user: current_user,
project: project,
+ coverage: true,
ref_path: ref_path,
- start_date: '2020-03-09',
- end_date: '2020-03-10',
- limit: limit
+ start_date: start_date,
+ end_date: end_date,
+ limit: limit,
+ sort: sort
}
end
- subject(:coverages) do
- described_class.new(**attributes).execute
- end
+ subject(:coverages) { finder.execute }
+
+ context 'when params are provided' do
+ context 'when current user is not allowed to read data' do
+ let(:current_user) { user_without_permission }
+
+ it 'returns an empty collection' do
+ expect(coverages).to be_empty
+ end
+ end
+
+ context 'when current user is allowed to read data' do
+ let(:current_user) { user_with_permission }
- context 'when ref_path is present' do
- context 'when current user is allowed to read build report results' do
- it 'returns all matching results within the given date range' do
+ it 'returns matching coverages within the given date range' do
expect(coverages).to match_array([
karma_coverage_2,
rspec_coverage_2,
@@ -43,37 +58,45 @@ RSpec.describe Ci::DailyBuildGroupReportResultsFinder do
])
end
- context 'and limit is specified' do
+ context 'when ref_path is nil' do
+ let(:default_branch) { true }
+ let(:ref_path) { nil }
+
+ it 'returns coverages for the default branch' do
+ rspec_coverage_4 = create_daily_coverage('rspec', 66.0, '2020-03-10')
+
+ expect(coverages).to contain_exactly(rspec_coverage_4)
+ end
+ end
+
+ context 'when limit is specified' do
let(:limit) { 2 }
- it 'returns limited number of matching results within the given date range' do
+ it 'returns limited number of matching coverages within the given date range' do
expect(coverages).to match_array([
karma_coverage_2,
rspec_coverage_2
])
end
end
- end
-
- context 'when current user is not allowed to read build report results' do
- let(:current_user) { create(:user) }
-
- it 'returns an empty result' do
- expect(coverages).to be_empty
- end
- end
- end
-
- context 'when ref_path query parameter is not present' do
- let(:ref_path) { nil }
- context 'when records with cover data from the default branch exist' do
- let(:default_branch) { true }
-
- it 'returns records with default_branch:true, irrespective of ref_path' do
- rspec_coverage_4 = create_daily_coverage('rspec', 66.0, '2020-03-10')
-
- expect(coverages).to contain_exactly(rspec_coverage_4)
+ context 'when provided dates are nil' do
+ let(:start_date) { nil }
+ let(:end_date) { nil }
+ let(:rspec_coverage_4) { create_daily_coverage('rspec', 98.0, 91.days.ago.to_date.to_s) }
+
+ it 'returns all coverages from the last 90 days' do
+ expect(coverages).to match_array(
+ [
+ karma_coverage_3,
+ rspec_coverage_3,
+ karma_coverage_2,
+ rspec_coverage_2,
+ karma_coverage_1,
+ rspec_coverage_1
+ ]
+ )
+ end
end
end
end
diff --git a/spec/finders/ci/testing/daily_build_group_report_results_finder_spec.rb b/spec/finders/ci/testing/daily_build_group_report_results_finder_spec.rb
deleted file mode 100644
index a703f3b800c..00000000000
--- a/spec/finders/ci/testing/daily_build_group_report_results_finder_spec.rb
+++ /dev/null
@@ -1,99 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Ci::Testing::DailyBuildGroupReportResultsFinder do
- describe '#execute' do
- let_it_be(:project) { create(:project, :private) }
- let(:user_without_permission) { create(:user) }
- let_it_be(:user_with_permission) { project.owner }
- let_it_be(:ref_path) { 'refs/heads/master' }
- let(:limit) { nil }
- let_it_be(:default_branch) { false }
- let(:start_date) { '2020-03-09' }
- let(:end_date) { '2020-03-10' }
- let(:sort) { true }
-
- let_it_be(:rspec_coverage_1) { create_daily_coverage('rspec', 79.0, '2020-03-09') }
- let_it_be(:karma_coverage_1) { create_daily_coverage('karma', 89.0, '2020-03-09') }
- let_it_be(:rspec_coverage_2) { create_daily_coverage('rspec', 95.0, '2020-03-10') }
- let_it_be(:karma_coverage_2) { create_daily_coverage('karma', 92.0, '2020-03-10') }
- let_it_be(:rspec_coverage_3) { create_daily_coverage('rspec', 97.0, '2020-03-11') }
- let_it_be(:karma_coverage_3) { create_daily_coverage('karma', 99.0, '2020-03-11') }
-
- let(:finder) { described_class.new(params: params, current_user: current_user) }
-
- let(:params) do
- {
- project: project,
- coverage: true,
- ref_path: ref_path,
- start_date: start_date,
- end_date: end_date,
- limit: limit,
- sort: sort
- }
- end
-
- subject(:coverages) { finder.execute }
-
- context 'when params are provided' do
- context 'when current user is not allowed to read data' do
- let(:current_user) { user_without_permission }
-
- it 'returns an empty collection' do
- expect(coverages).to be_empty
- end
- end
-
- context 'when current user is allowed to read data' do
- let(:current_user) { user_with_permission }
-
- it 'returns matching coverages within the given date range' do
- expect(coverages).to match_array([
- karma_coverage_2,
- rspec_coverage_2,
- karma_coverage_1,
- rspec_coverage_1
- ])
- end
-
- context 'when ref_path is nil' do
- let(:default_branch) { true }
- let(:ref_path) { nil }
-
- it 'returns coverages for the default branch' do
- rspec_coverage_4 = create_daily_coverage('rspec', 66.0, '2020-03-10')
-
- expect(coverages).to contain_exactly(rspec_coverage_4)
- end
- end
-
- context 'when limit is specified' do
- let(:limit) { 2 }
-
- it 'returns limited number of matching coverages within the given date range' do
- expect(coverages).to match_array([
- karma_coverage_2,
- rspec_coverage_2
- ])
- end
- end
- end
- end
- end
-
- private
-
- def create_daily_coverage(group_name, coverage, date)
- create(
- :ci_daily_build_group_report_result,
- project: project,
- ref_path: ref_path || 'feature-branch',
- group_name: group_name,
- data: { 'coverage' => coverage },
- date: date,
- default_branch: default_branch
- )
- end
-end
diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb
index 33b8a5954ae..b794ab626bf 100644
--- a/spec/finders/issues_finder_spec.rb
+++ b/spec/finders/issues_finder_spec.rb
@@ -179,33 +179,54 @@ RSpec.describe IssuesFinder do
end
end
- context 'filtering by author ID' do
- let(:params) { { author_id: user2.id } }
+ context 'filtering by author' do
+ context 'by author ID' do
+ let(:params) { { author_id: user2.id } }
- it 'returns issues created by that user' do
- expect(issues).to contain_exactly(issue3)
+ it 'returns issues created by that user' do
+ expect(issues).to contain_exactly(issue3)
+ end
end
- end
- context 'filtering by not author ID' do
- let(:params) { { not: { author_id: user2.id } } }
+ context 'using OR' do
+ let(:issue6) { create(:issue, project: project2) }
+ let(:params) { { or: { author_username: [issue3.author.username, issue6.author.username] } } }
+
+ it 'returns issues created by any of the given users' do
+ expect(issues).to contain_exactly(issue3, issue6)
+ end
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(or_issuable_queries: false)
+ end
- it 'returns issues not created by that user' do
- expect(issues).to contain_exactly(issue1, issue2, issue4, issue5)
+ it 'does not add any filter' do
+ expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5, issue6)
+ end
+ end
end
- end
- context 'filtering by nonexistent author ID and issue term using CTE for search' do
- let(:params) do
- {
- author_id: 'does-not-exist',
- search: 'git',
- attempt_group_search_optimizations: true
- }
+ context 'filtering by NOT author ID' do
+ let(:params) { { not: { author_id: user2.id } } }
+
+ it 'returns issues not created by that user' do
+ expect(issues).to contain_exactly(issue1, issue2, issue4, issue5)
+ end
end
- it 'returns no results' do
- expect(issues).to be_empty
+ context 'filtering by nonexistent author ID and issue term using CTE for search' do
+ let(:params) do
+ {
+ author_id: 'does-not-exist',
+ search: 'git',
+ attempt_group_search_optimizations: true
+ }
+ end
+
+ it 'returns no results' do
+ expect(issues).to be_empty
+ end
end
end
diff --git a/spec/finders/merge_request_target_project_finder_spec.rb b/spec/finders/merge_request_target_project_finder_spec.rb
index dfb4d86fbb6..08fbfd7229a 100644
--- a/spec/finders/merge_request_target_project_finder_spec.rb
+++ b/spec/finders/merge_request_target_project_finder_spec.rb
@@ -16,13 +16,22 @@ RSpec.describe MergeRequestTargetProjectFinder do
expect(finder.execute).to contain_exactly(base_project, other_fork, forked_project)
end
- it 'does not include projects that have merge requests turned off' do
+ it 'does not include projects that have merge requests turned off by default' do
other_fork.project_feature.update!(merge_requests_access_level: ProjectFeature::DISABLED)
base_project.project_feature.update!(merge_requests_access_level: ProjectFeature::DISABLED)
expect(finder.execute).to contain_exactly(forked_project)
end
+ it 'includes projects that have merge requests turned off by default with a more-permissive project feature' do
+ finder = described_class.new(current_user: user, source_project: forked_project, project_feature: :repository)
+
+ other_fork.project_feature.update!(merge_requests_access_level: ProjectFeature::DISABLED)
+ base_project.project_feature.update!(merge_requests_access_level: ProjectFeature::DISABLED)
+
+ expect(finder.execute).to contain_exactly(base_project, other_fork, forked_project)
+ end
+
it 'does not contain archived projects' do
base_project.update!(archived: true)
diff --git a/spec/finders/merge_requests/oldest_per_commit_finder_spec.rb b/spec/finders/merge_requests/oldest_per_commit_finder_spec.rb
index 4e9d021fa5d..4724a8eb5c7 100644
--- a/spec/finders/merge_requests/oldest_per_commit_finder_spec.rb
+++ b/spec/finders/merge_requests/oldest_per_commit_finder_spec.rb
@@ -6,12 +6,20 @@ RSpec.describe MergeRequests::OldestPerCommitFinder do
describe '#execute' do
it 'returns a Hash mapping commit SHAs to their oldest merge requests' do
project = create(:project)
+ sha1 = Digest::SHA1.hexdigest('foo')
+ sha2 = Digest::SHA1.hexdigest('bar')
+ sha3 = Digest::SHA1.hexdigest('baz')
mr1 = create(:merge_request, :merged, target_project: project)
mr2 = create(:merge_request, :merged, target_project: project)
+ mr3 = create(
+ :merge_request,
+ :merged,
+ target_project: project,
+ merge_commit_sha: sha3
+ )
+
mr1_diff = create(:merge_request_diff, merge_request: mr1)
mr2_diff = create(:merge_request_diff, merge_request: mr2)
- sha1 = Digest::SHA1.hexdigest('foo')
- sha2 = Digest::SHA1.hexdigest('bar')
create(:merge_request_diff_commit, merge_request_diff: mr1_diff, sha: sha1)
create(:merge_request_diff_commit, merge_request_diff: mr2_diff, sha: sha1)
@@ -22,11 +30,16 @@ RSpec.describe MergeRequests::OldestPerCommitFinder do
relative_order: 1
)
- commits = [double(:commit, id: sha1), double(:commit, id: sha2)]
+ commits = [
+ double(:commit, id: sha1),
+ double(:commit, id: sha2),
+ double(:commit, id: sha3)
+ ]
expect(described_class.new(project).execute(commits)).to eq(
sha1 => mr1,
- sha2 => mr2
+ sha2 => mr2,
+ sha3 => mr3
)
end
@@ -42,5 +55,45 @@ RSpec.describe MergeRequests::OldestPerCommitFinder do
expect(described_class.new(mr.target_project).execute(commits))
.to be_empty
end
+
+ it 'includes the merge request for a merge commit' do
+ project = create(:project)
+ sha = Digest::SHA1.hexdigest('foo')
+ mr = create(
+ :merge_request,
+ :merged,
+ target_project: project,
+ merge_commit_sha: sha
+ )
+
+ commits = [double(:commit, id: sha)]
+
+ # This expectation is set so we're certain that the merge commit SHAs (if
+ # a matching merge request is found) aren't also used for finding MRs
+ # according to diffs.
+ expect(MergeRequestDiffCommit)
+ .not_to receive(:oldest_merge_request_id_per_commit)
+
+ expect(described_class.new(project).execute(commits)).to eq(sha => mr)
+ end
+
+ it 'includes the oldest merge request when a merge commit is present in a newer merge request' do
+ project = create(:project)
+ sha = Digest::SHA1.hexdigest('foo')
+ mr1 = create(
+ :merge_request,
+ :merged,
+ target_project: project, merge_commit_sha: sha
+ )
+
+ mr2 = create(:merge_request, :merged, target_project: project)
+ mr_diff = create(:merge_request_diff, merge_request: mr2)
+
+ create(:merge_request_diff_commit, merge_request_diff: mr_diff, sha: sha)
+
+ commits = [double(:commit, id: sha)]
+
+ expect(described_class.new(project).execute(commits)).to eq(sha => mr1)
+ end
end
end
diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb
index 6fdfe780463..b3000498bb6 100644
--- a/spec/finders/merge_requests_finder_spec.rb
+++ b/spec/finders/merge_requests_finder_spec.rb
@@ -41,30 +41,51 @@ RSpec.describe MergeRequestsFinder do
expect(merge_requests).to contain_exactly(merge_request1)
end
- it 'filters by nonexistent author ID and MR term using CTE for search' do
- params = {
- author_id: 'does-not-exist',
- search: 'git',
- attempt_group_search_optimizations: true
- }
+ context 'filtering by author' do
+ subject(:merge_requests) { described_class.new(user, params).execute }
- merge_requests = described_class.new(user, params).execute
+ context 'using OR' do
+ let(:params) { { or: { author_username: [merge_request1.author.username, merge_request2.author.username] } } }
- expect(merge_requests).to be_empty
- end
+ before do
+ merge_request1.update!(author: create(:user))
+ merge_request2.update!(author: create(:user))
+ end
+
+ it 'returns merge requests created by any of the given users' do
+ expect(merge_requests).to contain_exactly(merge_request1, merge_request2)
+ end
- context 'filtering by not author ID' do
- let(:params) { { not: { author_id: user2.id } } }
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(or_issuable_queries: false)
+ end
- before do
- merge_request2.update!(author: user2)
- merge_request3.update!(author: user2)
+ it 'does not add any filter' do
+ expect(merge_requests).to contain_exactly(merge_request1, merge_request2, merge_request3, merge_request4, merge_request5)
+ end
+ end
end
- it 'returns merge requests not created by that user' do
- merge_requests = described_class.new(user, params).execute
+ context 'with nonexistent author ID and MR term using CTE for search' do
+ let(:params) { { author_id: 'does-not-exist', search: 'git', attempt_group_search_optimizations: true } }
+
+ it 'returns no results' do
+ expect(merge_requests).to be_empty
+ end
+ end
- expect(merge_requests).to contain_exactly(merge_request1, merge_request4, merge_request5)
+ context 'filtering by not author ID' do
+ let(:params) { { not: { author_id: user2.id } } }
+
+ before do
+ merge_request2.update!(author: user2)
+ merge_request3.update!(author: user2)
+ end
+
+ it 'returns merge requests not created by that user' do
+ expect(merge_requests).to contain_exactly(merge_request1, merge_request4, merge_request5)
+ end
end
end
diff --git a/spec/finders/namespaces/projects_finder_spec.rb b/spec/finders/namespaces/projects_finder_spec.rb
new file mode 100644
index 00000000000..0f48aa6a9f4
--- /dev/null
+++ b/spec/finders/namespaces/projects_finder_spec.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Namespaces::ProjectsFinder do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:namespace) { create(:group, :public) }
+ let_it_be(:subgroup) { create(:group, parent: namespace) }
+ let_it_be(:project_1) { create(:project, :public, group: namespace, path: 'project', name: 'Project') }
+ let_it_be(:project_2) { create(:project, :public, group: namespace, path: 'test-project', name: 'Test Project') }
+ let_it_be(:project_3) { create(:project, :public, path: 'sub-test-project', group: subgroup, name: 'Sub Test Project') }
+ let_it_be(:project_4) { create(:project, :public, path: 'test-project-2', group: namespace, name: 'Test Project 2') }
+
+ let(:params) { {} }
+
+ let(:finder) { described_class.new(namespace: namespace, params: params, current_user: current_user) }
+
+ subject(:projects) { finder.execute }
+
+ describe '#execute' do
+ context 'without a namespace' do
+ let(:namespace) { nil }
+
+ it 'returns an empty array' do
+ expect(projects).to be_empty
+ end
+ end
+
+ context 'with a namespace' do
+ it 'returns the project for the namespace' do
+ expect(projects).to contain_exactly(project_1, project_2, project_4)
+ end
+
+ context 'when include_subgroups is provided' do
+ let(:params) { { include_subgroups: true } }
+
+ it 'returns all projects for the namespace' do
+ expect(projects).to contain_exactly(project_1, project_2, project_3, project_4)
+ end
+
+ context 'when ids are provided' do
+ let(:params) { { include_subgroups: true, ids: [project_3.id] } }
+
+ it 'returns all projects for the ids' do
+ expect(projects).to contain_exactly(project_3)
+ end
+ end
+ end
+
+ context 'when ids are provided' do
+ let(:params) { { ids: [project_1.id] } }
+
+ it 'returns all projects for the ids' do
+ expect(projects).to contain_exactly(project_1)
+ end
+ end
+
+ context 'when sort is similarity' do
+ let(:params) { { sort: :similarity, search: 'test' } }
+
+ it 'returns projects by similarity' do
+ expect(projects).to contain_exactly(project_2, project_4)
+ end
+ end
+
+ context 'when search parameter is missing' do
+ let(:params) { { sort: :similarity } }
+
+ it 'returns all projects' do
+ expect(projects).to contain_exactly(project_1, project_2, project_4)
+ end
+ end
+
+ context 'when sort parameter is missing' do
+ let(:params) { { search: 'test' } }
+
+ it 'returns matching projects' do
+ expect(projects).to contain_exactly(project_2, project_4)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/finders/packages/group_packages_finder_spec.rb b/spec/finders/packages/group_packages_finder_spec.rb
index 445482a5a96..d6daf73aba2 100644
--- a/spec/finders/packages/group_packages_finder_spec.rb
+++ b/spec/finders/packages/group_packages_finder_spec.rb
@@ -122,7 +122,7 @@ RSpec.describe Packages::GroupPackagesFinder do
end
context 'when there are processing packages' do
- let_it_be(:package4) { create(:nuget_package, project: project, name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) }
+ let_it_be(:package4) { create(:nuget_package, project: project, name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) }
it { is_expected.to match_array([package1, package2]) }
end
diff --git a/spec/finders/packages/npm/package_finder_spec.rb b/spec/finders/packages/npm/package_finder_spec.rb
index 78c23971f92..f021d800f31 100644
--- a/spec/finders/packages/npm/package_finder_spec.rb
+++ b/spec/finders/packages/npm/package_finder_spec.rb
@@ -2,39 +2,139 @@
require 'spec_helper'
RSpec.describe ::Packages::Npm::PackageFinder do
- let(:package) { create(:npm_package) }
+ let_it_be_with_reload(:project) { create(:project)}
+ let_it_be(:package) { create(:npm_package, project: project) }
+
let(:project) { package.project }
let(:package_name) { package.name }
- describe '#execute!' do
- subject { described_class.new(project, package_name).execute }
+ shared_examples 'accepting a namespace for' do |example_name|
+ before do
+ project.update!(namespace: namespace)
+ end
+
+ context 'that is a group' do
+ let_it_be(:namespace) { create(:group) }
+
+ it_behaves_like example_name
+
+ context 'within another group' do
+ let_it_be(:subgroup) { create(:group, parent: namespace) }
+
+ before do
+ project.update!(namespace: subgroup)
+ end
+
+ it_behaves_like example_name
+ end
+ end
+
+ context 'that is a user namespace' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:namespace) { user.namespace }
+
+ it_behaves_like example_name
+ end
+ end
+
+ describe '#execute' do
+ shared_examples 'finding packages by name' do
+ it { is_expected.to eq([package]) }
+
+ context 'with unknown package name' do
+ let(:package_name) { 'baz' }
+
+ it { is_expected.to be_empty }
+ end
+ end
+
+ subject { finder.execute }
+
+ context 'with a project' do
+ let(:finder) { described_class.new(package_name, project: project) }
- it { is_expected.to eq([package]) }
+ it_behaves_like 'finding packages by name'
- context 'with unknown package name' do
- let(:package_name) { 'baz' }
+ context 'set to nil' do
+ let(:project) { nil }
- it { is_expected.to be_empty }
+ it { is_expected.to be_empty }
+ end
end
- context 'with nil project' do
- let(:project) { nil }
+ context 'with a namespace' do
+ let(:finder) { described_class.new(package_name, namespace: namespace) }
+
+ it_behaves_like 'accepting a namespace for', 'finding packages by name'
+
+ context 'set to nil' do
+ let_it_be(:namespace) { nil }
- it { is_expected.to be_empty }
+ it { is_expected.to be_empty }
+ end
end
end
describe '#find_by_version' do
let(:version) { package.version }
- subject { described_class.new(project, package.name).find_by_version(version) }
+ subject { finder.find_by_version(version) }
+
+ shared_examples 'finding packages by version' do
+ it { is_expected.to eq(package) }
+
+ context 'with unknown version' do
+ let(:version) { 'foobar' }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ context 'with a project' do
+ let(:finder) { described_class.new(package_name, project: project) }
+
+ it_behaves_like 'finding packages by version'
+ end
+
+ context 'with a namespace' do
+ let(:finder) { described_class.new(package_name, namespace: namespace) }
+
+ it_behaves_like 'accepting a namespace for', 'finding packages by version'
+ end
+ end
+
+ describe '#last' do
+ subject { finder.last }
+
+ shared_examples 'finding package by last' do
+ it { is_expected.to eq(package) }
+ end
+
+ context 'with a project' do
+ let(:finder) { described_class.new(package_name, project: project) }
+
+ it_behaves_like 'finding package by last'
+ end
+
+ context 'with a namespace' do
+ let(:finder) { described_class.new(package_name, namespace: namespace) }
+
+ it_behaves_like 'accepting a namespace for', 'finding package by last'
- it { is_expected.to eq(package) }
+ context 'with duplicate packages' do
+ let_it_be(:namespace) { create(:group) }
+ let_it_be(:subgroup1) { create(:group, parent: namespace) }
+ let_it_be(:subgroup2) { create(:group, parent: namespace) }
+ let_it_be(:project2) { create(:project, namespace: subgroup2) }
+ let_it_be(:package2) { create(:npm_package, name: package.name, project: project2) }
- context 'with unknown version' do
- let(:version) { 'foobar' }
+ before do
+ project.update!(namespace: subgroup1)
+ end
- it { is_expected.to be_nil }
+ # the most recent one is returned
+ it { is_expected.to eq(package2) }
+ end
end
end
end
diff --git a/spec/finders/packages/package_finder_spec.rb b/spec/finders/packages/package_finder_spec.rb
index ef07e7575d1..e8c7404a612 100644
--- a/spec/finders/packages/package_finder_spec.rb
+++ b/spec/finders/packages/package_finder_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe ::Packages::PackageFinder do
it { is_expected.to eq(maven_package) }
context 'processing packages' do
- let_it_be(:nuget_package) { create(:nuget_package, project: project, name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) }
+ let_it_be(:nuget_package) { create(:nuget_package, project: project, name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) }
let(:package_id) { nuget_package.id }
it 'are not returned' do
diff --git a/spec/finders/packages/packages_finder_spec.rb b/spec/finders/packages/packages_finder_spec.rb
index 6e92616bafa..0add77a8478 100644
--- a/spec/finders/packages/packages_finder_spec.rb
+++ b/spec/finders/packages/packages_finder_spec.rb
@@ -76,7 +76,7 @@ RSpec.describe ::Packages::PackagesFinder do
end
context 'with processing packages' do
- let_it_be(:nuget_package) { create(:nuget_package, project: project, name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) }
+ let_it_be(:nuget_package) { create(:nuget_package, project: project, name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) }
it { is_expected.to match_array([conan_package, maven_package]) }
end
diff --git a/spec/finders/projects/groups_finder_spec.rb b/spec/finders/projects/groups_finder_spec.rb
new file mode 100644
index 00000000000..89d4edaec7c
--- /dev/null
+++ b/spec/finders/projects/groups_finder_spec.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::GroupsFinder do
+ describe '#execute' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:root_group) { create(:group, :public) }
+ let_it_be(:project_group) { create(:group, :public, parent: root_group) }
+ let_it_be(:shared_group_with_dev_access) { create(:group, :private, parent: root_group) }
+ let_it_be(:shared_group_with_reporter_access) { create(:group, :private) }
+
+ let_it_be(:public_project) { create(:project, :public, group: project_group) }
+ let_it_be(:private_project) { create(:project, :private, group: project_group) }
+
+ before_all do
+ [public_project, private_project].each do |project|
+ create(:project_group_link, :developer, group: shared_group_with_dev_access, project: project)
+ create(:project_group_link, :reporter, group: shared_group_with_reporter_access, project: project)
+ end
+ end
+
+ let(:params) { {} }
+ let(:current_user) { user }
+ let(:finder) { described_class.new(project: project, current_user: current_user, params: params) }
+
+ subject { finder.execute }
+
+ shared_examples 'finding related groups' do
+ it 'returns ancestor groups for this project' do
+ is_expected.to match_array([project_group, root_group])
+ end
+
+ context 'when the project does not belong to any group' do
+ before do
+ allow(project).to receive(:group) { nil }
+ end
+
+ it { is_expected.to eq([]) }
+ end
+
+ context 'when shared groups option is on' do
+ let(:params) { { with_shared: true } }
+
+ it 'returns ancestor and all shared groups' do
+ is_expected.to match_array([project_group, root_group, shared_group_with_dev_access, shared_group_with_reporter_access])
+ end
+
+ context 'when shared_min_access_level is developer' do
+ let(:params) { super().merge(shared_min_access_level: Gitlab::Access::DEVELOPER) }
+
+ it 'returns ancestor and shared groups with at least developer access' do
+ is_expected.to match_array([project_group, root_group, shared_group_with_dev_access])
+ end
+ end
+ end
+
+ context 'when skip group option is on' do
+ let(:params) { { skip_groups: [project_group.id] } }
+
+ it 'excludes provided groups' do
+ is_expected.to match_array([root_group])
+ end
+ end
+ end
+
+ context 'Public project' do
+ it_behaves_like 'finding related groups' do
+ let(:project) { public_project }
+
+ context 'when user is not authorized' do
+ let(:current_user) { nil }
+
+ it 'returns ancestor groups for this project' do
+ is_expected.to match_array([project_group, root_group])
+ end
+ end
+ end
+ end
+
+ context 'Private project' do
+ it_behaves_like 'finding related groups' do
+ let(:project) { private_project }
+
+ before do
+ project.add_developer(user)
+ end
+
+ context 'when user is not authorized' do
+ let(:current_user) { nil }
+
+ it { is_expected.to eq([]) }
+ end
+ end
+ end
+
+ context 'Missing project' do
+ let(:project) { nil }
+
+ it { is_expected.to eq([]) }
+ end
+ end
+end
diff --git a/spec/finders/repositories/changelog_commits_finder_spec.rb b/spec/finders/repositories/changelog_commits_finder_spec.rb
new file mode 100644
index 00000000000..8665d36144a
--- /dev/null
+++ b/spec/finders/repositories/changelog_commits_finder_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Repositories::ChangelogCommitsFinder do
+ let_it_be(:project) { create(:project, :repository) }
+
+ describe '#each_page' do
+ it 'only yields commits with the given trailer' do
+ finder = described_class.new(
+ project: project,
+ from: '570e7b2abdd848b95f2f578043fc23bd6f6fd24d',
+ to: 'c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd'
+ )
+
+ commits = finder.each_page('Signed-off-by').to_a.flatten
+
+ expect(commits.length).to eq(1)
+ expect(commits.first.id).to eq('5937ac0a7beb003549fc5fd26fc247adbce4a52e')
+ expect(commits.first.trailers).to eq(
+ 'Signed-off-by' => 'Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>'
+ )
+ end
+
+ it 'ignores commits that are reverted' do
+ # This range of commits is found on the branch
+ # https://gitlab.com/gitlab-org/gitlab-test/-/commits/trailers.
+ finder = described_class.new(
+ project: project,
+ from: 'ddd0f15ae83993f5cb66a927a28673882e99100b',
+ to: '694e6c2f08cad00d183682d9dede99615998a630'
+ )
+
+ commits = finder.each_page('Changelog').to_a.flatten
+
+ expect(commits).to be_empty
+ end
+
+ it 'includes revert commits if they have a trailer' do
+ finder = described_class.new(
+ project: project,
+ from: 'ddd0f15ae83993f5cb66a927a28673882e99100b',
+ to: 'f0a5ed60d24c98ec6d00ac010c1f3f01ee0a8373'
+ )
+
+ initial_commit = project.commit('ed2e92bf50b3da2c7cbbab053f4977a4ecbd109a')
+ revert_commit = project.commit('f0a5ed60d24c98ec6d00ac010c1f3f01ee0a8373')
+
+ commits = finder.each_page('Changelog').to_a.flatten
+
+ expect(commits).to eq([revert_commit, initial_commit])
+ end
+
+ it 'supports paginating of commits' do
+ finder = described_class.new(
+ project: project,
+ from: 'c1acaa58bbcbc3eafe538cb8274ba387047b69f8',
+ to: '5937ac0a7beb003549fc5fd26fc247adbce4a52e',
+ per_page: 1
+ )
+
+ commits = finder.each_page('Signed-off-by')
+
+ expect(commits.count).to eq(4)
+ end
+ end
+
+ describe '#revert_commit_sha' do
+ let(:finder) { described_class.new(project: project, from: 'a', to: 'b') }
+
+ it 'returns the SHA of a reverted commit' do
+ commit = double(
+ :commit,
+ description: 'This reverts commit 152c03af1b09f50fa4b567501032b106a3a81ff3.'
+ )
+
+ expect(finder.send(:revert_commit_sha, commit))
+ .to eq('152c03af1b09f50fa4b567501032b106a3a81ff3')
+ end
+
+ it 'returns nil when the commit is not a revert commit' do
+ commit = double(:commit, description: 'foo')
+
+ expect(finder.send(:revert_commit_sha, commit)).to be_nil
+ end
+
+ it 'returns nil when the commit has no description' do
+ commit = double(:commit, description: nil)
+
+ expect(finder.send(:revert_commit_sha, commit)).to be_nil
+ end
+ end
+end
diff --git a/spec/finders/repositories/commits_with_trailer_finder_spec.rb b/spec/finders/repositories/commits_with_trailer_finder_spec.rb
deleted file mode 100644
index 0c457aae340..00000000000
--- a/spec/finders/repositories/commits_with_trailer_finder_spec.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Repositories::CommitsWithTrailerFinder do
- let(:project) { create(:project, :repository) }
-
- describe '#each_page' do
- it 'only yields commits with the given trailer' do
- finder = described_class.new(
- project: project,
- from: '570e7b2abdd848b95f2f578043fc23bd6f6fd24d',
- to: 'c7fbe50c7c7419d9701eebe64b1fdacc3df5b9dd'
- )
-
- commits = finder.each_page('Signed-off-by').to_a.flatten
-
- expect(commits.length).to eq(1)
- expect(commits.first.id).to eq('5937ac0a7beb003549fc5fd26fc247adbce4a52e')
- expect(commits.first.trailers).to eq(
- 'Signed-off-by' => 'Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>'
- )
- end
-
- it 'supports paginating of commits' do
- finder = described_class.new(
- project: project,
- from: 'c1acaa58bbcbc3eafe538cb8274ba387047b69f8',
- to: '5937ac0a7beb003549fc5fd26fc247adbce4a52e',
- per_page: 1
- )
-
- commits = finder.each_page('Signed-off-by')
-
- expect(commits.count).to eq(4)
- end
- end
-end
diff --git a/spec/finders/repositories/previous_tag_finder_spec.rb b/spec/finders/repositories/previous_tag_finder_spec.rb
index 7cc33d11baf..b332dd158d1 100644
--- a/spec/finders/repositories/previous_tag_finder_spec.rb
+++ b/spec/finders/repositories/previous_tag_finder_spec.rb
@@ -12,16 +12,20 @@ RSpec.describe Repositories::PreviousTagFinder do
tag1 = double(:tag1, name: 'v1.0.0')
tag2 = double(:tag2, name: 'v1.1.0')
tag3 = double(:tag3, name: 'v2.0.0')
- tag4 = double(:tag4, name: '1.0.0')
+ tag4 = double(:tag4, name: '0.9.0')
+ tag5 = double(:tag5, name: 'v0.8.0-pre1')
+ tag6 = double(:tag6, name: 'v0.7.0')
allow(project.repository)
.to receive(:tags)
- .and_return([tag1, tag3, tag2, tag4])
+ .and_return([tag1, tag3, tag2, tag4, tag5, tag6])
expect(finder.execute('2.1.0')).to eq(tag3)
expect(finder.execute('2.0.0')).to eq(tag2)
expect(finder.execute('1.5.0')).to eq(tag2)
expect(finder.execute('1.0.1')).to eq(tag1)
+ expect(finder.execute('1.0.0')).to eq(tag4)
+ expect(finder.execute('0.9.0')).to eq(tag6)
end
end
diff --git a/spec/finders/security/license_compliance_jobs_finder_spec.rb b/spec/finders/security/license_compliance_jobs_finder_spec.rb
index 3066912df12..de4a7eb2c12 100644
--- a/spec/finders/security/license_compliance_jobs_finder_spec.rb
+++ b/spec/finders/security/license_compliance_jobs_finder_spec.rb
@@ -15,10 +15,9 @@ RSpec.describe Security::LicenseComplianceJobsFinder do
let!(:container_scanning_build) { create(:ci_build, :container_scanning, pipeline: pipeline) }
let!(:dast_build) { create(:ci_build, :dast, pipeline: pipeline) }
let!(:license_scanning_build) { create(:ci_build, :license_scanning, pipeline: pipeline) }
- let!(:license_management_build) { create(:ci_build, :license_management, pipeline: pipeline) }
- it 'returns only the license_scanning jobs' do
- is_expected.to contain_exactly(license_scanning_build, license_management_build)
+ it 'returns only the license_scanning job' do
+ is_expected.to contain_exactly(license_scanning_build)
end
end
end
diff --git a/spec/finders/users_finder_spec.rb b/spec/finders/users_finder_spec.rb
index d9cc71106d5..b0f8b803141 100644
--- a/spec/finders/users_finder_spec.rb
+++ b/spec/finders/users_finder_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe UsersFinder do
it 'returns all users' do
users = described_class.new(user).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, blocked_user, external_user, omniauth_user, internal_user, admin_user)
end
it 'filters by username' do
@@ -48,12 +48,18 @@ RSpec.describe UsersFinder do
it 'filters by active users' do
users = described_class.new(user, active: true).execute
- expect(users).to contain_exactly(user, normal_user, omniauth_user, admin_user)
+ expect(users).to contain_exactly(user, normal_user, external_user, omniauth_user, admin_user)
end
- it 'returns no external users' do
+ it 'filters by external users' do
users = described_class.new(user, external: true).execute
+ expect(users).to contain_exactly(external_user)
+ end
+
+ 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)
end
@@ -71,7 +77,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, blocked_user, omniauth_user, admin_user)
+ expect(users).to contain_exactly(user, normal_user, external_user, blocked_user, omniauth_user, admin_user)
end
it 'does not filter by custom attributes' do
@@ -80,18 +86,18 @@ RSpec.describe UsersFinder do
custom_attributes: { foo: 'bar' }
).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, blocked_user, external_user, omniauth_user, internal_user, admin_user)
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, omniauth_user, internal_user, user])
+ expect(users).to eq([normal_user, admin_user, blocked_user, external_user, omniauth_user, internal_user, 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, admin_user, blocked_user, omniauth_user, internal_user)
+ expect(users).to contain_exactly(user, normal_user, external_user, admin_user, blocked_user, omniauth_user, internal_user)
end
end
diff --git a/spec/fixtures/api/schemas/entities/test_suite_comparer.json b/spec/fixtures/api/schemas/entities/test_suite_comparer.json
index ecb331ae013..ac001ef8843 100644
--- a/spec/fixtures/api/schemas/entities/test_suite_comparer.json
+++ b/spec/fixtures/api/schemas/entities/test_suite_comparer.json
@@ -26,7 +26,14 @@
"existing_failures": { "type": "array", "items": { "$ref": "test_case.json" } },
"new_errors": { "type": "array", "items": { "$ref": "test_case.json" } },
"resolved_errors": { "type": "array", "items": { "$ref": "test_case.json" } },
- "existing_errors": { "type": "array", "items": { "$ref": "test_case.json" } }
+ "existing_errors": { "type": "array", "items": { "$ref": "test_case.json" } },
+ "suite_errors": {
+ "type": ["object", "null"],
+ "properties": {
+ "head": { "type": ["string", "null"] },
+ "base": { "type": ["string", "null"] }
+ }
+ }
},
"additionalProperties": false
}
diff --git a/spec/fixtures/api/schemas/public_api/v4/packages/composer/index.json b/spec/fixtures/api/schemas/public_api/v4/packages/composer/index.json
index 2245b39cabe..81a222b922d 100644
--- a/spec/fixtures/api/schemas/public_api/v4/packages/composer/index.json
+++ b/spec/fixtures/api/schemas/public_api/v4/packages/composer/index.json
@@ -1,6 +1,6 @@
{
"type": "object",
- "required": ["packages", "provider-includes", "providers-url"],
+ "required": ["packages", "provider-includes", "providers-url", "metadata-url"],
"properties": {
"packages": {
"type": "array",
@@ -9,6 +9,9 @@
"providers-url": {
"type": "string"
},
+ "metadata-url": {
+ "type": "string"
+ },
"provider-includes": {
"type": "object",
"required": ["p/%hash%.json"],
diff --git a/spec/fixtures/api/schemas/public_api/v4/pipeline.json b/spec/fixtures/api/schemas/public_api/v4/pipeline.json
index f83844a115d..7e553f9e5de 100644
--- a/spec/fixtures/api/schemas/public_api/v4/pipeline.json
+++ b/spec/fixtures/api/schemas/public_api/v4/pipeline.json
@@ -1,10 +1,13 @@
{
"type": "object",
- "required": ["id", "sha", "ref", "status", "created_at", "updated_at", "web_url"],
+ "required": ["id", "project_id", "sha", "ref", "status", "created_at", "updated_at", "web_url"],
"properties": {
"id": {
"type": "integer"
},
+ "project_id": {
+ "type": "integer"
+ },
"sha": {
"type": "string"
},
diff --git a/spec/fixtures/dependency_proxy/manifest b/spec/fixtures/dependency_proxy/manifest
index a899d05d697..ed543883d60 100644
--- a/spec/fixtures/dependency_proxy/manifest
+++ b/spec/fixtures/dependency_proxy/manifest
@@ -1,38 +1,16 @@
{
- "schemaVersion": 1,
- "name": "library/alpine",
- "tag": "latest",
- "architecture": "amd64",
- "fsLayers": [
+ "schemaVersion": 2,
+ "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+ "config": {
+ "mediaType": "application/vnd.docker.container.image.v1+json",
+ "size": 1472,
+ "digest": "sha256:7731472c3f2a25edbb9c085c78f42ec71259f2b83485aa60648276d408865839"
+ },
+ "layers": [
{
- "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
- },
- {
- "blobSum": "sha256:188c0c94c7c576fff0792aca7ec73d67a2f7f4cb3a6e53a84559337260b36964"
- }
- ],
- "history": [
- {
- "v1Compatibility": "{\"architecture\":\"amd64\",\"config\":{\"Hostname\":\"\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"/bin/sh\"],\"ArgsEscaped\":true,\"Image\":\"sha256:3543079adc6fb5170279692361be8b24e89ef1809a374c1b4429e1d560d1459c\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"container\":\"8c59eb170e19b8c3768b8d06c91053b0debf4a6fa6a452df394145fe9b885ea5\",\"container_config\":{\"Hostname\":\"8c59eb170e19\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) \",\"CMD [\\\"/bin/sh\\\"]\"],\"ArgsEscaped\":true,\"Image\":\"sha256:3543079adc6fb5170279692361be8b24e89ef1809a374c1b4429e1d560d1459c\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":{}},\"created\":\"2020-10-22T02:19:24.499382102Z\",\"docker_version\":\"18.09.7\",\"id\":\"c5f1aab5bb88eaf1aa62bea08ea6654547d43fd4d15b1a476c77e705dd5385ba\",\"os\":\"linux\",\"parent\":\"dc0b50cc52bc340d7848a62cfe8a756f4420592f4984f7a680ef8f9d258176ed\",\"throwaway\":true}"
- },
- {
- "v1Compatibility": "{\"id\":\"dc0b50cc52bc340d7848a62cfe8a756f4420592f4984f7a680ef8f9d258176ed\",\"created\":\"2020-10-22T02:19:24.33416307Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ADD file:f17f65714f703db9012f00e5ec98d0b2541ff6147c2633f7ab9ba659d0c507f4 in / \"]}}"
- }
- ],
- "signatures": [
- {
- "header": {
- "jwk": {
- "crv": "P-256",
- "kid": "XOTE:DZ4C:YBPJ:3O3L:YI4B:NYXU:T4VR:USH6:CXXN:SELU:CSCC:FVPE",
- "kty": "EC",
- "x": "cR1zye_3354mdbD7Dn-mtXNXvtPtmLlUVDa5vH6Lp74",
- "y": "rldUXSllLit6_2BW6AV8aqkwWJXHoYPG9OwkIBouwxQ"
- },
- "alg": "ES256"
- },
- "signature": "DYB2iB-XKIisqp5Q0OXFOBIOlBOuRV7pnZuKy0cxVB2Qj1VFRhWX4Tq336y0VMWbF6ma1he5A1E_Vk4jazrJ9g",
- "protected": "eyJmb3JtYXRMZW5ndGgiOjIxMzcsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAyMC0xMS0yNFQyMjowMTo1MVoifQ"
+ "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+ "size": 2810825,
+ "digest": "sha256:596ba82af5aaa3e2fd9d6f955b8b94f0744a2b60710e3c243ba3e4a467f051d1"
}
]
} \ No newline at end of file
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/project.json b/spec/fixtures/lib/gitlab/import_export/complex/project.json
index 637e01bf123..9b8bc9d304e 100644
--- a/spec/fixtures/lib/gitlab/import_export/complex/project.json
+++ b/spec/fixtures/lib/gitlab/import_export/complex/project.json
@@ -7032,7 +7032,8 @@
"created_at": "2016-08-30T07:32:52.490Z",
"updated_at": "2016-08-30T07:32:52.490Z"
}
- ]
+ ],
+ "allow_force_push":false
}
],
"protected_environments": [
diff --git a/spec/fixtures/security_reports/master/gl-sast-report.json b/spec/fixtures/security_reports/master/gl-sast-report.json
index 98bb15e349f..ab610945508 100644
--- a/spec/fixtures/security_reports/master/gl-sast-report.json
+++ b/spec/fixtures/security_reports/master/gl-sast-report.json
@@ -28,7 +28,21 @@
"file": "python/hardcoded/hardcoded-tmp.py",
"line": 1,
"url": "https://docs.openstack.org/bandit/latest/plugins/b108_hardcoded_tmp_directory.html",
- "tool": "bandit"
+ "tool": "bandit",
+ "tracking": {
+ "type": "source",
+ "items": [
+ {
+ "file": "python/hardcoded/hardcoded-tmp.py",
+ "start_line": 1,
+ "end_line": 1,
+ "fingerprints": [
+ { "algorithm": "hash", "value": "HASHVALUE" },
+ { "algorithm": "scope_offset", "value": "python/hardcoded/hardcoded-tmp.py:ClassA:method_b:2" }
+ ]
+ }
+ ]
+ }
},
{
"category": "sast",
diff --git a/spec/frontend/.eslintrc.yml b/spec/frontend/.eslintrc.yml
index d0e585e844a..145e6c8961a 100644
--- a/spec/frontend/.eslintrc.yml
+++ b/spec/frontend/.eslintrc.yml
@@ -14,7 +14,6 @@ settings:
globals:
getJSONFixture: false
loadFixtures: false
- preloadFixtures: false
setFixtures: false
rules:
jest/expect-expect:
diff --git a/spec/frontend/__helpers__/fake_date/fixtures.js b/spec/frontend/__helpers__/fake_date/fixtures.js
new file mode 100644
index 00000000000..fcf9d4a9c64
--- /dev/null
+++ b/spec/frontend/__helpers__/fake_date/fixtures.js
@@ -0,0 +1,4 @@
+import { useFakeDate } from './jest';
+
+// Also see spec/support/helpers/javascript_fixtures_helpers.rb
+export const useFixturesFakeDate = () => useFakeDate(2015, 6, 3, 10);
diff --git a/spec/frontend/__helpers__/fake_date/index.js b/spec/frontend/__helpers__/fake_date/index.js
index 3d1b124ce79..9d00349bd26 100644
--- a/spec/frontend/__helpers__/fake_date/index.js
+++ b/spec/frontend/__helpers__/fake_date/index.js
@@ -1,2 +1,3 @@
export * from './fake_date';
export * from './jest';
+export * from './fixtures';
diff --git a/spec/frontend/__helpers__/vue_test_utils_helper.js b/spec/frontend/__helpers__/vue_test_utils_helper.js
index ffccfb249c2..d6132ef84ac 100644
--- a/spec/frontend/__helpers__/vue_test_utils_helper.js
+++ b/spec/frontend/__helpers__/vue_test_utils_helper.js
@@ -45,9 +45,16 @@ export const extendedWrapper = (wrapper) => {
return wrapper;
}
- return Object.defineProperty(wrapper, 'findByTestId', {
- value(id) {
- return this.find(`[data-testid="${id}"]`);
+ return Object.defineProperties(wrapper, {
+ findByTestId: {
+ value(id) {
+ return this.find(`[data-testid="${id}"]`);
+ },
+ },
+ findAllByTestId: {
+ value(id) {
+ return this.findAll(`[data-testid="${id}"]`);
+ },
},
});
};
diff --git a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js
index 31c4ccd5dbb..d4f8e36c169 100644
--- a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js
+++ b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js
@@ -88,5 +88,22 @@ describe('Vue test utils helpers', () => {
expect(mockComponent.findByTestId(testId).exists()).toBe(true);
});
});
+
+ describe('findAllByTestId', () => {
+ const testId = 'a-component';
+ let mockComponent;
+
+ beforeEach(() => {
+ mockComponent = extendedWrapper(
+ shallowMount({
+ template: `<div><div data-testid="${testId}"></div><div data-testid="${testId}"></div></div>`,
+ }),
+ );
+ });
+
+ it('should find all components by test id', () => {
+ expect(mockComponent.findAllByTestId(testId)).toHaveLength(2);
+ });
+ });
});
});
diff --git a/spec/frontend/__mocks__/@gitlab/ui.js b/spec/frontend/__mocks__/@gitlab/ui.js
index ecd67247362..4c491a87fcb 100644
--- a/spec/frontend/__mocks__/@gitlab/ui.js
+++ b/spec/frontend/__mocks__/@gitlab/ui.js
@@ -39,7 +39,10 @@ jest.mock('@gitlab/ui/dist/components/base/popover/popover.js', () => ({
default: () => [],
},
...Object.fromEntries(
- ['target', 'triggers', 'placement', 'boundary', 'container'].map((prop) => [prop, {}]),
+ ['title', 'target', 'triggers', 'placement', 'boundary', 'container'].map((prop) => [
+ prop,
+ {},
+ ]),
),
},
render(h) {
diff --git a/spec/frontend/access_tokens/components/projects_field_spec.js b/spec/frontend/access_tokens/components/projects_field_spec.js
new file mode 100644
index 00000000000..a9e0799d114
--- /dev/null
+++ b/spec/frontend/access_tokens/components/projects_field_spec.js
@@ -0,0 +1,131 @@
+import { within, fireEvent } from '@testing-library/dom';
+import { mount } from '@vue/test-utils';
+import ProjectsField from '~/access_tokens/components/projects_field.vue';
+import ProjectsTokenSelector from '~/access_tokens/components/projects_token_selector.vue';
+
+describe('ProjectsField', () => {
+ let wrapper;
+
+ const createComponent = ({ inputAttrsValue = '' } = {}) => {
+ wrapper = mount(ProjectsField, {
+ propsData: {
+ inputAttrs: {
+ id: 'projects',
+ name: 'projects',
+ value: inputAttrsValue,
+ },
+ },
+ });
+ };
+
+ const queryByLabelText = (text) => within(wrapper.element).queryByLabelText(text);
+ const queryByText = (text) => within(wrapper.element).queryByText(text);
+ const findAllProjectsRadio = () => queryByLabelText('All projects');
+ const findSelectedProjectsRadio = () => queryByLabelText('Selected projects');
+ const findProjectsTokenSelector = () => wrapper.findComponent(ProjectsTokenSelector);
+ const findHiddenInput = () => wrapper.find('input[type="hidden"]');
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders label and sub-label', () => {
+ createComponent();
+
+ expect(queryByText('Projects')).not.toBe(null);
+ expect(queryByText('Set access permissions for this token.')).not.toBe(null);
+ });
+
+ describe('when `inputAttrs.value` is empty', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders "All projects" radio as checked', () => {
+ expect(findAllProjectsRadio().checked).toBe(true);
+ });
+
+ it('renders "Selected projects" radio as unchecked', () => {
+ expect(findSelectedProjectsRadio().checked).toBe(false);
+ });
+
+ it('sets `projects-token-selector` `initialProjectIds` prop to an empty array', () => {
+ expect(findProjectsTokenSelector().props('initialProjectIds')).toEqual([]);
+ });
+ });
+
+ describe('when `inputAttrs.value` is a comma separated list of project IDs', () => {
+ beforeEach(() => {
+ createComponent({ inputAttrsValue: '1,2' });
+ });
+
+ it('renders "All projects" radio as unchecked', () => {
+ expect(findAllProjectsRadio().checked).toBe(false);
+ });
+
+ it('renders "Selected projects" radio as checked', () => {
+ expect(findSelectedProjectsRadio().checked).toBe(true);
+ });
+
+ it('sets `projects-token-selector` `initialProjectIds` prop to an array of project IDs', () => {
+ expect(findProjectsTokenSelector().props('initialProjectIds')).toEqual(['1', '2']);
+ });
+ });
+
+ it('renders `projects-token-selector` component', () => {
+ createComponent();
+
+ expect(findProjectsTokenSelector().exists()).toBe(true);
+ });
+
+ it('renders hidden input with correct `name` and `id` attributes', () => {
+ createComponent();
+
+ expect(findHiddenInput().attributes()).toEqual(
+ expect.objectContaining({
+ id: 'projects',
+ name: 'projects',
+ }),
+ );
+ });
+
+ describe('when `projects-token-selector` is focused', () => {
+ beforeEach(() => {
+ createComponent();
+
+ findProjectsTokenSelector().vm.$emit('focus');
+ });
+
+ it('auto selects the "Selected projects" radio', () => {
+ expect(findSelectedProjectsRadio().checked).toBe(true);
+ });
+
+ describe('when `projects-token-selector` is changed', () => {
+ beforeEach(() => {
+ findProjectsTokenSelector().vm.$emit('input', [
+ {
+ id: 1,
+ },
+ {
+ id: 2,
+ },
+ ]);
+ });
+
+ it('updates the hidden input value to a comma separated list of project IDs', () => {
+ expect(findHiddenInput().attributes('value')).toBe('1,2');
+ });
+
+ describe('when radio is changed back to "All projects"', () => {
+ beforeEach(() => {
+ fireEvent.click(findAllProjectsRadio());
+ });
+
+ it('removes the hidden input value', () => {
+ expect(findHiddenInput().attributes('value')).toBe('');
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/access_tokens/components/projects_token_selector_spec.js b/spec/frontend/access_tokens/components/projects_token_selector_spec.js
new file mode 100644
index 00000000000..09f52fe9a5f
--- /dev/null
+++ b/spec/frontend/access_tokens/components/projects_token_selector_spec.js
@@ -0,0 +1,269 @@
+import {
+ GlAvatar,
+ GlAvatarLabeled,
+ GlIntersectionObserver,
+ GlToken,
+ GlTokenSelector,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import produce from 'immer';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+
+import { getJSONFixture } from 'helpers/fixtures';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import ProjectsTokenSelector from '~/access_tokens/components/projects_token_selector.vue';
+import getProjectsQuery from '~/access_tokens/graphql/queries/get_projects.query.graphql';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+
+describe('ProjectsTokenSelector', () => {
+ const getProjectsQueryResponse = getJSONFixture(
+ 'graphql/projects/access_tokens/get_projects.query.graphql.json',
+ );
+ const getProjectsQueryResponsePage2 = produce(
+ getProjectsQueryResponse,
+ (getProjectsQueryResponseDraft) => {
+ /* eslint-disable no-param-reassign */
+ getProjectsQueryResponseDraft.data.projects.pageInfo.hasNextPage = false;
+ getProjectsQueryResponseDraft.data.projects.pageInfo.endCursor = null;
+ getProjectsQueryResponseDraft.data.projects.nodes.splice(1, 1);
+ getProjectsQueryResponseDraft.data.projects.nodes[0].id = 'gid://gitlab/Project/100';
+ /* eslint-enable no-param-reassign */
+ },
+ );
+
+ const runDebounce = () => jest.runAllTimers();
+
+ const { pageInfo, nodes: projects } = getProjectsQueryResponse.data.projects;
+ const project1 = projects[0];
+ const project2 = projects[1];
+
+ let wrapper;
+
+ let resolveGetProjectsQuery;
+ let resolveGetInitialProjectsQuery;
+ const getProjectsQueryRequestHandler = jest.fn(
+ ({ ids }) =>
+ new Promise((resolve) => {
+ if (ids) {
+ resolveGetInitialProjectsQuery = resolve;
+ } else {
+ resolveGetProjectsQuery = resolve;
+ }
+ }),
+ );
+
+ const createComponent = ({
+ propsData = {},
+ apolloProvider = createMockApollo([[getProjectsQuery, getProjectsQueryRequestHandler]]),
+ resolveQueries = true,
+ } = {}) => {
+ Vue.use(VueApollo);
+
+ wrapper = extendedWrapper(
+ mount(ProjectsTokenSelector, {
+ apolloProvider,
+ propsData: {
+ selectedProjects: [],
+ initialProjectIds: [],
+ ...propsData,
+ },
+ stubs: ['gl-intersection-observer'],
+ }),
+ );
+
+ runDebounce();
+
+ if (resolveQueries) {
+ resolveGetProjectsQuery(getProjectsQueryResponse);
+
+ return waitForPromises();
+ }
+
+ return Promise.resolve();
+ };
+
+ const findTokenSelector = () => wrapper.findComponent(GlTokenSelector);
+ const findTokenSelectorInput = () => findTokenSelector().find('input[type="text"]');
+ const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
+
+ it('renders dropdown items with project avatars', async () => {
+ await createComponent();
+
+ wrapper.findAllComponents(GlAvatarLabeled).wrappers.forEach((avatarLabeledWrapper, index) => {
+ const project = projects[index];
+
+ expect(avatarLabeledWrapper.attributes()).toEqual(
+ expect.objectContaining({
+ 'entity-id': `${getIdFromGraphQLId(project.id)}`,
+ 'entity-name': project.name,
+ ...(project.avatarUrl && { src: project.avatarUrl }),
+ }),
+ );
+
+ expect(avatarLabeledWrapper.props()).toEqual(
+ expect.objectContaining({
+ label: project.name,
+ subLabel: project.nameWithNamespace,
+ }),
+ );
+ });
+ });
+
+ it('renders tokens with project avatars', () => {
+ createComponent({
+ propsData: {
+ selectedProjects: [{ ...project2, id: getIdFromGraphQLId(project2.id) }],
+ },
+ });
+
+ const token = wrapper.findComponent(GlToken);
+ const avatar = token.findComponent(GlAvatar);
+
+ expect(token.text()).toContain(project2.nameWithNamespace);
+ expect(avatar.attributes('src')).toBe(project2.avatarUrl);
+ expect(avatar.props()).toEqual(
+ expect.objectContaining({
+ entityId: getIdFromGraphQLId(project2.id),
+ entityName: project2.name,
+ }),
+ );
+ });
+
+ describe('when `enter` key is pressed', () => {
+ it('calls `preventDefault` so form is not submitted when user selects a project from the dropdown', () => {
+ createComponent();
+
+ const event = {
+ preventDefault: jest.fn(),
+ };
+
+ findTokenSelectorInput().trigger('keydown.enter', event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ });
+ });
+
+ describe('when text input is typed in', () => {
+ const searchTerm = 'foo bar';
+
+ beforeEach(async () => {
+ await createComponent();
+
+ await findTokenSelectorInput().setValue(searchTerm);
+ runDebounce();
+ });
+
+ it('makes GraphQL request with `search` variable set', async () => {
+ expect(getProjectsQueryRequestHandler).toHaveBeenLastCalledWith({
+ search: searchTerm,
+ after: null,
+ first: 20,
+ ids: null,
+ });
+ });
+
+ it('sets loading state while waiting for GraphQL request to resolve', async () => {
+ expect(findTokenSelector().props('loading')).toBe(true);
+
+ resolveGetProjectsQuery(getProjectsQueryResponse);
+ await waitForPromises();
+
+ expect(findTokenSelector().props('loading')).toBe(false);
+ });
+ });
+
+ describe('when there is a next page of projects and user scrolls to the bottom of the dropdown', () => {
+ beforeEach(async () => {
+ await createComponent();
+
+ findIntersectionObserver().vm.$emit('appear');
+ });
+
+ it('makes GraphQL request with `after` variable set', async () => {
+ expect(getProjectsQueryRequestHandler).toHaveBeenLastCalledWith({
+ after: pageInfo.endCursor,
+ first: 20,
+ search: '',
+ ids: null,
+ });
+ });
+
+ it('displays loading icon while waiting for GraphQL request to resolve', async () => {
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+
+ resolveGetProjectsQuery(getProjectsQueryResponsePage2);
+ await waitForPromises();
+
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false);
+ });
+ });
+
+ describe('when there is not a next page of projects', () => {
+ it('does not render `GlIntersectionObserver`', async () => {
+ createComponent({ resolveQueries: false });
+
+ resolveGetProjectsQuery(getProjectsQueryResponsePage2);
+ await waitForPromises();
+
+ expect(findIntersectionObserver().exists()).toBe(false);
+ });
+ });
+
+ describe('when `GlTokenSelector` emits `input` event', () => {
+ it('emits `input` event used by `v-model`', () => {
+ findTokenSelector().vm.$emit('input', project1);
+
+ expect(wrapper.emitted('input')[0]).toEqual([project1]);
+ });
+ });
+
+ describe('when `GlTokenSelector` emits `focus` event', () => {
+ it('emits `focus` event', () => {
+ const event = { fakeEvent: 'foo' };
+ findTokenSelector().vm.$emit('focus', event);
+
+ expect(wrapper.emitted('focus')[0]).toEqual([event]);
+ });
+ });
+
+ describe('when `initialProjectIds` is an empty array', () => {
+ it('does not request initial projects', async () => {
+ await createComponent();
+
+ expect(getProjectsQueryRequestHandler).toHaveBeenCalledTimes(1);
+ expect(getProjectsQueryRequestHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ids: null,
+ }),
+ );
+ });
+ });
+
+ describe('when `initialProjectIds` is an array of project IDs', () => {
+ it('requests those projects and emits `input` event with result', async () => {
+ await createComponent({
+ propsData: {
+ initialProjectIds: [getIdFromGraphQLId(project1.id), getIdFromGraphQLId(project2.id)],
+ },
+ });
+
+ resolveGetInitialProjectsQuery(getProjectsQueryResponse);
+ await waitForPromises();
+
+ expect(getProjectsQueryRequestHandler).toHaveBeenCalledWith({
+ after: '',
+ first: null,
+ search: '',
+ ids: [project1.id, project2.id],
+ });
+ expect(wrapper.emitted('input')[0][0]).toEqual([
+ { ...project1, id: getIdFromGraphQLId(project1.id) },
+ { ...project2, id: getIdFromGraphQLId(project2.id) },
+ ]);
+ });
+ });
+});
diff --git a/spec/frontend/access_tokens/index_spec.js b/spec/frontend/access_tokens/index_spec.js
new file mode 100644
index 00000000000..e3f17e21739
--- /dev/null
+++ b/spec/frontend/access_tokens/index_spec.js
@@ -0,0 +1,74 @@
+import { createWrapper } from '@vue/test-utils';
+import Vue from 'vue';
+
+import { initExpiresAtField, initProjectsField } from '~/access_tokens';
+import * as ExpiresAtField from '~/access_tokens/components/expires_at_field.vue';
+import * as ProjectsField from '~/access_tokens/components/projects_field.vue';
+
+describe('access tokens', () => {
+ const FakeComponent = Vue.component('FakeComponent', {
+ props: {
+ inputAttrs: {
+ type: Object,
+ required: true,
+ },
+ },
+ render: () => null,
+ });
+
+ beforeEach(() => {
+ window.gon = { features: { personalAccessTokensScopedToProjects: true } };
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ describe.each`
+ initFunction | mountSelector | expectedComponent
+ ${initExpiresAtField} | ${'js-access-tokens-expires-at'} | ${ExpiresAtField}
+ ${initProjectsField} | ${'js-access-tokens-projects'} | ${ProjectsField}
+ `('$initFunction', ({ initFunction, mountSelector, expectedComponent }) => {
+ describe('when mount element exists', () => {
+ beforeEach(() => {
+ const mountEl = document.createElement('div');
+ mountEl.classList.add(mountSelector);
+
+ const input = document.createElement('input');
+ input.setAttribute('name', 'foo-bar');
+ input.setAttribute('id', 'foo-bar');
+ input.setAttribute('placeholder', 'Foo bar');
+ input.setAttribute('value', '1,2');
+
+ mountEl.appendChild(input);
+
+ document.body.appendChild(mountEl);
+
+ // Mock component so we don't have to deal with mocking Apollo
+ // eslint-disable-next-line no-param-reassign
+ expectedComponent.default = FakeComponent;
+ });
+
+ it('mounts component and sets `inputAttrs` prop', async () => {
+ const vueInstance = await initFunction();
+
+ const wrapper = createWrapper(vueInstance);
+ const component = wrapper.findComponent(FakeComponent);
+
+ expect(component.exists()).toBe(true);
+ expect(component.props('inputAttrs')).toEqual({
+ name: 'foo-bar',
+ id: 'foo-bar',
+ value: '1,2',
+ placeholder: 'Foo bar',
+ });
+ });
+ });
+
+ describe('when mount element does not exist', () => {
+ it('returns `null`', () => {
+ expect(initFunction()).toBe(null);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/admin/users/tabs_spec.js b/spec/frontend/admin/users/tabs_spec.js
new file mode 100644
index 00000000000..39ba8618486
--- /dev/null
+++ b/spec/frontend/admin/users/tabs_spec.js
@@ -0,0 +1,37 @@
+import initTabs from '~/admin/users/tabs';
+import Api from '~/api';
+
+jest.mock('~/api.js');
+jest.mock('~/lib/utils/common_utils');
+
+describe('tabs', () => {
+ beforeEach(() => {
+ setFixtures(`
+ <div>
+ <div class="js-users-tab-item">
+ <a href="#users" data-testid='users-tab'>Users</a>
+ </div>
+ <div class="js-users-tab-item">
+ <a href="#cohorts" data-testid='cohorts-tab'>Cohorts</a>
+ </div>
+ </div`);
+
+ initTabs();
+ });
+
+ afterEach(() => {});
+
+ describe('tracking', () => {
+ it('tracks event when cohorts tab is clicked', () => {
+ document.querySelector('[data-testid="cohorts-tab"]').click();
+
+ expect(Api.trackRedisHllUserEvent).toHaveBeenCalledWith('i_analytics_cohorts');
+ });
+
+ it('does not track an event when users tab is clicked', () => {
+ document.querySelector('[data-testid="users-tab"]').click();
+
+ expect(Api.trackRedisHllUserEvent).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/alert_management/components/alert_management_table_spec.js b/spec/frontend/alert_management/components/alert_management_table_spec.js
index cea665aa50d..dece3dfbe5f 100644
--- a/spec/frontend/alert_management/components/alert_management_table_spec.js
+++ b/spec/frontend/alert_management/components/alert_management_table_spec.js
@@ -2,6 +2,8 @@ import { GlTable, GlAlert, GlLoadingIcon, GlDropdown, GlIcon, GlAvatar } from '@
import { mount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import mockAlerts from 'jest/vue_shared/alert_details/mocks/alerts.json';
import AlertManagementTable from '~/alert_management/components/alert_management_table.vue';
import { visitUrl } from '~/lib/utils/url_utility';
@@ -18,19 +20,18 @@ describe('AlertManagementTable', () => {
let wrapper;
let mock;
- const findAlertsTable = () => wrapper.find(GlTable);
+ const findAlertsTable = () => wrapper.findComponent(GlTable);
const findAlerts = () => wrapper.findAll('table tbody tr');
- const findAlert = () => wrapper.find(GlAlert);
- const findLoader = () => wrapper.find(GlLoadingIcon);
- const findStatusDropdown = () => wrapper.find(GlDropdown);
- const findDateFields = () => wrapper.findAll(TimeAgo);
- const findSearch = () => wrapper.find(FilteredSearchBar);
- const findSeverityColumnHeader = () =>
- wrapper.find('[data-testid="alert-management-severity-sort"]');
- const findFirstIDField = () => wrapper.findAll('[data-testid="idField"]').at(0);
- const findAssignees = () => wrapper.findAll('[data-testid="assigneesField"]');
- const findSeverityFields = () => wrapper.findAll('[data-testid="severityField"]');
- const findIssueFields = () => wrapper.findAll('[data-testid="issueField"]');
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findLoader = () => wrapper.findComponent(GlLoadingIcon);
+ const findStatusDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDateFields = () => wrapper.findAllComponents(TimeAgo);
+ const findSearch = () => wrapper.findComponent(FilteredSearchBar);
+ const findSeverityColumnHeader = () => wrapper.findByTestId('alert-management-severity-sort');
+ const findFirstIDField = () => wrapper.findAllByTestId('idField').at(0);
+ const findAssignees = () => wrapper.findAllByTestId('assigneesField');
+ const findSeverityFields = () => wrapper.findAllByTestId('severityField');
+ const findIssueFields = () => wrapper.findAllByTestId('issueField');
const alertsCount = {
open: 24,
triggered: 20,
@@ -40,29 +41,34 @@ describe('AlertManagementTable', () => {
};
function mountComponent({ provide = {}, data = {}, loading = false, stubs = {} } = {}) {
- wrapper = mount(AlertManagementTable, {
- provide: {
- ...defaultProvideValues,
- alertManagementEnabled: true,
- userCanEnableAlertManagement: true,
- ...provide,
- },
- data() {
- return data;
- },
- mocks: {
- $apollo: {
- mutate: jest.fn(),
- query: jest.fn(),
- queries: {
- alerts: {
- loading,
+ wrapper = extendedWrapper(
+ mount(AlertManagementTable, {
+ provide: {
+ ...defaultProvideValues,
+ alertManagementEnabled: true,
+ userCanEnableAlertManagement: true,
+ ...provide,
+ },
+ data() {
+ return data;
+ },
+ mocks: {
+ $apollo: {
+ mutate: jest.fn(),
+ query: jest.fn(),
+ queries: {
+ alerts: {
+ loading,
+ },
},
},
},
- },
- stubs,
- });
+ stubs,
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ }),
+ );
}
beforeEach(() => {
@@ -72,7 +78,6 @@ describe('AlertManagementTable', () => {
afterEach(() => {
if (wrapper) {
wrapper.destroy();
- wrapper = null;
}
mock.restore();
});
@@ -241,9 +246,14 @@ describe('AlertManagementTable', () => {
expect(findIssueFields().at(0).text()).toBe('None');
});
- it('renders a link when one exists', () => {
- expect(findIssueFields().at(1).text()).toBe('#1');
- expect(findIssueFields().at(1).attributes('href')).toBe('/gitlab-org/gitlab/-/issues/1');
+ it('renders a link when one exists with the issue state and title tooltip', () => {
+ const issueField = findIssueFields().at(1);
+ const tooltip = getBinding(issueField.element, 'gl-tooltip');
+
+ expect(issueField.text()).toBe(`#1 (closed)`);
+ expect(issueField.attributes('href')).toBe('/gitlab-org/gitlab/-/issues/incident/1');
+ expect(issueField.attributes('title')).toBe('My test issue');
+ expect(tooltip).not.toBe(undefined);
});
});
diff --git a/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap
index eb2b82a0211..1f8429af7dd 100644
--- a/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap
+++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_settings_form_spec.js.snap
@@ -4,125 +4,151 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`]
<form
class="gl-mt-6"
>
- <h5
- class="gl-font-lg gl-my-5"
- >
- Add new integrations
- </h5>
-
<div
- class="form-group gl-form-group"
- id="integration-type"
- role="group"
+ class="tabs gl-tabs"
+ id="__BVID__6"
>
- <label
- class="d-block col-form-label"
- for="integration-type"
- id="integration-type__BV_label_"
- >
- 1. Select integration type
- </label>
+ <!---->
<div
- class="bv-no-focus-ring"
+ class=""
>
- <select
- class="gl-form-select mw-100 custom-select"
- id="__BVID__8"
+ <ul
+ class="nav gl-tabs-nav"
+ id="__BVID__6__BV_tab_controls_"
+ role="tablist"
>
- <option
- value=""
+ <!---->
+ <li
+ class="nav-item"
+ role="presentation"
>
- Select integration type
- </option>
- <option
- value="HTTP"
+ <a
+ aria-controls="__BVID__8"
+ aria-posinset="1"
+ aria-selected="true"
+ aria-setsize="3"
+ class="nav-link active gl-tab-nav-item gl-tab-nav-item-active gl-tab-nav-item-active-indigo"
+ href="#"
+ id="__BVID__8___BV_tab_button__"
+ role="tab"
+ target="_self"
+ >
+ Configure details
+ </a>
+ </li>
+ <li
+ class="nav-item"
+ role="presentation"
>
- HTTP Endpoint
- </option>
- <option
- value="PROMETHEUS"
+ <a
+ aria-controls="__BVID__19"
+ aria-disabled="true"
+ aria-posinset="2"
+ aria-selected="false"
+ aria-setsize="3"
+ class="nav-link disabled disabled gl-tab-nav-item"
+ href="#"
+ id="__BVID__19___BV_tab_button__"
+ role="tab"
+ tabindex="-1"
+ target="_self"
+ >
+ View credentials
+ </a>
+ </li>
+ <li
+ class="nav-item"
+ role="presentation"
>
- External Prometheus
- </option>
- </select>
-
- <!---->
- <!---->
- <!---->
- <!---->
+ <a
+ aria-controls="__BVID__41"
+ aria-disabled="true"
+ aria-posinset="3"
+ aria-selected="false"
+ aria-setsize="3"
+ class="nav-link disabled disabled gl-tab-nav-item"
+ href="#"
+ id="__BVID__41___BV_tab_button__"
+ role="tab"
+ tabindex="-1"
+ target="_self"
+ >
+ Send test alert
+ </a>
+ </li>
+ <!---->
+ </ul>
</div>
- </div>
-
- <transition-stub
- class="gl-mt-3"
- css="true"
- enteractiveclass="collapsing"
- enterclass=""
- entertoclass="collapse show"
- leaveactiveclass="collapsing"
- leaveclass="collapse show"
- leavetoclass="collapse"
- >
<div
- class="collapse"
- id="__BVID__10"
- style="display: none;"
+ class="tab-content gl-tab-content"
+ id="__BVID__6__BV_tab_container_"
>
- <div>
+ <transition-stub
+ css="true"
+ enteractiveclass=""
+ enterclass=""
+ entertoclass="show"
+ leaveactiveclass=""
+ leaveclass="show"
+ leavetoclass=""
+ mode="out-in"
+ name=""
+ >
<div
- class="form-group gl-form-group"
- id="name-integration"
- role="group"
+ aria-hidden="false"
+ aria-labelledby="__BVID__8___BV_tab_button__"
+ class="tab-pane active"
+ id="__BVID__8"
+ role="tabpanel"
+ style=""
>
- <label
- class="d-block col-form-label"
- for="name-integration"
- id="name-integration__BV_label_"
- >
- 2. Name integration
- </label>
<div
- class="bv-no-focus-ring"
+ class="form-group gl-form-group"
+ id="integration-type"
+ role="group"
>
- <input
- class="gl-form-input form-control"
- id="__BVID__15"
- placeholder="Enter integration name"
- type="text"
- />
- <!---->
- <!---->
- <!---->
+ <label
+ class="d-block col-form-label"
+ for="integration-type"
+ id="integration-type__BV_label_"
+ >
+ 1.Select integration type
+ </label>
+ <div
+ class="bv-no-focus-ring"
+ >
+ <select
+ class="gl-form-select gl-max-w-full custom-select"
+ id="__BVID__13"
+ >
+ <option
+ value=""
+ >
+ Select integration type
+ </option>
+ <option
+ value="HTTP"
+ >
+ HTTP Endpoint
+ </option>
+ <option
+ value="PROMETHEUS"
+ >
+ External Prometheus
+ </option>
+ </select>
+
+ <!---->
+ <!---->
+ <!---->
+ <!---->
+ </div>
</div>
- </div>
-
- <div
- class="form-group gl-form-group"
- id="integration-webhook"
- role="group"
- >
- <label
- class="d-block col-form-label"
- for="integration-webhook"
- id="integration-webhook__BV_label_"
- >
- 3. Set up webhook
- </label>
+
<div
- class="bv-no-focus-ring"
+ class="gl-mt-3"
>
- <span>
- Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the
- <a
- class="gl-link gl-display-inline-block"
- href="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html"
- rel="noopener noreferrer"
- target="_blank"
- >
- GitLab documentation
- </a>
- to learn more about configuring your endpoint.
- </span>
+ <!---->
<label
class="gl-display-flex gl-flex-direction-column gl-mb-0 gl-w-max-content gl-my-4 gl-font-weight-normal"
@@ -166,241 +192,333 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`]
<!---->
- <div
- class="gl-my-4"
+ <!---->
+ </div>
+
+ <div
+ class="gl-display-flex gl-justify-content-start gl-py-3"
+ >
+ <button
+ class="btn js-no-auto-disable btn-confirm btn-md gl-button"
+ data-testid="integration-form-submit"
+ type="submit"
>
+ <!---->
+
+ <!---->
+
<span
- class="gl-font-weight-bold"
+ class="gl-button-text"
>
- Webhook URL
-
+ Save integration
+
</span>
+ </button>
+
+ <button
+ class="btn gl-ml-3 js-no-auto-disable btn-default btn-md gl-button"
+ type="reset"
+ >
+ <!---->
+ <!---->
+
+ <span
+ class="gl-button-text"
+ >
+ Cancel and close
+ </span>
+ </button>
+ </div>
+ </div>
+ </transition-stub>
+
+ <transition-stub
+ css="true"
+ enteractiveclass=""
+ enterclass=""
+ entertoclass="show"
+ leaveactiveclass=""
+ leaveclass="show"
+ leavetoclass=""
+ mode="out-in"
+ name=""
+ >
+ <div
+ aria-hidden="true"
+ aria-labelledby="__BVID__19___BV_tab_button__"
+ class="tab-pane disabled"
+ id="__BVID__19"
+ role="tabpanel"
+ style="display: none;"
+ >
+ <span>
+ Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the
+ <a
+ class="gl-link gl-display-inline-block"
+ href="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ GitLab documentation
+ </a>
+ to learn more about configuring your endpoint.
+ </span>
+
+ <fieldset
+ class="form-group gl-form-group"
+ id="integration-webhook"
+ >
+ <!---->
+ <div
+ class="bv-no-focus-ring"
+ role="group"
+ tabindex="-1"
+ >
<div
- id="url"
- readonly="readonly"
+ class="gl-my-4"
>
+ <span
+ class="gl-font-weight-bold"
+ >
+
+ Webhook URL
+
+ </span>
+
<div
- class="input-group"
- role="group"
+ id="url"
+ readonly="readonly"
>
- <!---->
- <!---->
-
- <input
- class="gl-form-input form-control"
- id="url"
- readonly="readonly"
- type="text"
- />
-
<div
- class="input-group-append"
+ class="input-group"
+ role="group"
>
- <button
- aria-label="Copy this value"
- class="btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon"
- data-clipboard-text=""
- title="Copy"
- type="button"
+ <!---->
+ <!---->
+
+ <input
+ class="gl-form-input form-control"
+ id="url"
+ readonly="readonly"
+ type="text"
+ />
+
+ <div
+ class="input-group-append"
>
- <!---->
-
- <svg
- aria-hidden="true"
- class="gl-button-icon gl-icon s16"
- data-testid="copy-to-clipboard-icon"
+ <button
+ aria-label="Copy this value"
+ class="btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon"
+ data-clipboard-text=""
+ title="Copy"
+ type="button"
>
- <use
- href="#copy-to-clipboard"
- />
- </svg>
-
- <!---->
- </button>
+ <!---->
+
+ <svg
+ aria-hidden="true"
+ class="gl-button-icon gl-icon s16"
+ data-testid="copy-to-clipboard-icon"
+ >
+ <use
+ href="#copy-to-clipboard"
+ />
+ </svg>
+
+ <!---->
+ </button>
+ </div>
+ <!---->
</div>
- <!---->
</div>
</div>
- </div>
-
- <div
- class="gl-my-4"
- >
- <span
- class="gl-font-weight-bold"
- >
-
- Authorization key
-
- </span>
<div
- class="gl-mb-3"
- id="authorization-key"
- readonly="readonly"
+ class="gl-my-4"
>
+ <span
+ class="gl-font-weight-bold"
+ >
+
+ Authorization key
+
+ </span>
+
<div
- class="input-group"
- role="group"
+ class="gl-mb-3"
+ id="authorization-key"
+ readonly="readonly"
>
- <!---->
- <!---->
-
- <input
- class="gl-form-input form-control"
- id="authorization-key"
- readonly="readonly"
- type="text"
- />
-
<div
- class="input-group-append"
+ class="input-group"
+ role="group"
>
- <button
- aria-label="Copy this value"
- class="btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon"
- data-clipboard-text=""
- title="Copy"
- type="button"
+ <!---->
+ <!---->
+
+ <input
+ class="gl-form-input form-control"
+ id="authorization-key"
+ readonly="readonly"
+ type="text"
+ />
+
+ <div
+ class="input-group-append"
>
- <!---->
-
- <svg
- aria-hidden="true"
- class="gl-button-icon gl-icon s16"
- data-testid="copy-to-clipboard-icon"
+ <button
+ aria-label="Copy this value"
+ class="btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon"
+ data-clipboard-text=""
+ title="Copy"
+ type="button"
>
- <use
- href="#copy-to-clipboard"
- />
- </svg>
-
- <!---->
- </button>
+ <!---->
+
+ <svg
+ aria-hidden="true"
+ class="gl-button-icon gl-icon s16"
+ data-testid="copy-to-clipboard-icon"
+ >
+ <use
+ href="#copy-to-clipboard"
+ />
+ </svg>
+
+ <!---->
+ </button>
+ </div>
+ <!---->
</div>
- <!---->
</div>
</div>
-
- <button
- class="btn btn-default btn-md disabled gl-button"
- disabled="disabled"
- type="button"
- >
- <!---->
-
- <!---->
-
- <span
- class="gl-button-text"
- >
-
- Reset Key
-
- </span>
- </button>
-
+ <!---->
+ <!---->
<!---->
</div>
- <!---->
- <!---->
- <!---->
- </div>
- </div>
-
- <div
- class="form-group gl-form-group"
- id="test-integration"
- role="group"
- >
- <label
- class="d-block col-form-label"
- for="test-integration"
- id="test-integration__BV_label_"
- >
- 4. Sample alert payload (optional)
- </label>
- <div
- class="bv-no-focus-ring"
+ </fieldset>
+
+ <button
+ class="btn btn-danger btn-md disabled gl-button"
+ disabled="disabled"
+ type="button"
>
- <span>
- Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional).
- </span>
+ <!---->
- <textarea
- class="gl-form-input gl-form-textarea gl-my-3 form-control is-valid"
- disabled="disabled"
- id="test-payload"
- placeholder="{ \\"events\\": [{ \\"application\\": \\"Name of application\\" }] }"
- style="resize: none; overflow-y: scroll;"
- wrap="soft"
- />
<!---->
+
+ <span
+ class="gl-button-text"
+ >
+
+ Reset Key
+
+ </span>
+ </button>
+
+ <button
+ class="btn gl-ml-3 js-no-auto-disable btn-default btn-md gl-button"
+ type="reset"
+ >
<!---->
+
<!---->
- </div>
+
+ <span
+ class="gl-button-text"
+ >
+ Cancel and close
+ </span>
+ </button>
+
+ <!---->
</div>
-
- <!---->
-
- <!---->
- </div>
+ </transition-stub>
- <div
- class="gl-display-flex gl-justify-content-start gl-py-3"
+ <transition-stub
+ css="true"
+ enteractiveclass=""
+ enterclass=""
+ entertoclass="show"
+ leaveactiveclass=""
+ leaveclass="show"
+ leavetoclass=""
+ mode="out-in"
+ name=""
>
- <button
- class="btn js-no-auto-disable btn-success btn-md gl-button"
- data-testid="integration-form-submit"
- type="submit"
+ <div
+ aria-hidden="true"
+ aria-labelledby="__BVID__41___BV_tab_button__"
+ class="tab-pane disabled"
+ id="__BVID__41"
+ role="tabpanel"
+ style="display: none;"
>
- <!---->
-
- <!---->
-
- <span
- class="gl-button-text"
+ <fieldset
+ class="form-group gl-form-group"
+ id="test-integration"
>
- Save integration
-
- </span>
- </button>
-
- <button
- class="btn gl-mx-3 js-no-auto-disable btn-success btn-md disabled gl-button btn-success-secondary"
- data-testid="integration-test-and-submit"
- disabled="disabled"
- type="button"
- >
- <!---->
+ <!---->
+ <div
+ class="bv-no-focus-ring"
+ role="group"
+ tabindex="-1"
+ >
+ <span>
+ Provide an example payload from the monitoring tool you intend to integrate with. This will allow you to send an alert to an active GitLab alerting point.
+ </span>
+
+ <textarea
+ class="gl-form-input gl-form-textarea gl-my-3 form-control is-valid"
+ id="test-payload"
+ placeholder="{ \\"events\\": [{ \\"application\\": \\"Name of application\\" }] }"
+ style="resize: none; overflow-y: scroll;"
+ wrap="soft"
+ />
+ <!---->
+ <!---->
+ <!---->
+ </div>
+ </fieldset>
- <!---->
-
- <span
- class="gl-button-text"
+ <button
+ class="btn js-no-auto-disable btn-confirm btn-md gl-button"
+ data-testid="send-test-alert"
+ type="button"
>
- Save and test payload
- </span>
- </button>
-
- <button
- class="btn js-no-auto-disable btn-default btn-md gl-button"
- type="reset"
- >
- <!---->
+ <!---->
+
+ <!---->
+
+ <span
+ class="gl-button-text"
+ >
+
+ Send
+
+ </span>
+ </button>
- <!---->
-
- <span
- class="gl-button-text"
+ <button
+ class="btn gl-ml-3 js-no-auto-disable btn-default btn-md gl-button"
+ type="reset"
>
- Cancel
- </span>
- </button>
- </div>
+ <!---->
+
+ <!---->
+
+ <span
+ class="gl-button-text"
+ >
+ Cancel and close
+ </span>
+ </button>
+ </div>
+ </transition-stub>
+ <!---->
</div>
- </transition-stub>
+ </div>
</form>
`;
diff --git a/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js
index 7e1d1acb62c..dba9c8be669 100644
--- a/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js
+++ b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js
@@ -1,10 +1,10 @@
import { GlIcon, GlFormInput, GlDropdown, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import AlertMappingBuilder, { i18n } from '~/alerts_settings/components/alert_mapping_builder.vue';
-import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json';
import * as transformationUtils from '~/alerts_settings/utils/mapping_transformations';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
-import alertFields from '../mocks/alertFields.json';
+import alertFields from '../mocks/alert_fields.json';
+import parsedMapping from '../mocks/parsed_mapping.json';
describe('AlertMappingBuilder', () => {
let wrapper;
@@ -12,8 +12,8 @@ describe('AlertMappingBuilder', () => {
function mountComponent() {
wrapper = shallowMount(AlertMappingBuilder, {
propsData: {
- parsedPayload: parsedMapping.samplePayload.payloadAlerFields.nodes,
- savedMapping: parsedMapping.storedMapping.nodes,
+ parsedPayload: parsedMapping.payloadAlerFields,
+ savedMapping: parsedMapping.payloadAttributeMappings,
alertFields,
},
});
@@ -33,6 +33,15 @@ describe('AlertMappingBuilder', () => {
const findColumnInRow = (row, column) =>
wrapper.findAll('.gl-display-table-row').at(row).findAll('.gl-display-table-cell ').at(column);
+ const getDropdownContent = (dropdown, types) => {
+ const searchBox = dropdown.findComponent(GlSearchBoxByType);
+ const dropdownItems = dropdown.findAllComponents(GlDropdownItem);
+ const mappingOptions = parsedMapping.payloadAlerFields.filter(({ type }) =>
+ types.includes(type),
+ );
+ return { searchBox, dropdownItems, mappingOptions };
+ };
+
it('renders column captions', () => {
expect(findColumnInRow(0, 0).text()).toContain(i18n.columns.gitlabKeyTitle);
expect(findColumnInRow(0, 2).text()).toContain(i18n.columns.payloadKeyTitle);
@@ -63,10 +72,7 @@ describe('AlertMappingBuilder', () => {
it('renders mapping dropdown for each field', () => {
alertFields.forEach(({ types }, index) => {
const dropdown = findColumnInRow(index + 1, 2).find(GlDropdown);
- const searchBox = dropdown.findComponent(GlSearchBoxByType);
- const dropdownItems = dropdown.findAllComponents(GlDropdownItem);
- const { nodes } = parsedMapping.samplePayload.payloadAlerFields;
- const mappingOptions = nodes.filter(({ type }) => types.includes(type));
+ const { searchBox, dropdownItems, mappingOptions } = getDropdownContent(dropdown, types);
expect(dropdown.exists()).toBe(true);
expect(searchBox.exists()).toBe(true);
@@ -80,11 +86,7 @@ describe('AlertMappingBuilder', () => {
expect(dropdown.exists()).toBe(Boolean(numberOfFallbacks));
if (numberOfFallbacks) {
- const searchBox = dropdown.findComponent(GlSearchBoxByType);
- const dropdownItems = dropdown.findAllComponents(GlDropdownItem);
- const { nodes } = parsedMapping.samplePayload.payloadAlerFields;
- const mappingOptions = nodes.filter(({ type }) => types.includes(type));
-
+ const { searchBox, dropdownItems, mappingOptions } = getDropdownContent(dropdown, types);
expect(searchBox.exists()).toBe(Boolean(numberOfFallbacks));
expect(dropdownItems).toHaveLength(mappingOptions.length);
}
diff --git a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js
index 02229b3d3da..d2dcff14432 100644
--- a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js
+++ b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js
@@ -1,29 +1,18 @@
-import {
- GlForm,
- GlFormSelect,
- GlCollapse,
- GlFormInput,
- GlToggle,
- GlFormTextarea,
-} from '@gitlab/ui';
+import { GlForm, GlFormSelect, GlFormInput, GlToggle, GlFormTextarea, GlTab } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import MappingBuilder from '~/alerts_settings/components/alert_mapping_builder.vue';
import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue';
import { typeSet } from '~/alerts_settings/constants';
-import alertFields from '../mocks/alertFields.json';
+import alertFields from '../mocks/alert_fields.json';
+import parsedMapping from '../mocks/parsed_mapping.json';
import { defaultAlertSettingsConfig } from './util';
describe('AlertsSettingsForm', () => {
let wrapper;
const mockToastShow = jest.fn();
- const createComponent = ({
- data = {},
- props = {},
- multipleHttpIntegrationsCustomMapping = false,
- multiIntegrations = true,
- } = {}) => {
+ const createComponent = ({ data = {}, props = {}, multiIntegrations = true } = {}) => {
wrapper = mount(AlertsSettingsForm, {
data() {
return { ...data };
@@ -35,10 +24,12 @@ describe('AlertsSettingsForm', () => {
},
provide: {
...defaultAlertSettingsConfig,
- glFeatures: { multipleHttpIntegrationsCustomMapping },
multiIntegrations,
},
mocks: {
+ $apollo: {
+ query: jest.fn(),
+ },
$toast: {
show: mockToastShow,
},
@@ -46,20 +37,20 @@ describe('AlertsSettingsForm', () => {
});
};
- const findForm = () => wrapper.find(GlForm);
- const findSelect = () => wrapper.find(GlFormSelect);
- const findFormSteps = () => wrapper.find(GlCollapse);
- const findFormFields = () => wrapper.findAll(GlFormInput);
- const findFormToggle = () => wrapper.find(GlToggle);
- const findTestPayloadSection = () => wrapper.find(`[id = "test-integration"]`);
+ const findForm = () => wrapper.findComponent(GlForm);
+ const findSelect = () => wrapper.findComponent(GlFormSelect);
+ const findFormFields = () => wrapper.findAllComponents(GlFormInput);
+ const findFormToggle = () => wrapper.findComponent(GlToggle);
+ const findSamplePayloadSection = () => wrapper.find('[data-testid="sample-payload-section"]');
const findMappingBuilderSection = () => wrapper.find(`[id = "mapping-builder"]`);
const findMappingBuilder = () => wrapper.findComponent(MappingBuilder);
const findSubmitButton = () => wrapper.find(`[type = "submit"]`);
const findMultiSupportText = () =>
wrapper.find(`[data-testid="multi-integrations-not-supported"]`);
- const findJsonTestSubmit = () => wrapper.find(`[data-testid="integration-test-and-submit"]`);
+ const findJsonTestSubmit = () => wrapper.find(`[data-testid="send-test-alert"]`);
const findJsonTextArea = () => wrapper.find(`[id = "test-payload"]`);
const findActionBtn = () => wrapper.find(`[data-testid="payload-action-btn"]`);
+ const findTabs = () => wrapper.findAllComponents(GlTab);
afterEach(() => {
if (wrapper) {
@@ -91,7 +82,7 @@ describe('AlertsSettingsForm', () => {
expect(findForm().exists()).toBe(true);
expect(findSelect().exists()).toBe(true);
expect(findMultiSupportText().exists()).toBe(false);
- expect(findFormSteps().attributes('visible')).toBeUndefined();
+ expect(findFormFields()).toHaveLength(0);
});
it('shows the rest of the form when the dropdown is used', async () => {
@@ -106,37 +97,47 @@ describe('AlertsSettingsForm', () => {
expect(findMultiSupportText().exists()).toBe(true);
});
- it('disabled the name input when the selected value is prometheus', async () => {
+ it('hides the name input when the selected value is prometheus', async () => {
createComponent();
await selectOptionAtIndex(2);
-
- expect(findFormFields().at(0).attributes('disabled')).toBe('disabled');
+ expect(findFormFields().at(0).attributes('id')).not.toBe('name-integration');
});
- });
-
- describe('submitting integration form', () => {
- describe('HTTP', () => {
- it('create', async () => {
- createComponent();
- const integrationName = 'Test integration';
- await selectOptionAtIndex(1);
- enableIntegration(0, integrationName);
-
- const submitBtn = findSubmitButton();
- expect(submitBtn.exists()).toBe(true);
- expect(submitBtn.text()).toBe('Save integration');
+ describe('form tabs', () => {
+ it('renders 3 tabs', () => {
+ expect(findTabs()).toHaveLength(3);
+ });
- findForm().trigger('submit');
+ it('only first tab is enabled on integration create', () => {
+ createComponent({
+ data: {
+ currentIntegration: null,
+ },
+ });
+ const tabs = findTabs();
+ expect(tabs.at(0).find('[role="tabpanel"]').classes('disabled')).toBe(false);
+ expect(tabs.at(1).find('[role="tabpanel"]').classes('disabled')).toBe(true);
+ expect(tabs.at(2).find('[role="tabpanel"]').classes('disabled')).toBe(true);
+ });
- expect(wrapper.emitted('create-new-integration')[0]).toEqual([
- { type: typeSet.http, variables: { name: integrationName, active: true } },
- ]);
+ it('all tabs are enabled on integration edit', () => {
+ createComponent({
+ data: {
+ currentIntegration: { id: 1 },
+ },
+ });
+ const tabs = findTabs();
+ expect(tabs.at(0).find('[role="tabpanel"]').classes('disabled')).toBe(false);
+ expect(tabs.at(1).find('[role="tabpanel"]').classes('disabled')).toBe(false);
+ expect(tabs.at(2).find('[role="tabpanel"]').classes('disabled')).toBe(false);
});
+ });
+ });
+ describe('submitting integration form', () => {
+ describe('HTTP', () => {
it('create with custom mapping', async () => {
createComponent({
- multipleHttpIntegrationsCustomMapping: true,
multiIntegrations: true,
props: { alertFields },
});
@@ -146,7 +147,7 @@ describe('AlertsSettingsForm', () => {
enableIntegration(0, integrationName);
- const sampleMapping = { field: 'test' };
+ const sampleMapping = parsedMapping.payloadAttributeMappings;
findMappingBuilder().vm.$emit('onMappingUpdate', sampleMapping);
findForm().trigger('submit');
@@ -157,7 +158,7 @@ describe('AlertsSettingsForm', () => {
name: integrationName,
active: true,
payloadAttributeMappings: sampleMapping,
- payloadExample: null,
+ payloadExample: '{}',
},
},
]);
@@ -182,23 +183,28 @@ describe('AlertsSettingsForm', () => {
findForm().trigger('submit');
- expect(wrapper.emitted('update-integration')[0]).toEqual([
- { type: typeSet.http, variables: { name: updatedIntegrationName, active: true } },
- ]);
+ expect(wrapper.emitted('update-integration')[0]).toEqual(
+ expect.arrayContaining([
+ {
+ type: typeSet.http,
+ variables: {
+ name: updatedIntegrationName,
+ active: true,
+ payloadAttributeMappings: [],
+ payloadExample: '{}',
+ },
+ },
+ ]),
+ );
});
});
describe('PROMETHEUS', () => {
it('create', async () => {
createComponent();
-
await selectOptionAtIndex(2);
-
const apiUrl = 'https://test.com';
- enableIntegration(1, apiUrl);
-
- findFormToggle().trigger('click');
-
+ enableIntegration(0, apiUrl);
const submitBtn = findSubmitButton();
expect(submitBtn.exists()).toBe(true);
expect(submitBtn.text()).toBe('Save integration');
@@ -222,7 +228,7 @@ describe('AlertsSettingsForm', () => {
});
const apiUrl = 'https://test-post.com';
- enableIntegration(1, apiUrl);
+ enableIntegration(0, apiUrl);
const submitBtn = findSubmitButton();
expect(submitBtn.exists()).toBe(true);
@@ -260,7 +266,7 @@ describe('AlertsSettingsForm', () => {
const jsonTestSubmit = findJsonTestSubmit();
expect(jsonTestSubmit.exists()).toBe(true);
- expect(jsonTestSubmit.text()).toBe('Save and test payload');
+ expect(jsonTestSubmit.text()).toBe('Send');
expect(jsonTestSubmit.props('disabled')).toBe(true);
});
@@ -275,56 +281,73 @@ describe('AlertsSettingsForm', () => {
});
describe('Test payload section for HTTP integration', () => {
+ const validSamplePayload = JSON.stringify(alertFields);
+ const emptySamplePayload = '{}';
+
beforeEach(() => {
createComponent({
- multipleHttpIntegrationsCustomMapping: true,
- props: {
+ data: {
currentIntegration: {
type: typeSet.http,
+ payloadExample: validSamplePayload,
+ payloadAttributeMappings: [],
},
- alertFields,
+ active: false,
+ resetPayloadAndMappingConfirmed: false,
},
+ props: { alertFields },
});
});
describe.each`
- active | resetSamplePayloadConfirmed | disabled
- ${true} | ${true} | ${undefined}
- ${false} | ${true} | ${'disabled'}
- ${true} | ${false} | ${'disabled'}
- ${false} | ${false} | ${'disabled'}
- `('', ({ active, resetSamplePayloadConfirmed, disabled }) => {
- const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed';
+ active | resetPayloadAndMappingConfirmed | disabled
+ ${true} | ${true} | ${undefined}
+ ${false} | ${true} | ${'disabled'}
+ ${true} | ${false} | ${'disabled'}
+ ${false} | ${false} | ${'disabled'}
+ `('', ({ active, resetPayloadAndMappingConfirmed, disabled }) => {
+ const payloadResetMsg = resetPayloadAndMappingConfirmed
+ ? 'was confirmed'
+ : 'was not confirmed';
const enabledState = disabled === 'disabled' ? 'disabled' : 'enabled';
const activeState = active ? 'active' : 'not active';
it(`textarea should be ${enabledState} when payload reset ${payloadResetMsg} and current integration is ${activeState}`, async () => {
wrapper.setData({
- customMapping: { samplePayload: true },
+ selectedIntegration: typeSet.http,
active,
- resetSamplePayloadConfirmed,
+ resetPayloadAndMappingConfirmed,
});
await wrapper.vm.$nextTick();
- expect(findTestPayloadSection().find(GlFormTextarea).attributes('disabled')).toBe(disabled);
+ expect(findSamplePayloadSection().find(GlFormTextarea).attributes('disabled')).toBe(
+ disabled,
+ );
});
});
describe('action buttons for sample payload', () => {
describe.each`
- resetSamplePayloadConfirmed | samplePayload | caption
- ${false} | ${true} | ${'Edit payload'}
- ${true} | ${false} | ${'Submit payload'}
- ${true} | ${true} | ${'Submit payload'}
- ${false} | ${false} | ${'Submit payload'}
- `('', ({ resetSamplePayloadConfirmed, samplePayload, caption }) => {
- const samplePayloadMsg = samplePayload ? 'was provided' : 'was not provided';
- const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed';
+ resetPayloadAndMappingConfirmed | payloadExample | caption
+ ${false} | ${validSamplePayload} | ${'Edit payload'}
+ ${true} | ${emptySamplePayload} | ${'Parse payload for custom mapping'}
+ ${true} | ${validSamplePayload} | ${'Parse payload for custom mapping'}
+ ${false} | ${emptySamplePayload} | ${'Parse payload for custom mapping'}
+ `('', ({ resetPayloadAndMappingConfirmed, payloadExample, caption }) => {
+ const samplePayloadMsg = payloadExample ? 'was provided' : 'was not provided';
+ const payloadResetMsg = resetPayloadAndMappingConfirmed
+ ? 'was confirmed'
+ : 'was not confirmed';
it(`shows ${caption} button when sample payload ${samplePayloadMsg} and payload reset ${payloadResetMsg}`, async () => {
wrapper.setData({
selectedIntegration: typeSet.http,
- customMapping: { samplePayload },
- resetSamplePayloadConfirmed,
+ currentIntegration: {
+ payloadExample,
+ type: typeSet.http,
+ active: true,
+ payloadAttributeMappings: [],
+ },
+ resetPayloadAndMappingConfirmed,
});
await wrapper.vm.$nextTick();
expect(findActionBtn().text()).toBe(caption);
@@ -333,16 +356,20 @@ describe('AlertsSettingsForm', () => {
});
describe('Parsing payload', () => {
- it('displays a toast message on successful parse', async () => {
- jest.useFakeTimers();
+ beforeEach(() => {
wrapper.setData({
selectedIntegration: typeSet.http,
- customMapping: { samplePayload: false },
+ resetPayloadAndMappingConfirmed: true,
});
- await wrapper.vm.$nextTick();
+ });
+ it('displays a toast message on successful parse', async () => {
+ jest.spyOn(wrapper.vm.$apollo, 'query').mockResolvedValue({
+ data: {
+ project: { alertManagementPayloadFields: [] },
+ },
+ });
findActionBtn().vm.$emit('click');
- jest.advanceTimersByTime(1000);
await waitForPromises();
@@ -350,27 +377,33 @@ describe('AlertsSettingsForm', () => {
'Sample payload has been parsed. You can now map the fields.',
);
});
+
+ it('displays an error message under payload field on unsuccessful parse', async () => {
+ const errorMessage = 'Error parsing paylod';
+ jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({ message: errorMessage });
+ findActionBtn().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect(findSamplePayloadSection().find('.invalid-feedback').text()).toBe(errorMessage);
+ });
});
});
describe('Mapping builder section', () => {
describe.each`
- alertFieldsProvided | multiIntegrations | featureFlag | integrationOption | visible
- ${true} | ${true} | ${true} | ${1} | ${true}
- ${true} | ${true} | ${true} | ${2} | ${false}
- ${true} | ${true} | ${false} | ${1} | ${false}
- ${true} | ${true} | ${false} | ${2} | ${false}
- ${true} | ${false} | ${true} | ${1} | ${false}
- ${false} | ${true} | ${true} | ${1} | ${false}
- `('', ({ alertFieldsProvided, multiIntegrations, featureFlag, integrationOption, visible }) => {
+ alertFieldsProvided | multiIntegrations | integrationOption | visible
+ ${true} | ${true} | ${1} | ${true}
+ ${true} | ${true} | ${2} | ${false}
+ ${true} | ${false} | ${1} | ${false}
+ ${false} | ${true} | ${1} | ${false}
+ `('', ({ alertFieldsProvided, multiIntegrations, integrationOption, visible }) => {
const visibleMsg = visible ? 'is rendered' : 'is not rendered';
- const featureFlagMsg = featureFlag ? 'is enabled' : 'is disabled';
const alertFieldsMsg = alertFieldsProvided ? 'are provided' : 'are not provided';
const integrationType = integrationOption === 1 ? typeSet.http : typeSet.prometheus;
- it(`${visibleMsg} when multipleHttpIntegrationsCustomMapping feature flag ${featureFlagMsg} and integration type is ${integrationType} and alert fields ${alertFieldsMsg}`, async () => {
+ it(`${visibleMsg} when integration type is ${integrationType} and alert fields ${alertFieldsMsg}`, async () => {
createComponent({
- multipleHttpIntegrationsCustomMapping: featureFlag,
multiIntegrations,
props: {
alertFields: alertFieldsProvided ? alertFields : [],
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 80293597ab6..77fac6dd022 100644
--- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js
+++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js
@@ -2,21 +2,26 @@ import { GlLoadingIcon } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import VueApollo from 'vue-apollo';
+import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql';
+import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
import waitForPromises from 'helpers/wait_for_promises';
import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue';
import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue';
-import AlertsSettingsWrapper from '~/alerts_settings/components/alerts_settings_wrapper.vue';
+import AlertsSettingsWrapper, {
+ i18n,
+} from '~/alerts_settings/components/alerts_settings_wrapper.vue';
import { typeSet } from '~/alerts_settings/constants';
-import createHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql';
import createPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql';
import destroyHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql';
import resetHttpTokenMutation from '~/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql';
import resetPrometheusTokenMutation from '~/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql';
-import updateHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql';
+import updateCurrentHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/update_current_http_integration.mutation.graphql';
+import updateCurrentPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql';
import updatePrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql';
import getIntegrationsQuery from '~/alerts_settings/graphql/queries/get_integrations.query.graphql';
+import alertsUpdateService from '~/alerts_settings/services';
import {
ADD_INTEGRATION_ERROR,
RESET_INTEGRATION_TOKEN_ERROR,
@@ -24,14 +29,15 @@ import {
INTEGRATION_PAYLOAD_TEST_ERROR,
DELETE_INTEGRATION_ERROR,
} from '~/alerts_settings/utils/error_messages';
-import createFlash from '~/flash';
+import createFlash, { FLASH_TYPES } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import {
createHttpVariables,
updateHttpVariables,
createPrometheusVariables,
updatePrometheusVariables,
- ID,
+ HTTP_ID,
+ PROMETHEUS_ID,
errorMsg,
getIntegrationsQueryResponse,
destroyIntegrationResponse,
@@ -50,9 +56,33 @@ describe('AlertsSettingsWrapper', () => {
let fakeApollo;
let destroyIntegrationHandler;
useMockIntersectionObserver();
+ const httpMappingData = {
+ payloadExample: '{"test: : "field"}',
+ payloadAttributeMappings: [],
+ payloadAlertFields: [],
+ };
+ const httpIntegrations = {
+ list: [
+ {
+ id: mockIntegrations[0].id,
+ ...httpMappingData,
+ },
+ {
+ id: mockIntegrations[1].id,
+ ...httpMappingData,
+ },
+ {
+ id: mockIntegrations[2].id,
+ httpMappingData,
+ },
+ ],
+ };
- const findLoader = () => wrapper.find(IntegrationsList).find(GlLoadingIcon);
+ const findLoader = () => wrapper.findComponent(IntegrationsList).findComponent(GlLoadingIcon);
+ const findIntegrationsList = () => wrapper.findComponent(IntegrationsList);
const findIntegrations = () => wrapper.find(IntegrationsList).findAll('table tbody tr');
+ const findAddIntegrationBtn = () => wrapper.find('[data-testid="add-integration-btn"]');
+ const findAlertsSettingsForm = () => wrapper.findComponent(AlertsSettingsForm);
async function destroyHttpIntegration(localWrapper) {
await jest.runOnlyPendingTimers();
@@ -119,14 +149,37 @@ describe('AlertsSettingsWrapper', () => {
wrapper = null;
});
- describe('rendered via default permissions', () => {
- it('renders the GraphQL alerts integrations list and new form', () => {
- createComponent();
- expect(wrapper.find(IntegrationsList).exists()).toBe(true);
- expect(wrapper.find(AlertsSettingsForm).exists()).toBe(true);
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent({
+ data: {
+ integrations: { list: mockIntegrations },
+ httpIntegrations: { list: [] },
+ currentIntegration: mockIntegrations[0],
+ },
+ loading: false,
+ });
});
- it('uses a loading state inside the IntegrationsList table', () => {
+ it('renders alerts integrations list and add new integration button by default', () => {
+ expect(findLoader().exists()).toBe(false);
+ expect(findIntegrations()).toHaveLength(mockIntegrations.length);
+ expect(findAddIntegrationBtn().exists()).toBe(true);
+ });
+
+ it('does NOT render settings form by default', () => {
+ expect(findAlertsSettingsForm().exists()).toBe(false);
+ });
+
+ it('hides `add new integration` button and displays setting form on btn click', async () => {
+ const addNewIntegrationBtn = findAddIntegrationBtn();
+ expect(addNewIntegrationBtn.exists()).toBe(true);
+ await addNewIntegrationBtn.trigger('click');
+ expect(findAlertsSettingsForm().exists()).toBe(true);
+ expect(addNewIntegrationBtn.exists()).toBe(false);
+ });
+
+ it('shows loading indicator inside the IntegrationsList table', () => {
createComponent({
data: { integrations: {} },
loading: true,
@@ -134,26 +187,24 @@ describe('AlertsSettingsWrapper', () => {
expect(wrapper.find(IntegrationsList).exists()).toBe(true);
expect(findLoader().exists()).toBe(true);
});
+ });
- it('renders the IntegrationsList table using the API data', () => {
+ describe('Integration updates', () => {
+ beforeEach(() => {
createComponent({
- data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
+ data: {
+ integrations: { list: mockIntegrations },
+ currentIntegration: mockIntegrations[0],
+ formVisible: true,
+ },
loading: false,
});
- expect(findLoader().exists()).toBe(false);
- expect(findIntegrations()).toHaveLength(mockIntegrations.length);
});
-
it('calls `$apollo.mutate` with `createHttpIntegrationMutation`', () => {
- createComponent({
- data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
- loading: false,
- });
-
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: { createHttpIntegrationMutation: { integration: { id: '1' } } },
});
- wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', {
+ findAlertsSettingsForm().vm.$emit('create-new-integration', {
type: typeSet.http,
variables: createHttpVariables,
});
@@ -167,15 +218,10 @@ describe('AlertsSettingsWrapper', () => {
});
it('calls `$apollo.mutate` with `updateHttpIntegrationMutation`', () => {
- createComponent({
- data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
- loading: false,
- });
-
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: { updateHttpIntegrationMutation: { integration: { id: '1' } } },
});
- wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', {
+ findAlertsSettingsForm().vm.$emit('update-integration', {
type: typeSet.http,
variables: updateHttpVariables,
});
@@ -187,37 +233,27 @@ describe('AlertsSettingsWrapper', () => {
});
it('calls `$apollo.mutate` with `resetHttpTokenMutation`', () => {
- createComponent({
- data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
- loading: false,
- });
-
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: { resetHttpTokenMutation: { integration: { id: '1' } } },
});
- wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', {
+ findAlertsSettingsForm().vm.$emit('reset-token', {
type: typeSet.http,
- variables: { id: ID },
+ variables: { id: HTTP_ID },
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: resetHttpTokenMutation,
variables: {
- id: ID,
+ id: HTTP_ID,
},
});
});
it('calls `$apollo.mutate` with `createPrometheusIntegrationMutation`', () => {
- createComponent({
- data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
- loading: false,
- });
-
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: { createPrometheusIntegrationMutation: { integration: { id: '2' } } },
});
- wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', {
+ findAlertsSettingsForm().vm.$emit('create-new-integration', {
type: typeSet.prometheus,
variables: createPrometheusVariables,
});
@@ -232,14 +268,18 @@ describe('AlertsSettingsWrapper', () => {
it('calls `$apollo.mutate` with `updatePrometheusIntegrationMutation`', () => {
createComponent({
- data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
+ data: {
+ integrations: { list: mockIntegrations },
+ currentIntegration: mockIntegrations[3],
+ formVisible: true,
+ },
loading: false,
});
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: { updatePrometheusIntegrationMutation: { integration: { id: '2' } } },
});
- wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', {
+ findAlertsSettingsForm().vm.$emit('update-integration', {
type: typeSet.prometheus,
variables: updatePrometheusVariables,
});
@@ -251,35 +291,25 @@ describe('AlertsSettingsWrapper', () => {
});
it('calls `$apollo.mutate` with `resetPrometheusTokenMutation`', () => {
- createComponent({
- data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
- loading: false,
- });
-
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: { resetPrometheusTokenMutation: { integration: { id: '1' } } },
});
- wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', {
+ findAlertsSettingsForm().vm.$emit('reset-token', {
type: typeSet.prometheus,
- variables: { id: ID },
+ variables: { id: PROMETHEUS_ID },
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: resetPrometheusTokenMutation,
variables: {
- id: ID,
+ id: PROMETHEUS_ID,
},
});
});
it('shows an error alert when integration creation fails ', async () => {
- createComponent({
- data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
- loading: false,
- });
-
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(ADD_INTEGRATION_ERROR);
- wrapper.find(AlertsSettingsForm).vm.$emit('create-new-integration', {});
+ findAlertsSettingsForm().vm.$emit('create-new-integration', {});
await waitForPromises();
@@ -287,28 +317,18 @@ describe('AlertsSettingsWrapper', () => {
});
it('shows an error alert when integration token reset fails ', async () => {
- createComponent({
- data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
- loading: false,
- });
-
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(RESET_INTEGRATION_TOKEN_ERROR);
- wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', {});
+ findAlertsSettingsForm().vm.$emit('reset-token', {});
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({ message: RESET_INTEGRATION_TOKEN_ERROR });
});
it('shows an error alert when integration update fails ', async () => {
- createComponent({
- data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
- loading: false,
- });
-
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg);
- wrapper.find(AlertsSettingsForm).vm.$emit('update-integration', {});
+ findAlertsSettingsForm().vm.$emit('update-integration', {});
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({ message: UPDATE_INTEGRATION_ERROR });
@@ -317,15 +337,74 @@ describe('AlertsSettingsWrapper', () => {
it('shows an error alert when integration test payload fails ', async () => {
const mock = new AxiosMockAdapter(axios);
mock.onPost(/(.*)/).replyOnce(403);
+ return wrapper.vm.testAlertPayload({ endpoint: '', data: '', token: '' }).then(() => {
+ expect(createFlash).toHaveBeenCalledWith({ message: INTEGRATION_PAYLOAD_TEST_ERROR });
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ mock.restore();
+ });
+ });
+
+ it('calls `$apollo.mutate` with `updateCurrentHttpIntegrationMutation` on HTTP integration edit', () => {
createComponent({
- data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
+ data: {
+ integrations: { list: mockIntegrations },
+ currentIntegration: mockIntegrations[0],
+ httpIntegrations,
+ },
loading: false,
});
- return wrapper.vm.validateAlertPayload({ endpoint: '', data: '', token: '' }).then(() => {
- expect(createFlash).toHaveBeenCalledWith({ message: INTEGRATION_PAYLOAD_TEST_ERROR });
- expect(createFlash).toHaveBeenCalledTimes(1);
- mock.restore();
+ jest.spyOn(wrapper.vm.$apollo, 'mutate');
+ findIntegrationsList().vm.$emit('edit-integration', updateHttpVariables);
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: updateCurrentHttpIntegrationMutation,
+ variables: { ...mockIntegrations[0], ...httpMappingData },
+ });
+ });
+
+ it('calls `$apollo.mutate` with `updateCurrentPrometheusIntegrationMutation` on PROMETHEUS integration edit', () => {
+ createComponent({
+ data: {
+ integrations: { list: mockIntegrations },
+ currentIntegration: mockIntegrations[3],
+ httpIntegrations,
+ },
+ loading: false,
+ });
+
+ jest.spyOn(wrapper.vm.$apollo, 'mutate');
+ findIntegrationsList().vm.$emit('edit-integration', updatePrometheusVariables);
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: updateCurrentPrometheusIntegrationMutation,
+ variables: mockIntegrations[3],
+ });
+ });
+
+ describe('Test alert', () => {
+ it('makes `updateTestAlert` service call', async () => {
+ jest.spyOn(alertsUpdateService, 'updateTestAlert').mockResolvedValueOnce();
+ const testPayload = '{"title":"test"}';
+ findAlertsSettingsForm().vm.$emit('test-alert-payload', testPayload);
+ expect(alertsUpdateService.updateTestAlert).toHaveBeenCalledWith(testPayload);
+ });
+
+ it('shows success message on successful test', async () => {
+ jest.spyOn(alertsUpdateService, 'updateTestAlert').mockResolvedValueOnce({});
+ findAlertsSettingsForm().vm.$emit('test-alert-payload', '');
+ await waitForPromises();
+ expect(createFlash).toHaveBeenCalledWith({
+ message: i18n.alertSent,
+ type: FLASH_TYPES.SUCCESS,
+ });
+ });
+
+ it('shows error message when test alert fails', async () => {
+ jest.spyOn(alertsUpdateService, 'updateTestAlert').mockRejectedValueOnce({});
+ findAlertsSettingsForm().vm.$emit('test-alert-payload', '');
+ await waitForPromises();
+ expect(createFlash).toHaveBeenCalledWith({
+ message: INTEGRATION_PAYLOAD_TEST_ERROR,
+ });
});
});
});
diff --git a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js
index e0eba1e8421..828580a436b 100644
--- a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js
+++ b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js
@@ -1,29 +1,34 @@
const projectPath = '';
-export const ID = 'gid://gitlab/AlertManagement::HttpIntegration/7';
+export const HTTP_ID = 'gid://gitlab/AlertManagement::HttpIntegration/7';
+export const PROMETHEUS_ID = 'gid://gitlab/PrometheusService/12';
export const errorMsg = 'Something went wrong';
export const createHttpVariables = {
name: 'Test Pre',
active: true,
projectPath,
+ type: 'HTTP',
};
export const updateHttpVariables = {
name: 'Test Pre',
active: true,
- id: ID,
+ id: HTTP_ID,
+ type: 'HTTP',
};
export const createPrometheusVariables = {
apiUrl: 'https://test-pre.com',
active: true,
projectPath,
+ type: 'PROMETHEUS',
};
export const updatePrometheusVariables = {
apiUrl: 'https://test-pre.com',
active: true,
- id: ID,
+ id: PROMETHEUS_ID,
+ type: 'PROMETHEUS',
};
export const getIntegrationsQueryResponse = {
@@ -99,6 +104,9 @@ export const destroyIntegrationResponse = {
'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json',
token: '89eb01df471d990ff5162a1c640408cf',
apiUrl: null,
+ payloadExample: '{"field": "value"}',
+ payloadAttributeMappings: [],
+ payloadAlertFields: [],
},
},
},
@@ -117,6 +125,9 @@ export const destroyIntegrationResponseWithErrors = {
'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json',
token: '89eb01df471d990ff5162a1c640408cf',
apiUrl: null,
+ payloadExample: '{"field": "value"}',
+ payloadAttributeMappings: [],
+ payloadAlertFields: [],
},
},
},
diff --git a/spec/frontend/alerts_settings/mocks/alertFields.json b/spec/frontend/alerts_settings/mocks/alert_fields.json
index ffe59dd0c05..ffe59dd0c05 100644
--- a/spec/frontend/alerts_settings/mocks/alertFields.json
+++ b/spec/frontend/alerts_settings/mocks/alert_fields.json
diff --git a/spec/frontend/alerts_settings/mocks/parsed_mapping.json b/spec/frontend/alerts_settings/mocks/parsed_mapping.json
new file mode 100644
index 00000000000..e985671a923
--- /dev/null
+++ b/spec/frontend/alerts_settings/mocks/parsed_mapping.json
@@ -0,0 +1,122 @@
+{
+ "payloadAlerFields": [
+ {
+ "path": [
+ "dashboardId"
+ ],
+ "label": "Dashboard Id",
+ "type": "string"
+ },
+ {
+ "path": [
+ "evalMatches"
+ ],
+ "label": "Eval Matches",
+ "type": "array"
+ },
+ {
+ "path": [
+ "createdAt"
+ ],
+ "label": "Created At",
+ "type": "datetime"
+ },
+ {
+ "path": [
+ "imageUrl"
+ ],
+ "label": "Image Url",
+ "type": "string"
+ },
+ {
+ "path": [
+ "message"
+ ],
+ "label": "Message",
+ "type": "string"
+ },
+ {
+ "path": [
+ "orgId"
+ ],
+ "label": "Org Id",
+ "type": "string"
+ },
+ {
+ "path": [
+ "panelId"
+ ],
+ "label": "Panel Id",
+ "type": "string"
+ },
+ {
+ "path": [
+ "ruleId"
+ ],
+ "label": "Rule Id",
+ "type": "string"
+ },
+ {
+ "path": [
+ "ruleName"
+ ],
+ "label": "Rule Name",
+ "type": "string"
+ },
+ {
+ "path": [
+ "ruleUrl"
+ ],
+ "label": "Rule Url",
+ "type": "string"
+ },
+ {
+ "path": [
+ "state"
+ ],
+ "label": "State",
+ "type": "string"
+ },
+ {
+ "path": [
+ "title"
+ ],
+ "label": "Title",
+ "type": "string"
+ },
+ {
+ "path": [
+ "tags",
+ "tag"
+ ],
+ "label": "Tags",
+ "type": "string"
+ }
+ ],
+ "payloadAttributeMappings": [
+ {
+ "fieldName": "title",
+ "label": "Title",
+ "type": "STRING",
+ "path": ["title"]
+ },
+ {
+ "fieldName": "description",
+ "label": "description",
+ "type": "STRING",
+ "path": ["description"]
+ },
+ {
+ "fieldName": "hosts",
+ "label": "Host",
+ "type": "ARRAY",
+ "path": ["hosts", "host"]
+ },
+ {
+ "fieldName": "startTime",
+ "label": "Created Atd",
+ "type": "STRING",
+ "path": ["time", "createdAt"]
+ }
+ ]
+}
diff --git a/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js b/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js
index 8c1977ffebe..62b95c6078b 100644
--- a/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js
+++ b/spec/frontend/alerts_settings/utils/mapping_transformations_spec.js
@@ -1,29 +1,25 @@
-import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json';
-import {
- getMappingData,
- getPayloadFields,
- transformForSave,
-} from '~/alerts_settings/utils/mapping_transformations';
-import alertFields from '../mocks/alertFields.json';
+import { getMappingData, transformForSave } from '~/alerts_settings/utils/mapping_transformations';
+import alertFields from '../mocks/alert_fields.json';
+import parsedMapping from '../mocks/parsed_mapping.json';
describe('Mapping Transformation Utilities', () => {
const nameField = {
label: 'Name',
path: ['alert', 'name'],
- type: 'string',
+ type: 'STRING',
};
const dashboardField = {
label: 'Dashboard Id',
path: ['alert', 'dashboardId'],
- type: 'string',
+ type: 'STRING',
};
describe('getMappingData', () => {
it('should return mapping data', () => {
const result = getMappingData(
alertFields,
- getPayloadFields(parsedMapping.samplePayload.payloadAlerFields.nodes.slice(0, 3)),
- parsedMapping.storedMapping.nodes.slice(0, 3),
+ parsedMapping.payloadAlerFields.slice(0, 3),
+ parsedMapping.payloadAttributeMappings.slice(0, 3),
);
result.forEach((data, index) => {
@@ -44,8 +40,8 @@ describe('Mapping Transformation Utilities', () => {
const mockMappingData = [
{
name: fieldName,
- mapping: 'alert_name',
- mappingFields: getPayloadFields([dashboardField, nameField]),
+ mapping: ['alert', 'name'],
+ mappingFields: [dashboardField, nameField],
},
];
const result = transformForSave(mockMappingData);
@@ -61,21 +57,11 @@ describe('Mapping Transformation Utilities', () => {
{
name: fieldName,
mapping: null,
- mappingFields: getPayloadFields([nameField, dashboardField]),
+ mappingFields: [nameField, dashboardField],
},
];
const result = transformForSave(mockMappingData);
expect(result).toEqual([]);
});
});
-
- describe('getPayloadFields', () => {
- it('should add name field to each payload field', () => {
- const result = getPayloadFields([nameField, dashboardField]);
- expect(result).toEqual([
- { ...nameField, name: 'alert_name' },
- { ...dashboardField, name: 'alert_dashboardId' },
- ]);
- });
- });
});
diff --git a/spec/frontend/analytics/instance_statistics/components/app_spec.js b/spec/frontend/analytics/instance_statistics/components/app_spec.js
deleted file mode 100644
index b945cc20bd6..00000000000
--- a/spec/frontend/analytics/instance_statistics/components/app_spec.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue';
-import InstanceStatisticsApp from '~/analytics/instance_statistics/components/app.vue';
-import InstanceStatisticsCountChart from '~/analytics/instance_statistics/components/instance_statistics_count_chart.vue';
-import ProjectsAndGroupsChart from '~/analytics/instance_statistics/components/projects_and_groups_chart.vue';
-import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue';
-
-describe('InstanceStatisticsApp', () => {
- let wrapper;
-
- const createComponent = () => {
- wrapper = shallowMount(InstanceStatisticsApp);
- };
-
- beforeEach(() => {
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- it('displays the instance counts component', () => {
- expect(wrapper.find(InstanceCounts).exists()).toBe(true);
- });
-
- ['Pipelines', 'Issues & Merge Requests'].forEach((instance) => {
- it(`displays the ${instance} chart`, () => {
- const chartTitles = wrapper
- .findAll(InstanceStatisticsCountChart)
- .wrappers.map((chartComponent) => chartComponent.props('chartTitle'));
-
- expect(chartTitles).toContain(instance);
- });
- });
-
- it('displays the users chart component', () => {
- expect(wrapper.find(UsersChart).exists()).toBe(true);
- });
-
- it('displays the projects and groups chart component', () => {
- expect(wrapper.find(ProjectsAndGroupsChart).exists()).toBe(true);
- });
-});
diff --git a/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js
deleted file mode 100644
index bbfc65f19b1..00000000000
--- a/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js
+++ /dev/null
@@ -1,215 +0,0 @@
-import { GlAlert } from '@gitlab/ui';
-import { GlLineChart } from '@gitlab/ui/dist/charts';
-import { createLocalVue, shallowMount } from '@vue/test-utils';
-import VueApollo from 'vue-apollo';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import ProjectsAndGroupChart from '~/analytics/instance_statistics/components/projects_and_groups_chart.vue';
-import groupsQuery from '~/analytics/instance_statistics/graphql/queries/groups.query.graphql';
-import projectsQuery from '~/analytics/instance_statistics/graphql/queries/projects.query.graphql';
-import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
-import { mockQueryResponse } from '../apollo_mock_data';
-import { mockCountsData2, roundedSortedCountsMonthlyChartData2 } from '../mock_data';
-
-const localVue = createLocalVue();
-localVue.use(VueApollo);
-
-describe('ProjectsAndGroupChart', () => {
- let wrapper;
- let queryResponses = { projects: null, groups: null };
- const mockAdditionalData = [{ recordedAt: '2020-07-21', count: 5 }];
-
- const createComponent = ({
- loadingError = false,
- projects = [],
- groups = [],
- projectsLoading = false,
- groupsLoading = false,
- projectsAdditionalData = [],
- groupsAdditionalData = [],
- } = {}) => {
- queryResponses = {
- projects: mockQueryResponse({
- key: 'projects',
- data: projects,
- loading: projectsLoading,
- additionalData: projectsAdditionalData,
- }),
- groups: mockQueryResponse({
- key: 'groups',
- data: groups,
- loading: groupsLoading,
- additionalData: groupsAdditionalData,
- }),
- };
-
- return shallowMount(ProjectsAndGroupChart, {
- props: {
- startDate: new Date(2020, 9, 26),
- endDate: new Date(2020, 10, 1),
- totalDataPoints: mockCountsData2.length,
- },
- localVue,
- apolloProvider: createMockApollo([
- [projectsQuery, queryResponses.projects],
- [groupsQuery, queryResponses.groups],
- ]),
- data() {
- return { loadingError };
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- queryResponses = {
- projects: null,
- groups: null,
- };
- });
-
- const findLoader = () => wrapper.find(ChartSkeletonLoader);
- const findAlert = () => wrapper.find(GlAlert);
- const findChart = () => wrapper.find(GlLineChart);
-
- describe('while loading', () => {
- beforeEach(() => {
- wrapper = createComponent({ projectsLoading: true, groupsLoading: true });
- });
-
- it('displays the skeleton loader', () => {
- expect(findLoader().exists()).toBe(true);
- });
-
- it('hides the chart', () => {
- expect(findChart().exists()).toBe(false);
- });
- });
-
- describe('while loading 1 data set', () => {
- beforeEach(async () => {
- wrapper = createComponent({
- projects: mockCountsData2,
- groupsLoading: true,
- });
-
- await wrapper.vm.$nextTick();
- });
-
- it('hides the skeleton loader', () => {
- expect(findLoader().exists()).toBe(false);
- });
-
- it('renders the chart', () => {
- expect(findChart().exists()).toBe(true);
- });
- });
-
- describe('without data', () => {
- beforeEach(async () => {
- wrapper = createComponent({ projects: [] });
- await wrapper.vm.$nextTick();
- });
-
- it('renders a no data message', () => {
- expect(findAlert().text()).toBe('No data available.');
- });
-
- it('hides the skeleton loader', () => {
- expect(findLoader().exists()).toBe(false);
- });
-
- it('does not render the chart', () => {
- expect(findChart().exists()).toBe(false);
- });
- });
-
- describe('with data', () => {
- beforeEach(async () => {
- wrapper = createComponent({ projects: mockCountsData2 });
- await wrapper.vm.$nextTick();
- });
-
- it('hides the skeleton loader', () => {
- expect(findLoader().exists()).toBe(false);
- });
-
- it('renders the chart', () => {
- expect(findChart().exists()).toBe(true);
- });
-
- it('passes the data to the line chart', () => {
- expect(findChart().props('data')).toEqual([
- { data: roundedSortedCountsMonthlyChartData2, name: 'Total projects' },
- { data: [], name: 'Total groups' },
- ]);
- });
- });
-
- describe('with errors', () => {
- beforeEach(async () => {
- wrapper = createComponent({ loadingError: true });
- await wrapper.vm.$nextTick();
- });
-
- it('renders an error message', () => {
- expect(findAlert().text()).toBe('No data available.');
- });
-
- it('hides the skeleton loader', () => {
- expect(findLoader().exists()).toBe(false);
- });
-
- it('hides the chart', () => {
- expect(findChart().exists()).toBe(false);
- });
- });
-
- describe.each`
- metric | loadingState | newData
- ${'projects'} | ${{ projectsAdditionalData: mockAdditionalData }} | ${{ projects: mockCountsData2 }}
- ${'groups'} | ${{ groupsAdditionalData: mockAdditionalData }} | ${{ groups: mockCountsData2 }}
- `('$metric - fetchMore', ({ metric, loadingState, newData }) => {
- describe('when the fetchMore query returns data', () => {
- beforeEach(async () => {
- wrapper = createComponent({
- ...loadingState,
- ...newData,
- });
-
- jest.spyOn(wrapper.vm.$apollo.queries[metric], 'fetchMore');
- await wrapper.vm.$nextTick();
- });
-
- it('requests data twice', () => {
- expect(queryResponses[metric]).toBeCalledTimes(2);
- });
-
- it('calls fetchMore', () => {
- expect(wrapper.vm.$apollo.queries[metric].fetchMore).toHaveBeenCalledTimes(1);
- });
- });
-
- describe('when the fetchMore query throws an error', () => {
- beforeEach(() => {
- wrapper = createComponent({
- ...loadingState,
- ...newData,
- });
-
- jest
- .spyOn(wrapper.vm.$apollo.queries[metric], 'fetchMore')
- .mockImplementation(jest.fn().mockRejectedValue());
- return wrapper.vm.$nextTick();
- });
-
- it('calls fetchMore', () => {
- expect(wrapper.vm.$apollo.queries[metric].fetchMore).toHaveBeenCalledTimes(1);
- });
-
- it('renders an error message', () => {
- expect(findAlert().text()).toBe('No data available.');
- });
- });
- });
-});
diff --git a/spec/frontend/analytics/instance_statistics/apollo_mock_data.js b/spec/frontend/analytics/usage_trends/apollo_mock_data.js
index 98eabd577ee..98eabd577ee 100644
--- a/spec/frontend/analytics/instance_statistics/apollo_mock_data.js
+++ b/spec/frontend/analytics/usage_trends/apollo_mock_data.js
diff --git a/spec/frontend/analytics/instance_statistics/components/__snapshots__/instance_statistics_count_chart_spec.js.snap b/spec/frontend/analytics/usage_trends/components/__snapshots__/usage_trends_count_chart_spec.js.snap
index 29bcd5f223b..65de69c2692 100644
--- a/spec/frontend/analytics/instance_statistics/components/__snapshots__/instance_statistics_count_chart_spec.js.snap
+++ b/spec/frontend/analytics/usage_trends/components/__snapshots__/usage_trends_count_chart_spec.js.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`InstanceStatisticsCountChart when fetching more data when the fetchMore query returns data passes the data to the line chart 1`] = `
+exports[`UsageTrendsCountChart when fetching more data when the fetchMore query returns data passes the data to the line chart 1`] = `
Array [
Object {
"data": Array [
@@ -22,7 +22,7 @@ Array [
]
`;
-exports[`InstanceStatisticsCountChart with data passes the data to the line chart 1`] = `
+exports[`UsageTrendsCountChart with data passes the data to the line chart 1`] = `
Array [
Object {
"data": Array [
diff --git a/spec/frontend/analytics/usage_trends/components/app_spec.js b/spec/frontend/analytics/usage_trends/components/app_spec.js
new file mode 100644
index 00000000000..f0306ea72e3
--- /dev/null
+++ b/spec/frontend/analytics/usage_trends/components/app_spec.js
@@ -0,0 +1,40 @@
+import { shallowMount } from '@vue/test-utils';
+import UsageTrendsApp from '~/analytics/usage_trends/components/app.vue';
+import UsageCounts from '~/analytics/usage_trends/components/usage_counts.vue';
+import UsageTrendsCountChart from '~/analytics/usage_trends/components/usage_trends_count_chart.vue';
+import UsersChart from '~/analytics/usage_trends/components/users_chart.vue';
+
+describe('UsageTrendsApp', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(UsageTrendsApp);
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('displays the usage counts component', () => {
+ expect(wrapper.find(UsageCounts).exists()).toBe(true);
+ });
+
+ ['Total projects & groups', 'Pipelines', 'Issues & Merge Requests'].forEach((usage) => {
+ it(`displays the ${usage} chart`, () => {
+ const chartTitles = wrapper
+ .findAll(UsageTrendsCountChart)
+ .wrappers.map((chartComponent) => chartComponent.props('chartTitle'));
+
+ expect(chartTitles).toContain(usage);
+ });
+ });
+
+ it('displays the users chart component', () => {
+ expect(wrapper.find(UsersChart).exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js b/spec/frontend/analytics/usage_trends/components/instance_counts_spec.js
index 12b5e14b9c4..707d2cc310f 100644
--- a/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js
+++ b/spec/frontend/analytics/usage_trends/components/instance_counts_spec.js
@@ -1,9 +1,9 @@
import { shallowMount } from '@vue/test-utils';
-import InstanceCounts from '~/analytics/instance_statistics/components/instance_counts.vue';
import MetricCard from '~/analytics/shared/components/metric_card.vue';
-import { mockInstanceCounts } from '../mock_data';
+import UsageCounts from '~/analytics/usage_trends/components/usage_counts.vue';
+import { mockUsageCounts } from '../mock_data';
-describe('InstanceCounts', () => {
+describe('UsageCounts', () => {
let wrapper;
const createComponent = ({ loading = false, data = {} } = {}) => {
@@ -15,7 +15,7 @@ describe('InstanceCounts', () => {
},
};
- wrapper = shallowMount(InstanceCounts, {
+ wrapper = shallowMount(UsageCounts, {
mocks: { $apollo },
data() {
return {
@@ -44,11 +44,11 @@ describe('InstanceCounts', () => {
describe('with data', () => {
beforeEach(() => {
- createComponent({ data: { counts: mockInstanceCounts } });
+ createComponent({ data: { counts: mockUsageCounts } });
});
it('passes the counts data to the metric card', () => {
- expect(findMetricCard().props('metrics')).toEqual(mockInstanceCounts);
+ expect(findMetricCard().props('metrics')).toEqual(mockUsageCounts);
});
});
});
diff --git a/spec/frontend/analytics/instance_statistics/components/instance_statistics_count_chart_spec.js b/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js
index e80dcdff426..7c2df3fe8c4 100644
--- a/spec/frontend/analytics/instance_statistics/components/instance_statistics_count_chart_spec.js
+++ b/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js
@@ -3,8 +3,8 @@ import { GlLineChart } from '@gitlab/ui/dist/charts';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
-import InstanceStatisticsCountChart from '~/analytics/instance_statistics/components/instance_statistics_count_chart.vue';
-import statsQuery from '~/analytics/instance_statistics/graphql/queries/instance_count.query.graphql';
+import UsageTrendsCountChart from '~/analytics/usage_trends/components/usage_trends_count_chart.vue';
+import statsQuery from '~/analytics/usage_trends/graphql/queries/usage_count.query.graphql';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { mockQueryResponse, mockApolloResponse } from '../apollo_mock_data';
import { mockCountsData1 } from '../mock_data';
@@ -15,7 +15,7 @@ localVue.use(VueApollo);
const loadChartErrorMessage = 'My load error message';
const noDataMessage = 'My no data message';
-const queryResponseDataKey = 'instanceStatisticsMeasurements';
+const queryResponseDataKey = 'usageTrendsMeasurements';
const identifier = 'MOCK_QUERY';
const mockQueryConfig = {
identifier,
@@ -33,12 +33,12 @@ const mockChartConfig = {
queries: [mockQueryConfig],
};
-describe('InstanceStatisticsCountChart', () => {
+describe('UsageTrendsCountChart', () => {
let wrapper;
let queryHandler;
const createComponent = ({ responseHandler }) => {
- return shallowMount(InstanceStatisticsCountChart, {
+ return shallowMount(UsageTrendsCountChart, {
localVue,
apolloProvider: createMockApollo([[statsQuery, responseHandler]]),
propsData: { ...mockChartConfig },
diff --git a/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js b/spec/frontend/analytics/usage_trends/components/users_chart_spec.js
index d857b7fae61..6adfcca11ac 100644
--- a/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js
+++ b/spec/frontend/analytics/usage_trends/components/users_chart_spec.js
@@ -3,8 +3,8 @@ import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
-import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue';
-import usersQuery from '~/analytics/instance_statistics/graphql/queries/users.query.graphql';
+import UsersChart from '~/analytics/usage_trends/components/users_chart.vue';
+import usersQuery from '~/analytics/usage_trends/graphql/queries/users.query.graphql';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { mockQueryResponse } from '../apollo_mock_data';
import {
diff --git a/spec/frontend/analytics/instance_statistics/mock_data.js b/spec/frontend/analytics/usage_trends/mock_data.js
index e86e552a952..d96dfa26209 100644
--- a/spec/frontend/analytics/instance_statistics/mock_data.js
+++ b/spec/frontend/analytics/usage_trends/mock_data.js
@@ -1,4 +1,4 @@
-export const mockInstanceCounts = [
+export const mockUsageCounts = [
{ key: 'projects', value: 10, label: 'Projects' },
{ key: 'groups', value: 20, label: 'Group' },
];
diff --git a/spec/frontend/analytics/instance_statistics/utils_spec.js b/spec/frontend/analytics/usage_trends/utils_spec.js
index 3fd89c7f740..656f310dda7 100644
--- a/spec/frontend/analytics/instance_statistics/utils_spec.js
+++ b/spec/frontend/analytics/usage_trends/utils_spec.js
@@ -2,7 +2,7 @@ import {
getAverageByMonth,
getEarliestDate,
generateDataKeys,
-} from '~/analytics/instance_statistics/utils';
+} from '~/analytics/usage_trends/utils';
import {
mockCountsData1,
mockCountsData2,
diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js
index d2522a0124a..d6e1b170dd3 100644
--- a/spec/frontend/api_spec.js
+++ b/spec/frontend/api_spec.js
@@ -482,6 +482,30 @@ describe('Api', () => {
});
});
+ describe('projectShareWithGroup', () => {
+ it('invites a group to share access with the authenticated project', () => {
+ const projectId = 1;
+ const sharedGroupId = 99;
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/share`;
+ const options = {
+ group_id: sharedGroupId,
+ group_access: 10,
+ expires_at: undefined,
+ };
+
+ jest.spyOn(axios, 'post');
+
+ mock.onPost(expectedUrl).reply(200, {
+ status: 'success',
+ });
+
+ return Api.projectShareWithGroup(projectId, options).then(({ data }) => {
+ expect(data.status).toBe('success');
+ expect(axios.post).toHaveBeenCalledWith(expectedUrl, options);
+ });
+ });
+ });
+
describe('projectMilestones', () => {
it('fetches project milestones', (done) => {
const projectId = 1;
@@ -638,6 +662,30 @@ describe('Api', () => {
});
});
+ describe('groupShareWithGroup', () => {
+ it('invites a group to share access with the authenticated group', () => {
+ const groupId = 1;
+ const sharedGroupId = 99;
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/share`;
+ const options = {
+ group_id: sharedGroupId,
+ group_access: 10,
+ expires_at: undefined,
+ };
+
+ jest.spyOn(axios, 'post');
+
+ mock.onPost(expectedUrl).reply(200, {
+ status: 'success',
+ });
+
+ return Api.groupShareWithGroup(groupId, options).then(({ data }) => {
+ expect(data.status).toBe('success');
+ expect(axios.post).toHaveBeenCalledWith(expectedUrl, options);
+ });
+ });
+ });
+
describe('commit', () => {
const projectId = 'user/project';
const sha = 'abcd0123';
diff --git a/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap b/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap
index bf33aa731ef..2691e11e616 100644
--- a/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap
+++ b/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap
@@ -7,7 +7,6 @@ exports[`Keep latest artifact checkbox when application keep latest artifact set
<b-form-checkbox-stub
checked="true"
class="gl-form-checkbox"
- plain="true"
value="true"
>
<strong
diff --git a/spec/frontend/authentication/u2f/authenticate_spec.js b/spec/frontend/authentication/u2f/authenticate_spec.js
index bf50ee88035..153d4be56af 100644
--- a/spec/frontend/authentication/u2f/authenticate_spec.js
+++ b/spec/frontend/authentication/u2f/authenticate_spec.js
@@ -8,8 +8,6 @@ describe('U2FAuthenticate', () => {
let container;
let component;
- preloadFixtures('u2f/authenticate.html');
-
beforeEach(() => {
loadFixtures('u2f/authenticate.html');
u2fDevice = new MockU2FDevice();
diff --git a/spec/frontend/authentication/u2f/register_spec.js b/spec/frontend/authentication/u2f/register_spec.js
index 9cbadbc2fef..a814144ac7a 100644
--- a/spec/frontend/authentication/u2f/register_spec.js
+++ b/spec/frontend/authentication/u2f/register_spec.js
@@ -8,8 +8,6 @@ describe('U2FRegister', () => {
let container;
let component;
- preloadFixtures('u2f/register.html');
-
beforeEach((done) => {
loadFixtures('u2f/register.html');
u2fDevice = new MockU2FDevice();
diff --git a/spec/frontend/authentication/webauthn/authenticate_spec.js b/spec/frontend/authentication/webauthn/authenticate_spec.js
index 0a82adfd0ee..8b27560bbbe 100644
--- a/spec/frontend/authentication/webauthn/authenticate_spec.js
+++ b/spec/frontend/authentication/webauthn/authenticate_spec.js
@@ -13,7 +13,6 @@ const mockResponse = {
};
describe('WebAuthnAuthenticate', () => {
- preloadFixtures('webauthn/authenticate.html');
useMockNavigatorCredentials();
let fallbackElement;
diff --git a/spec/frontend/authentication/webauthn/register_spec.js b/spec/frontend/authentication/webauthn/register_spec.js
index 1de952d176d..43cd3d7ca34 100644
--- a/spec/frontend/authentication/webauthn/register_spec.js
+++ b/spec/frontend/authentication/webauthn/register_spec.js
@@ -5,7 +5,6 @@ import MockWebAuthnDevice from './mock_webauthn_device';
import { useMockNavigatorCredentials } from './util';
describe('WebAuthnRegister', () => {
- preloadFixtures('webauthn/register.html');
useMockNavigatorCredentials();
const mockResponse = {
diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js
index edd17cfd810..09270174674 100644
--- a/spec/frontend/awards_handler_spec.js
+++ b/spec/frontend/awards_handler_spec.js
@@ -60,7 +60,6 @@ describe('AwardsHandler', () => {
u: '6.0',
},
};
- preloadFixtures('snippets/show.html');
const openAndWaitForEmojiMenu = (sel = '.js-add-award') => {
$(sel).eq(0).click();
@@ -189,8 +188,6 @@ describe('AwardsHandler', () => {
expect($thumbsUpEmoji.hasClass('active')).toBe(true);
expect($thumbsDownEmoji.hasClass('active')).toBe(false);
- $thumbsUpEmoji.tooltip();
- $thumbsDownEmoji.tooltip();
awardsHandler.addAward($votesBlock, awardUrl, 'thumbsdown', true);
expect($thumbsUpEmoji.hasClass('active')).toBe(false);
@@ -218,9 +215,8 @@ describe('AwardsHandler', () => {
const $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent();
$thumbsUpEmoji.attr('data-title', 'sam, jerry, max, and andy');
awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false);
- $thumbsUpEmoji.tooltip();
- expect($thumbsUpEmoji.data('originalTitle')).toBe('You, sam, jerry, max, and andy');
+ expect($thumbsUpEmoji.attr('title')).toBe('You, sam, jerry, max, and andy');
});
it('handles the special case where "You" is not cleanly comma separated', () => {
@@ -229,9 +225,8 @@ describe('AwardsHandler', () => {
const $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent();
$thumbsUpEmoji.attr('data-title', 'sam');
awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false);
- $thumbsUpEmoji.tooltip();
- expect($thumbsUpEmoji.data('originalTitle')).toBe('You and sam');
+ expect($thumbsUpEmoji.attr('title')).toBe('You and sam');
});
});
@@ -243,9 +238,8 @@ describe('AwardsHandler', () => {
$thumbsUpEmoji.attr('data-title', 'You, sam, jerry, max, and andy');
$thumbsUpEmoji.addClass('active');
awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false);
- $thumbsUpEmoji.tooltip();
- expect($thumbsUpEmoji.data('originalTitle')).toBe('sam, jerry, max, and andy');
+ expect($thumbsUpEmoji.attr('title')).toBe('sam, jerry, max, and andy');
});
it('handles the special case where "You" is not cleanly comma separated', () => {
@@ -255,9 +249,8 @@ describe('AwardsHandler', () => {
$thumbsUpEmoji.attr('data-title', 'You and sam');
$thumbsUpEmoji.addClass('active');
awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false);
- $thumbsUpEmoji.tooltip();
- expect($thumbsUpEmoji.data('originalTitle')).toBe('sam');
+ expect($thumbsUpEmoji.attr('title')).toBe('sam');
});
});
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 885e02ef60f..da19265ce82 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
@@ -1,6 +1,7 @@
import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper';
+import service from '~/batch_comments/services/drafts_service';
import * as actions from '~/batch_comments/stores/modules/batch_comments/actions';
import axios from '~/lib/utils/axios_utils';
@@ -201,6 +202,12 @@ describe('Batch comments store actions', () => {
describe('updateDraft', () => {
let getters;
+ service.update = jest.fn();
+ service.update.mockResolvedValue({ data: { id: 1 } });
+
+ const commit = jest.fn();
+ let context;
+ let params;
beforeEach(() => {
getters = {
@@ -208,43 +215,43 @@ describe('Batch comments store actions', () => {
draftsPath: TEST_HOST,
},
};
- });
- it('commits RECEIVE_DRAFT_UPDATE_SUCCESS with returned data', (done) => {
- const commit = jest.fn();
- const context = {
+ context = {
getters,
commit,
};
res = { id: 1 };
mock.onAny().reply(200, res);
+ params = { note: { id: 1 }, noteText: 'test' };
+ });
- actions
- .updateDraft(context, { note: { id: 1 }, noteText: 'test', callback() {} })
- .then(() => {
- expect(commit).toHaveBeenCalledWith('RECEIVE_DRAFT_UPDATE_SUCCESS', { id: 1 });
- })
- .then(done)
- .catch(done.fail);
+ afterEach(() => jest.clearAllMocks());
+
+ it('commits RECEIVE_DRAFT_UPDATE_SUCCESS with returned data', () => {
+ return actions.updateDraft(context, { ...params, callback() {} }).then(() => {
+ expect(commit).toHaveBeenCalledWith('RECEIVE_DRAFT_UPDATE_SUCCESS', { id: 1 });
+ });
});
- it('calls passed callback', (done) => {
- const commit = jest.fn();
- const context = {
- getters,
- commit,
- };
+ it('calls passed callback', () => {
const callback = jest.fn();
- res = { id: 1 };
- mock.onAny().reply(200, res);
+ return actions.updateDraft(context, { ...params, callback }).then(() => {
+ expect(callback).toHaveBeenCalled();
+ });
+ });
- actions
- .updateDraft(context, { note: { id: 1 }, noteText: 'test', callback })
- .then(() => {
- expect(callback).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ it('does not stringify empty position', () => {
+ return actions.updateDraft(context, { ...params, position: {}, callback() {} }).then(() => {
+ expect(service.update.mock.calls[0][1].position).toBeUndefined();
+ });
+ });
+
+ it('stringifies a non-empty position', () => {
+ const position = { test: true };
+ const expectation = JSON.stringify(position);
+ return actions.updateDraft(context, { ...params, position, callback() {} }).then(() => {
+ expect(service.update.mock.calls[0][1].position).toBe(expectation);
+ });
});
});
diff --git a/spec/frontend/behaviors/quick_submit_spec.js b/spec/frontend/behaviors/quick_submit_spec.js
index d3d65892aff..86a85831c6b 100644
--- a/spec/frontend/behaviors/quick_submit_spec.js
+++ b/spec/frontend/behaviors/quick_submit_spec.js
@@ -6,8 +6,6 @@ describe('Quick Submit behavior', () => {
const keydownEvent = (options = { keyCode: 13, metaKey: true }) => $.Event('keydown', options);
- preloadFixtures('snippets/show.html');
-
beforeEach(() => {
loadFixtures('snippets/show.html');
diff --git a/spec/frontend/behaviors/requires_input_spec.js b/spec/frontend/behaviors/requires_input_spec.js
index 0f27f89d6dc..bb22133ae44 100644
--- a/spec/frontend/behaviors/requires_input_spec.js
+++ b/spec/frontend/behaviors/requires_input_spec.js
@@ -3,7 +3,6 @@ import '~/behaviors/requires_input';
describe('requiresInput', () => {
let submitButton;
- preloadFixtures('branches/new_branch.html');
beforeEach(() => {
loadFixtures('branches/new_branch.html');
diff --git a/spec/frontend/behaviors/shortcuts/keybindings_spec.js b/spec/frontend/behaviors/shortcuts/keybindings_spec.js
index d05b3fbdce2..53ce06e78c6 100644
--- a/spec/frontend/behaviors/shortcuts/keybindings_spec.js
+++ b/spec/frontend/behaviors/shortcuts/keybindings_spec.js
@@ -1,33 +1,53 @@
+import { flatten } from 'lodash';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+import {
+ keysFor,
+ getCustomizations,
+ keybindingGroups,
+ TOGGLE_PERFORMANCE_BAR,
+ LOCAL_STORAGE_KEY,
+ WEB_IDE_COMMIT,
+} from '~/behaviors/shortcuts/keybindings';
-describe('~/behaviors/shortcuts/keybindings.js', () => {
- let keysFor;
- let TOGGLE_PERFORMANCE_BAR;
- let LOCAL_STORAGE_KEY;
-
+describe('~/behaviors/shortcuts/keybindings', () => {
beforeAll(() => {
useLocalStorageSpy();
});
- const setupCustomizations = async (customizationsAsString) => {
+ const setupCustomizations = (customizationsAsString) => {
localStorage.clear();
if (customizationsAsString) {
localStorage.setItem(LOCAL_STORAGE_KEY, customizationsAsString);
}
- jest.resetModules();
- ({ keysFor, TOGGLE_PERFORMANCE_BAR, LOCAL_STORAGE_KEY } = await import(
- '~/behaviors/shortcuts/keybindings'
- ));
+ getCustomizations.cache.clear();
};
+ describe('keybinding definition errors', () => {
+ beforeEach(() => {
+ setupCustomizations();
+ });
+
+ it('has no duplicate group IDs', () => {
+ const allGroupIds = keybindingGroups.map((group) => group.id);
+ expect(allGroupIds).toHaveLength(new Set(allGroupIds).size);
+ });
+
+ it('has no duplicate commands IDs', () => {
+ const allCommandIds = flatten(
+ keybindingGroups.map((group) => group.keybindings.map((kb) => kb.id)),
+ );
+ expect(allCommandIds).toHaveLength(new Set(allCommandIds).size);
+ });
+ });
+
describe('when a command has not been customized', () => {
- beforeEach(async () => {
- await setupCustomizations('{}');
+ beforeEach(() => {
+ setupCustomizations('{}');
});
- it('returns the default keybinding for the command', () => {
+ it('returns the default keybindings for the command', () => {
expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(['p b']);
});
});
@@ -35,18 +55,30 @@ describe('~/behaviors/shortcuts/keybindings.js', () => {
describe('when a command has been customized', () => {
const customization = ['p b a r'];
- beforeEach(async () => {
- await setupCustomizations(JSON.stringify({ [TOGGLE_PERFORMANCE_BAR]: customization }));
+ beforeEach(() => {
+ setupCustomizations(JSON.stringify({ [TOGGLE_PERFORMANCE_BAR.id]: customization }));
});
- it('returns the default keybinding for the command', () => {
+ it('returns the custom keybindings for the command', () => {
expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(customization);
});
});
+ describe('when a command is marked as non-customizable', () => {
+ const customization = ['mod+shift+c'];
+
+ beforeEach(() => {
+ setupCustomizations(JSON.stringify({ [WEB_IDE_COMMIT.id]: customization }));
+ });
+
+ it('returns the default keybinding for the command', () => {
+ expect(keysFor(WEB_IDE_COMMIT)).toEqual(['mod+enter']);
+ });
+ });
+
describe("when the localStorage entry isn't valid JSON", () => {
- beforeEach(async () => {
- await setupCustomizations('{');
+ beforeEach(() => {
+ setupCustomizations('{');
});
it('returns the default keybinding for the command', () => {
@@ -55,8 +87,8 @@ describe('~/behaviors/shortcuts/keybindings.js', () => {
});
describe(`when localStorage doesn't contain the ${LOCAL_STORAGE_KEY} key`, () => {
- beforeEach(async () => {
- await setupCustomizations();
+ beforeEach(() => {
+ setupCustomizations();
});
it('returns the default keybinding for the command', () => {
diff --git a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js
index 94ba1615c89..26d38b115b6 100644
--- a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js
+++ b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js
@@ -13,8 +13,6 @@ describe('ShortcutsIssuable', () => {
const snippetShowFixtureName = 'snippets/show.html';
const mrShowFixtureName = 'merge_requests/merge_request_of_current_user.html';
- preloadFixtures(snippetShowFixtureName, mrShowFixtureName);
-
beforeAll((done) => {
initCopyAsGFM();
diff --git a/spec/frontend/blob/blob_file_dropzone_spec.js b/spec/frontend/blob/blob_file_dropzone_spec.js
index cbd36abd4ff..47c90030e18 100644
--- a/spec/frontend/blob/blob_file_dropzone_spec.js
+++ b/spec/frontend/blob/blob_file_dropzone_spec.js
@@ -2,7 +2,6 @@ import $ from 'jquery';
import BlobFileDropzone from '~/blob/blob_file_dropzone';
describe('BlobFileDropzone', () => {
- preloadFixtures('blob/show.html');
let dropzone;
let replaceFileButton;
diff --git a/spec/frontend/blob/sketch/index_spec.js b/spec/frontend/blob/sketch/index_spec.js
index a24e7de9037..7424897b22c 100644
--- a/spec/frontend/blob/sketch/index_spec.js
+++ b/spec/frontend/blob/sketch/index_spec.js
@@ -4,8 +4,6 @@ import SketchLoader from '~/blob/sketch';
jest.mock('jszip');
describe('Sketch viewer', () => {
- preloadFixtures('static/sketch_viewer.html');
-
beforeEach(() => {
loadFixtures('static/sketch_viewer.html');
});
diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js
index 7449de48ec0..e4f145ae81b 100644
--- a/spec/frontend/blob/viewer/index_spec.js
+++ b/spec/frontend/blob/viewer/index_spec.js
@@ -16,8 +16,6 @@ describe('Blob viewer', () => {
setTestTimeout(2000);
- preloadFixtures('blob/show_readme.html');
-
beforeEach(() => {
$.fn.extend(jQueryMock);
mock = new MockAdapter(axios);
@@ -85,9 +83,11 @@ describe('Blob viewer', () => {
describe('copy blob button', () => {
let copyButton;
+ let copyButtonTooltip;
beforeEach(() => {
copyButton = document.querySelector('.js-copy-blob-source-btn');
+ copyButtonTooltip = document.querySelector('.js-copy-blob-source-btn-tooltip');
});
it('disabled on load', () => {
@@ -95,7 +95,7 @@ describe('Blob viewer', () => {
});
it('has tooltip when disabled', () => {
- expect(copyButton.getAttribute('title')).toBe(
+ expect(copyButtonTooltip.getAttribute('title')).toBe(
'Switch to the source to copy the file contents',
);
});
@@ -131,7 +131,7 @@ describe('Blob viewer', () => {
document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click();
setImmediate(() => {
- expect(copyButton.getAttribute('title')).toBe('Copy file contents');
+ expect(copyButtonTooltip.getAttribute('title')).toBe('Copy file contents');
done();
});
diff --git a/spec/frontend/boards/issue_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js
index b9f84fed6b3..4487fc15de6 100644
--- a/spec/frontend/boards/issue_card_inner_spec.js
+++ b/spec/frontend/boards/board_card_inner_spec.js
@@ -1,7 +1,7 @@
import { GlLabel } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { range } from 'lodash';
-import IssueCardInner from '~/boards/components/issue_card_inner.vue';
+import BoardCardInner from '~/boards/components/board_card_inner.vue';
import eventHub from '~/boards/eventhub';
import defaultStore from '~/boards/stores';
import { updateHistory } from '~/lib/utils/url_utility';
@@ -10,7 +10,7 @@ import { mockLabelList } from './mock_data';
jest.mock('~/lib/utils/url_utility');
jest.mock('~/boards/eventhub');
-describe('Issue card component', () => {
+describe('Board card component', () => {
const user = {
id: 1,
name: 'testing 123',
@@ -31,18 +31,17 @@ describe('Issue card component', () => {
let list;
const createWrapper = (props = {}, store = defaultStore) => {
- wrapper = mount(IssueCardInner, {
+ wrapper = mount(BoardCardInner, {
store,
propsData: {
list,
- issue,
+ item: issue,
...props,
},
stubs: {
GlLabel: true,
},
provide: {
- groupId: null,
rootPath: '/',
scopedLabelsAvailable: false,
},
@@ -63,7 +62,7 @@ describe('Issue card component', () => {
weight: 1,
};
- createWrapper({ issue, list });
+ createWrapper({ item: issue, list });
});
afterEach(() => {
@@ -103,8 +102,8 @@ describe('Issue card component', () => {
describe('confidential issue', () => {
beforeEach(() => {
wrapper.setProps({
- issue: {
- ...wrapper.props('issue'),
+ item: {
+ ...wrapper.props('item'),
confidential: true,
},
});
@@ -119,8 +118,8 @@ describe('Issue card component', () => {
describe('with avatar', () => {
beforeEach(() => {
wrapper.setProps({
- issue: {
- ...wrapper.props('issue'),
+ item: {
+ ...wrapper.props('item'),
assignees: [user],
updateData(newData) {
Object.assign(this, newData);
@@ -146,8 +145,8 @@ describe('Issue card component', () => {
});
it('renders the avatar using avatarUrl property', async () => {
- wrapper.props('issue').updateData({
- ...wrapper.props('issue'),
+ wrapper.props('item').updateData({
+ ...wrapper.props('item'),
assignees: [
{
id: '1',
@@ -172,8 +171,8 @@ describe('Issue card component', () => {
global.gon.default_avatar_url = 'default_avatar';
wrapper.setProps({
- issue: {
- ...wrapper.props('issue'),
+ item: {
+ ...wrapper.props('item'),
assignees: [
{
id: 1,
@@ -201,8 +200,8 @@ describe('Issue card component', () => {
describe('multiple assignees', () => {
beforeEach(() => {
wrapper.setProps({
- issue: {
- ...wrapper.props('issue'),
+ item: {
+ ...wrapper.props('item'),
assignees: [
{
id: 2,
@@ -233,7 +232,7 @@ describe('Issue card component', () => {
describe('more than three assignees', () => {
beforeEach(() => {
- const { assignees } = wrapper.props('issue');
+ const { assignees } = wrapper.props('item');
assignees.push({
id: 5,
name: 'user5',
@@ -242,8 +241,8 @@ describe('Issue card component', () => {
});
wrapper.setProps({
- issue: {
- ...wrapper.props('issue'),
+ item: {
+ ...wrapper.props('item'),
assignees,
},
});
@@ -259,7 +258,7 @@ describe('Issue card component', () => {
it('renders 99+ avatar counter', async () => {
const assignees = [
- ...wrapper.props('issue').assignees,
+ ...wrapper.props('item').assignees,
...range(5, 103).map((i) => ({
id: i,
name: 'name',
@@ -268,8 +267,8 @@ describe('Issue card component', () => {
})),
];
wrapper.setProps({
- issue: {
- ...wrapper.props('issue'),
+ item: {
+ ...wrapper.props('item'),
assignees,
},
});
@@ -283,7 +282,7 @@ describe('Issue card component', () => {
describe('labels', () => {
beforeEach(() => {
- wrapper.setProps({ issue: { ...issue, labels: [list.label, label1] } });
+ wrapper.setProps({ item: { ...issue, labels: [list.label, label1] } });
});
it('does not render list label but renders all other labels', () => {
@@ -295,7 +294,7 @@ describe('Issue card component', () => {
});
it('does not render label if label does not have an ID', async () => {
- wrapper.setProps({ issue: { ...issue, labels: [label1, { title: 'closed' }] } });
+ wrapper.setProps({ item: { ...issue, labels: [label1, { title: 'closed' }] } });
await wrapper.vm.$nextTick();
@@ -307,8 +306,8 @@ describe('Issue card component', () => {
describe('blocked', () => {
beforeEach(() => {
wrapper.setProps({
- issue: {
- ...wrapper.props('issue'),
+ item: {
+ ...wrapper.props('item'),
blocked: true,
},
});
diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js
index 7ed20f20882..bf39c3f3e42 100644
--- a/spec/frontend/boards/board_list_spec.js
+++ b/spec/frontend/boards/board_list_spec.js
@@ -1,8 +1,9 @@
-import { createLocalVue, mount } from '@vue/test-utils';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import BoardCard from '~/boards/components/board_card.vue';
import BoardList from '~/boards/components/board_list.vue';
+import BoardNewIssue from '~/boards/components/board_new_issue.vue';
import eventHub from '~/boards/eventhub';
import defaultState from '~/boards/stores/state';
import { mockList, mockIssuesByListId, issues, mockIssues } from './mock_data';
@@ -11,13 +12,18 @@ const localVue = createLocalVue();
localVue.use(Vuex);
const actions = {
- fetchIssuesForList: jest.fn(),
+ fetchItemsForList: jest.fn(),
};
const createStore = (state = defaultState) => {
return new Vuex.Store({
state,
actions,
+ getters: {
+ isGroupBoard: () => false,
+ isProjectBoard: () => true,
+ isEpicBoard: () => false,
+ },
});
};
@@ -28,8 +34,8 @@ const createComponent = ({
state = {},
} = {}) => {
const store = createStore({
- issuesByListId: mockIssuesByListId,
- issues,
+ boardItemsByListId: mockIssuesByListId,
+ boardItems: issues,
pageInfoByListId: {
'gid://gitlab/List/1': { hasNextPage: true },
'gid://gitlab/List/2': {},
@@ -38,6 +44,7 @@ const createComponent = ({
'gid://gitlab/List/1': {},
'gid://gitlab/List/2': {},
},
+ selectedBoardItems: [],
...state,
});
@@ -58,12 +65,12 @@ const createComponent = ({
list.issuesCount = 1;
}
- const component = mount(BoardList, {
+ const component = shallowMount(BoardList, {
localVue,
propsData: {
disabled: false,
list,
- issues: [issue],
+ boardItems: [issue],
canAdminList: true,
...componentProps,
},
@@ -74,6 +81,10 @@ const createComponent = ({
weightFeatureAvailable: false,
boardWeight: null,
},
+ stubs: {
+ BoardCard,
+ BoardNewIssue,
+ },
});
return component;
@@ -81,7 +92,10 @@ const createComponent = ({
describe('Board list component', () => {
let wrapper;
+
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
+ const findIssueCountLoadingIcon = () => wrapper.find('[data-testid="count-loading-icon"]');
+
useFakeRequestAnimationFrame();
afterEach(() => {
@@ -111,7 +125,7 @@ describe('Board list component', () => {
});
it('sets data attribute with issue id', () => {
- expect(wrapper.find('.board-card').attributes('data-issue-id')).toBe('1');
+ expect(wrapper.find('.board-card').attributes('data-item-id')).toBe('1');
});
it('shows new issue form', async () => {
@@ -170,7 +184,7 @@ describe('Board list component', () => {
it('loads more issues after scrolling', () => {
wrapper.vm.listRef.dispatchEvent(new Event('scroll'));
- expect(actions.fetchIssuesForList).toHaveBeenCalled();
+ expect(actions.fetchItemsForList).toHaveBeenCalled();
});
it('does not load issues if already loading', () => {
@@ -179,7 +193,7 @@ describe('Board list component', () => {
});
wrapper.vm.listRef.dispatchEvent(new Event('scroll'));
- expect(actions.fetchIssuesForList).not.toHaveBeenCalled();
+ expect(actions.fetchItemsForList).not.toHaveBeenCalled();
});
it('shows loading more spinner', async () => {
@@ -189,7 +203,8 @@ describe('Board list component', () => {
wrapper.vm.showCount = true;
await wrapper.vm.$nextTick();
- expect(wrapper.find('.board-list-count .gl-spinner').exists()).toBe(true);
+
+ expect(findIssueCountLoadingIcon().exists()).toBe(true);
});
});
@@ -243,7 +258,7 @@ describe('Board list component', () => {
describe('handleDragOnEnd', () => {
it('removes class `is-dragging` from document body', () => {
- jest.spyOn(wrapper.vm, 'moveIssue').mockImplementation(() => {});
+ jest.spyOn(wrapper.vm, 'moveItem').mockImplementation(() => {});
document.body.classList.add('is-dragging');
findByTestId('tree-root-wrapper').vm.$emit('end', {
@@ -251,9 +266,9 @@ describe('Board list component', () => {
newIndex: 0,
item: {
dataset: {
- issueId: mockIssues[0].id,
- issueIid: mockIssues[0].iid,
- issuePath: mockIssues[0].referencePath,
+ itemId: mockIssues[0].id,
+ itemIid: mockIssues[0].iid,
+ itemPath: mockIssues[0].referencePath,
},
},
to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } },
diff --git a/spec/frontend/boards/board_new_issue_deprecated_spec.js b/spec/frontend/boards/board_new_issue_deprecated_spec.js
index 1a29f680166..3903ad201b2 100644
--- a/spec/frontend/boards/board_new_issue_deprecated_spec.js
+++ b/spec/frontend/boards/board_new_issue_deprecated_spec.js
@@ -3,6 +3,7 @@
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
+import Vuex from 'vuex';
import boardNewIssue from '~/boards/components/board_new_issue_deprecated.vue';
import boardsStore from '~/boards/stores/boards_store';
import axios from '~/lib/utils/axios_utils';
@@ -10,6 +11,8 @@ import axios from '~/lib/utils/axios_utils';
import '~/boards/models/list';
import { listObj, boardsMockInterceptor } from './mock_data';
+Vue.use(Vuex);
+
describe('Issue boards new issue form', () => {
let wrapper;
let vm;
@@ -43,11 +46,16 @@ describe('Issue boards new issue form', () => {
newIssueMock = Promise.resolve(promiseReturn);
jest.spyOn(list, 'newIssue').mockImplementation(() => newIssueMock);
+ const store = new Vuex.Store({
+ getters: { isGroupBoard: () => false },
+ });
+
wrapper = mount(BoardNewIssueComp, {
propsData: {
disabled: false,
list,
},
+ store,
provide: {
groupId: null,
},
diff --git a/spec/frontend/boards/components/board_add_new_column_form_spec.js b/spec/frontend/boards/components/board_add_new_column_form_spec.js
new file mode 100644
index 00000000000..3702f55f17b
--- /dev/null
+++ b/spec/frontend/boards/components/board_add_new_column_form_spec.js
@@ -0,0 +1,166 @@
+import { GlFormGroup, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+import Vuex from 'vuex';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
+import defaultState from '~/boards/stores/state';
+import { mockLabelList } from '../mock_data';
+
+Vue.use(Vuex);
+
+describe('Board card layout', () => {
+ let wrapper;
+
+ const createStore = ({ actions = {}, getters = {}, state = {} } = {}) => {
+ return new Vuex.Store({
+ state: {
+ ...defaultState,
+ ...state,
+ },
+ actions,
+ getters,
+ });
+ };
+
+ const mountComponent = ({
+ loading = false,
+ formDescription = '',
+ searchLabel = '',
+ searchPlaceholder = '',
+ selectedId,
+ actions,
+ slots,
+ } = {}) => {
+ wrapper = extendedWrapper(
+ shallowMount(BoardAddNewColumnForm, {
+ stubs: {
+ GlFormGroup: true,
+ },
+ propsData: {
+ loading,
+ formDescription,
+ searchLabel,
+ searchPlaceholder,
+ selectedId,
+ },
+ slots,
+ store: createStore({
+ actions: {
+ setAddColumnFormVisibility: jest.fn(),
+ ...actions,
+ },
+ }),
+ }),
+ );
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const formTitle = () => wrapper.findByTestId('board-add-column-form-title').text();
+ const findSearchInput = () => wrapper.find(GlSearchBoxByType);
+ const findSearchLabel = () => wrapper.find(GlFormGroup);
+ const cancelButton = () => wrapper.findByTestId('cancelAddNewColumn');
+ const submitButton = () => wrapper.findByTestId('addNewColumnButton');
+
+ it('shows form title & search input', () => {
+ mountComponent();
+
+ expect(formTitle()).toEqual(BoardAddNewColumnForm.i18n.newList);
+ expect(findSearchInput().exists()).toBe(true);
+ });
+
+ it('clicking cancel hides the form', () => {
+ const setAddColumnFormVisibility = jest.fn();
+ mountComponent({
+ actions: {
+ setAddColumnFormVisibility,
+ },
+ });
+
+ cancelButton().vm.$emit('click');
+
+ expect(setAddColumnFormVisibility).toHaveBeenCalledWith(expect.anything(), false);
+ });
+
+ it('sets placeholder and description from props', () => {
+ const props = {
+ formDescription: 'Some description of a list',
+ };
+
+ mountComponent(props);
+
+ expect(wrapper.html()).toHaveText(props.formDescription);
+ });
+
+ describe('items', () => {
+ const mountWithItems = (loading) =>
+ mountComponent({
+ loading,
+ slots: {
+ items: '<div class="item-slot">Some kind of list</div>',
+ },
+ });
+
+ it('hides items slot and shows skeleton while loading', () => {
+ mountWithItems(true);
+
+ expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true);
+ expect(wrapper.find('.item-slot').exists()).toBe(false);
+ });
+
+ it('shows items slot and hides skeleton while not loading', () => {
+ mountWithItems(false);
+
+ expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false);
+ expect(wrapper.find('.item-slot').exists()).toBe(true);
+ });
+ });
+
+ describe('search box', () => {
+ it('sets label and placeholder text from props', () => {
+ const props = {
+ searchLabel: 'Some items',
+ searchPlaceholder: 'Search for an item',
+ };
+
+ mountComponent(props);
+
+ expect(findSearchLabel().attributes('label')).toEqual(props.searchLabel);
+ expect(findSearchInput().attributes('placeholder')).toEqual(props.searchPlaceholder);
+ });
+
+ it('emits filter event on input', () => {
+ mountComponent();
+
+ const searchText = 'some text';
+
+ findSearchInput().vm.$emit('input', searchText);
+
+ expect(wrapper.emitted('filter-items')).toEqual([[searchText]]);
+ });
+ });
+
+ describe('Add list button', () => {
+ it('is disabled if no item is selected', () => {
+ mountComponent();
+
+ expect(submitButton().props('disabled')).toBe(true);
+ });
+
+ it('emits add-list event on click', async () => {
+ mountComponent({
+ selectedId: mockLabelList.label.id,
+ });
+
+ await nextTick();
+
+ submitButton().vm.$emit('click');
+
+ expect(wrapper.emitted('add-list')).toEqual([[]]);
+ });
+ });
+});
diff --git a/spec/frontend/boards/components/board_add_new_column_spec.js b/spec/frontend/boards/components/board_add_new_column_spec.js
new file mode 100644
index 00000000000..60584eaf6cf
--- /dev/null
+++ b/spec/frontend/boards/components/board_add_new_column_spec.js
@@ -0,0 +1,115 @@
+import { shallowMount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+import Vuex from 'vuex';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import BoardAddNewColumn from '~/boards/components/board_add_new_column.vue';
+import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
+import defaultState from '~/boards/stores/state';
+import { mockLabelList } from '../mock_data';
+
+Vue.use(Vuex);
+
+describe('Board card layout', () => {
+ let wrapper;
+
+ const createStore = ({ actions = {}, getters = {}, state = {} } = {}) => {
+ return new Vuex.Store({
+ state: {
+ ...defaultState,
+ ...state,
+ },
+ actions,
+ getters,
+ });
+ };
+
+ const mountComponent = ({
+ selectedId,
+ labels = [],
+ getListByLabelId = jest.fn(),
+ actions = {},
+ } = {}) => {
+ wrapper = extendedWrapper(
+ shallowMount(BoardAddNewColumn, {
+ data() {
+ return {
+ selectedId,
+ };
+ },
+ store: createStore({
+ actions: {
+ fetchLabels: jest.fn(),
+ setAddColumnFormVisibility: jest.fn(),
+ ...actions,
+ },
+ getters: {
+ shouldUseGraphQL: () => true,
+ getListByLabelId: () => getListByLabelId,
+ },
+ state: {
+ labels,
+ labelsLoading: false,
+ isEpicBoard: false,
+ },
+ }),
+ provide: {
+ scopedLabelsAvailable: true,
+ },
+ }),
+ );
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('Add list button', () => {
+ it('calls addList', async () => {
+ const getListByLabelId = jest.fn().mockReturnValue(null);
+ const highlightList = jest.fn();
+ const createList = jest.fn();
+
+ mountComponent({
+ labels: [mockLabelList.label],
+ selectedId: mockLabelList.label.id,
+ getListByLabelId,
+ actions: {
+ createList,
+ highlightList,
+ },
+ });
+
+ wrapper.findComponent(BoardAddNewColumnForm).vm.$emit('add-list');
+
+ await nextTick();
+
+ expect(highlightList).not.toHaveBeenCalled();
+ expect(createList).toHaveBeenCalledWith(expect.anything(), {
+ labelId: mockLabelList.label.id,
+ });
+ });
+
+ it('highlights existing list if trying to re-add', async () => {
+ const getListByLabelId = jest.fn().mockReturnValue(mockLabelList);
+ const highlightList = jest.fn();
+ const createList = jest.fn();
+
+ mountComponent({
+ labels: [mockLabelList.label],
+ selectedId: mockLabelList.label.id,
+ getListByLabelId,
+ actions: {
+ createList,
+ highlightList,
+ },
+ });
+
+ wrapper.findComponent(BoardAddNewColumnForm).vm.$emit('add-list');
+
+ await nextTick();
+
+ expect(highlightList).toHaveBeenCalledWith(expect.anything(), mockLabelList.id);
+ expect(createList).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/boards/components/board_card_deprecated_spec.js b/spec/frontend/boards/components/board_card_deprecated_spec.js
new file mode 100644
index 00000000000..266cbc7106d
--- /dev/null
+++ b/spec/frontend/boards/components/board_card_deprecated_spec.js
@@ -0,0 +1,219 @@
+/* global List */
+/* global ListAssignee */
+/* global ListLabel */
+
+import { mount } from '@vue/test-utils';
+
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import BoardCardDeprecated from '~/boards/components/board_card_deprecated.vue';
+import issueCardInner from '~/boards/components/issue_card_inner_deprecated.vue';
+import eventHub from '~/boards/eventhub';
+import store from '~/boards/stores';
+import boardsStore from '~/boards/stores/boards_store';
+import axios from '~/lib/utils/axios_utils';
+
+import sidebarEventHub from '~/sidebar/event_hub';
+import '~/boards/models/label';
+import '~/boards/models/assignee';
+import '~/boards/models/list';
+import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data';
+
+describe('BoardCard', () => {
+ let wrapper;
+ let mock;
+ let list;
+
+ const findIssueCardInner = () => wrapper.find(issueCardInner);
+ const findUserAvatarLink = () => wrapper.find(userAvatarLink);
+
+ // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized
+ const mountComponent = (propsData) => {
+ wrapper = mount(BoardCardDeprecated, {
+ stubs: {
+ issueCardInner,
+ },
+ store,
+ propsData: {
+ list,
+ issue: list.issues[0],
+ disabled: false,
+ index: 0,
+ ...propsData,
+ },
+ provide: {
+ groupId: null,
+ rootPath: '/',
+ scopedLabelsAvailable: false,
+ },
+ });
+ };
+
+ const setupData = async () => {
+ list = new List(listObj);
+ boardsStore.create();
+ boardsStore.detail.issue = {};
+ const label1 = new ListLabel({
+ id: 3,
+ title: 'testing 123',
+ color: '#000cff',
+ text_color: 'white',
+ description: 'test',
+ });
+ await waitForPromises();
+
+ list.issues[0].labels.push(label1);
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onAny().reply(boardsMockInterceptor);
+ setMockEndpoints();
+ return setupData();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ list = null;
+ mock.restore();
+ });
+
+ it('when details issue is empty does not show the element', () => {
+ mountComponent();
+ expect(wrapper.find('[data-testid="board_card"').classes()).not.toContain('is-active');
+ });
+
+ it('when detailIssue is equal to card issue shows the element', () => {
+ [boardsStore.detail.issue] = list.issues;
+ mountComponent();
+
+ expect(wrapper.classes()).toContain('is-active');
+ });
+
+ it('when multiSelect does not contain issue removes multi select class', () => {
+ mountComponent();
+ expect(wrapper.classes()).not.toContain('multi-select');
+ });
+
+ it('when multiSelect contain issue add multi select class', () => {
+ boardsStore.multiSelect.list = [list.issues[0]];
+ mountComponent();
+
+ expect(wrapper.classes()).toContain('multi-select');
+ });
+
+ it('adds user-can-drag class if not disabled', () => {
+ mountComponent();
+ expect(wrapper.classes()).toContain('user-can-drag');
+ });
+
+ it('does not add user-can-drag class disabled', () => {
+ mountComponent({ disabled: true });
+
+ expect(wrapper.classes()).not.toContain('user-can-drag');
+ });
+
+ it('does not add disabled class', () => {
+ mountComponent();
+ expect(wrapper.classes()).not.toContain('is-disabled');
+ });
+
+ it('adds disabled class is disabled is true', () => {
+ mountComponent({ disabled: true });
+
+ expect(wrapper.classes()).toContain('is-disabled');
+ });
+
+ describe('mouse events', () => {
+ it('does not set detail issue if showDetail is false', () => {
+ mountComponent();
+ expect(boardsStore.detail.issue).toEqual({});
+ });
+
+ it('does not set detail issue if link is clicked', () => {
+ mountComponent();
+ findIssueCardInner().find('a').trigger('mouseup');
+
+ expect(boardsStore.detail.issue).toEqual({});
+ });
+
+ it('does not set detail issue if img is clicked', () => {
+ mountComponent({
+ issue: {
+ ...list.issues[0],
+ assignees: [
+ new ListAssignee({
+ id: 1,
+ name: 'testing 123',
+ username: 'test',
+ avatar: 'test_image',
+ }),
+ ],
+ },
+ });
+
+ findUserAvatarLink().trigger('mouseup');
+
+ expect(boardsStore.detail.issue).toEqual({});
+ });
+
+ it('does not set detail issue if showDetail is false after mouseup', () => {
+ mountComponent();
+ wrapper.trigger('mouseup');
+
+ expect(boardsStore.detail.issue).toEqual({});
+ });
+
+ it('sets detail issue to card issue on mouse up', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+
+ mountComponent();
+
+ wrapper.trigger('mousedown');
+ wrapper.trigger('mouseup');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, false);
+ expect(boardsStore.detail.list).toEqual(wrapper.vm.list);
+ });
+
+ it('resets detail issue to empty if already set', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+ const [issue] = list.issues;
+ boardsStore.detail.issue = issue;
+ mountComponent();
+
+ wrapper.trigger('mousedown');
+ wrapper.trigger('mouseup');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', false);
+ });
+ });
+
+ describe('sidebarHub events', () => {
+ it('closes all sidebars before showing an issue if no issues are opened', () => {
+ jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {});
+ boardsStore.detail.issue = {};
+ mountComponent();
+
+ // sets conditional so that event is emitted.
+ wrapper.trigger('mousedown');
+
+ wrapper.trigger('mouseup');
+
+ expect(sidebarEventHub.$emit).toHaveBeenCalledWith('sidebar.closeAll');
+ });
+
+ it('it does not closes all sidebars before showing an issue if an issue is opened', () => {
+ jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {});
+ const [issue] = list.issues;
+ boardsStore.detail.issue = issue;
+ mountComponent();
+
+ wrapper.trigger('mousedown');
+
+ expect(sidebarEventHub.$emit).not.toHaveBeenCalledWith('sidebar.closeAll');
+ });
+ });
+});
diff --git a/spec/frontend/boards/components/board_card_layout_deprecated_spec.js b/spec/frontend/boards/components/board_card_layout_deprecated_spec.js
index 426c5289ba6..9853c9f434f 100644
--- a/spec/frontend/boards/components/board_card_layout_deprecated_spec.js
+++ b/spec/frontend/boards/components/board_card_layout_deprecated_spec.js
@@ -11,7 +11,7 @@ import '~/boards/models/label';
import '~/boards/models/assignee';
import '~/boards/models/list';
import BoardCardLayout from '~/boards/components/board_card_layout_deprecated.vue';
-import issueCardInner from '~/boards/components/issue_card_inner.vue';
+import issueCardInner from '~/boards/components/issue_card_inner_deprecated.vue';
import { ISSUABLE } from '~/boards/constants';
import boardsVuexStore from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
diff --git a/spec/frontend/boards/components/board_card_layout_spec.js b/spec/frontend/boards/components/board_card_layout_spec.js
deleted file mode 100644
index 3fa8714807c..00000000000
--- a/spec/frontend/boards/components/board_card_layout_spec.js
+++ /dev/null
@@ -1,116 +0,0 @@
-import { createLocalVue, shallowMount } from '@vue/test-utils';
-import Vuex from 'vuex';
-
-import BoardCardLayout from '~/boards/components/board_card_layout.vue';
-import IssueCardInner from '~/boards/components/issue_card_inner.vue';
-import { ISSUABLE } from '~/boards/constants';
-import defaultState from '~/boards/stores/state';
-import { mockLabelList, mockIssue } from '../mock_data';
-
-describe('Board card layout', () => {
- let wrapper;
- let store;
-
- const localVue = createLocalVue();
- localVue.use(Vuex);
-
- const createStore = ({ getters = {}, actions = {} } = {}) => {
- store = new Vuex.Store({
- state: defaultState,
- actions,
- getters,
- });
- };
-
- // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized
- const mountComponent = ({ propsData = {}, provide = {} } = {}) => {
- wrapper = shallowMount(BoardCardLayout, {
- localVue,
- stubs: {
- IssueCardInner,
- },
- store,
- propsData: {
- list: mockLabelList,
- issue: mockIssue,
- disabled: false,
- index: 0,
- ...propsData,
- },
- provide: {
- groupId: null,
- rootPath: '/',
- scopedLabelsAvailable: false,
- ...provide,
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- describe('mouse events', () => {
- it('sets showDetail to true on mousedown', async () => {
- createStore();
- mountComponent();
-
- wrapper.trigger('mousedown');
- await wrapper.vm.$nextTick();
-
- expect(wrapper.vm.showDetail).toBe(true);
- });
-
- it('sets showDetail to false on mousemove', async () => {
- createStore();
- mountComponent();
- wrapper.trigger('mousedown');
- await wrapper.vm.$nextTick();
- expect(wrapper.vm.showDetail).toBe(true);
- wrapper.trigger('mousemove');
- await wrapper.vm.$nextTick();
- expect(wrapper.vm.showDetail).toBe(false);
- });
-
- it("calls 'setActiveId'", async () => {
- const setActiveId = jest.fn();
- createStore({
- actions: {
- setActiveId,
- },
- });
- mountComponent();
-
- wrapper.trigger('mouseup');
- await wrapper.vm.$nextTick();
-
- expect(setActiveId).toHaveBeenCalledTimes(1);
- expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), {
- id: mockIssue.id,
- sidebarType: ISSUABLE,
- });
- });
-
- it("calls 'setActiveId' when epic swimlanes is active", async () => {
- const setActiveId = jest.fn();
- const isSwimlanesOn = () => true;
- createStore({
- getters: { isSwimlanesOn },
- actions: {
- setActiveId,
- },
- });
- mountComponent();
-
- wrapper.trigger('mouseup');
- await wrapper.vm.$nextTick();
-
- expect(setActiveId).toHaveBeenCalledTimes(1);
- expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), {
- id: mockIssue.id,
- sidebarType: ISSUABLE,
- });
- });
- });
-});
diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js
index 5f26ae1bb3b..022f8c05e1e 100644
--- a/spec/frontend/boards/components/board_card_spec.js
+++ b/spec/frontend/boards/components/board_card_spec.js
@@ -1,43 +1,50 @@
-/* global List */
-/* global ListAssignee */
-/* global ListLabel */
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import Vuex from 'vuex';
-import { mount } from '@vue/test-utils';
-
-import MockAdapter from 'axios-mock-adapter';
-import waitForPromises from 'helpers/wait_for_promises';
import BoardCard from '~/boards/components/board_card.vue';
-import issueCardInner from '~/boards/components/issue_card_inner.vue';
-import eventHub from '~/boards/eventhub';
-import store from '~/boards/stores';
-import boardsStore from '~/boards/stores/boards_store';
-import axios from '~/lib/utils/axios_utils';
-
-import sidebarEventHub from '~/sidebar/event_hub';
-import '~/boards/models/label';
-import '~/boards/models/assignee';
-import '~/boards/models/list';
-import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
-import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data';
-
-describe('BoardCard', () => {
- let wrapper;
- let mock;
- let list;
+import BoardCardInner from '~/boards/components/board_card_inner.vue';
+import { inactiveId } from '~/boards/constants';
+import { mockLabelList, mockIssue } from '../mock_data';
- const findIssueCardInner = () => wrapper.find(issueCardInner);
- const findUserAvatarLink = () => wrapper.find(userAvatarLink);
+describe('Board card', () => {
+ let wrapper;
+ let store;
+ let mockActions;
+
+ const localVue = createLocalVue();
+ localVue.use(Vuex);
+
+ const createStore = ({ initialState = {}, isSwimlanesOn = false } = {}) => {
+ mockActions = {
+ toggleBoardItem: jest.fn(),
+ toggleBoardItemMultiSelection: jest.fn(),
+ };
+
+ store = new Vuex.Store({
+ state: {
+ activeId: inactiveId,
+ selectedBoardItems: [],
+ ...initialState,
+ },
+ actions: mockActions,
+ getters: {
+ isSwimlanesOn: () => isSwimlanesOn,
+ isEpicBoard: () => false,
+ },
+ });
+ };
// this particular mount component needs to be used after the root beforeEach because it depends on list being initialized
- const mountComponent = (propsData) => {
- wrapper = mount(BoardCard, {
+ const mountComponent = ({ propsData = {}, provide = {} } = {}) => {
+ wrapper = shallowMount(BoardCard, {
+ localVue,
stubs: {
- issueCardInner,
+ BoardCardInner,
},
store,
propsData: {
- list,
- issue: list.issues[0],
+ list: mockLabelList,
+ item: mockIssue,
disabled: false,
index: 0,
...propsData,
@@ -46,174 +53,94 @@ describe('BoardCard', () => {
groupId: null,
rootPath: '/',
scopedLabelsAvailable: false,
+ ...provide,
},
});
};
- const setupData = async () => {
- list = new List(listObj);
- boardsStore.create();
- boardsStore.detail.issue = {};
- const label1 = new ListLabel({
- id: 3,
- title: 'testing 123',
- color: '#000cff',
- text_color: 'white',
- description: 'test',
- });
- await waitForPromises();
-
- list.issues[0].labels.push(label1);
+ const selectCard = async () => {
+ wrapper.trigger('mouseup');
+ await wrapper.vm.$nextTick();
};
- beforeEach(() => {
- mock = new MockAdapter(axios);
- mock.onAny().reply(boardsMockInterceptor);
- setMockEndpoints();
- return setupData();
- });
+ const multiSelectCard = async () => {
+ wrapper.trigger('mouseup', { ctrlKey: true });
+ await wrapper.vm.$nextTick();
+ };
afterEach(() => {
wrapper.destroy();
wrapper = null;
- list = null;
- mock.restore();
- });
-
- it('when details issue is empty does not show the element', () => {
- mountComponent();
- expect(wrapper.find('[data-testid="board_card"').classes()).not.toContain('is-active');
- });
-
- it('when detailIssue is equal to card issue shows the element', () => {
- [boardsStore.detail.issue] = list.issues;
- mountComponent();
-
- expect(wrapper.classes()).toContain('is-active');
- });
-
- it('when multiSelect does not contain issue removes multi select class', () => {
- mountComponent();
- expect(wrapper.classes()).not.toContain('multi-select');
- });
-
- it('when multiSelect contain issue add multi select class', () => {
- boardsStore.multiSelect.list = [list.issues[0]];
- mountComponent();
-
- expect(wrapper.classes()).toContain('multi-select');
- });
-
- it('adds user-can-drag class if not disabled', () => {
- mountComponent();
- expect(wrapper.classes()).toContain('user-can-drag');
- });
-
- it('does not add user-can-drag class disabled', () => {
- mountComponent({ disabled: true });
-
- expect(wrapper.classes()).not.toContain('user-can-drag');
- });
-
- it('does not add disabled class', () => {
- mountComponent();
- expect(wrapper.classes()).not.toContain('is-disabled');
+ store = null;
});
- it('adds disabled class is disabled is true', () => {
- mountComponent({ disabled: true });
-
- expect(wrapper.classes()).toContain('is-disabled');
- });
-
- describe('mouse events', () => {
- it('does not set detail issue if showDetail is false', () => {
+ describe.each`
+ isSwimlanesOn
+ ${true} | ${false}
+ `('when isSwimlanesOn is $isSwimlanesOn', ({ isSwimlanesOn }) => {
+ it('should not highlight the card by default', async () => {
+ createStore({ isSwimlanesOn });
mountComponent();
- expect(boardsStore.detail.issue).toEqual({});
- });
- it('does not set detail issue if link is clicked', () => {
- mountComponent();
- findIssueCardInner().find('a').trigger('mouseup');
-
- expect(boardsStore.detail.issue).toEqual({});
+ expect(wrapper.classes()).not.toContain('is-active');
+ expect(wrapper.classes()).not.toContain('multi-select');
});
- it('does not set detail issue if img is clicked', () => {
- mountComponent({
- issue: {
- ...list.issues[0],
- assignees: [
- new ListAssignee({
- id: 1,
- name: 'testing 123',
- username: 'test',
- avatar: 'test_image',
- }),
- ],
+ it('should highlight the card with a correct style when selected', async () => {
+ createStore({
+ initialState: {
+ activeId: mockIssue.id,
},
+ isSwimlanesOn,
});
-
- findUserAvatarLink().trigger('mouseup');
-
- expect(boardsStore.detail.issue).toEqual({});
- });
-
- it('does not set detail issue if showDetail is false after mouseup', () => {
- mountComponent();
- wrapper.trigger('mouseup');
-
- expect(boardsStore.detail.issue).toEqual({});
- });
-
- it('sets detail issue to card issue on mouse up', () => {
- jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
-
mountComponent();
- wrapper.trigger('mousedown');
- wrapper.trigger('mouseup');
-
- expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, false);
- expect(boardsStore.detail.list).toEqual(wrapper.vm.list);
+ expect(wrapper.classes()).toContain('is-active');
+ expect(wrapper.classes()).not.toContain('multi-select');
});
- it('resets detail issue to empty if already set', () => {
- jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
- const [issue] = list.issues;
- boardsStore.detail.issue = issue;
+ it('should highlight the card with a correct style when multi-selected', async () => {
+ createStore({
+ initialState: {
+ activeId: inactiveId,
+ selectedBoardItems: [mockIssue],
+ },
+ isSwimlanesOn,
+ });
mountComponent();
- wrapper.trigger('mousedown');
- wrapper.trigger('mouseup');
-
- expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', false);
+ expect(wrapper.classes()).toContain('multi-select');
+ expect(wrapper.classes()).not.toContain('is-active');
});
- });
-
- describe('sidebarHub events', () => {
- it('closes all sidebars before showing an issue if no issues are opened', () => {
- jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {});
- boardsStore.detail.issue = {};
- mountComponent();
-
- // sets conditional so that event is emitted.
- wrapper.trigger('mousedown');
- wrapper.trigger('mouseup');
+ describe('when mouseup event is called on the card', () => {
+ beforeEach(() => {
+ createStore({ isSwimlanesOn });
+ mountComponent();
+ });
- expect(sidebarEventHub.$emit).toHaveBeenCalledWith('sidebar.closeAll');
- });
+ describe('when not using multi-select', () => {
+ it('should call vuex action "toggleBoardItem" with correct parameters', async () => {
+ await selectCard();
- it('it does not closes all sidebars before showing an issue if an issue is opened', () => {
- jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {});
- const [issue] = list.issues;
- boardsStore.detail.issue = issue;
- mountComponent();
+ expect(mockActions.toggleBoardItem).toHaveBeenCalledTimes(1);
+ expect(mockActions.toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
+ boardItem: mockIssue,
+ });
+ });
+ });
- wrapper.trigger('mousedown');
+ describe('when using multi-select', () => {
+ it('should call vuex action "multiSelectBoardItem" with correct parameters', async () => {
+ await multiSelectCard();
- expect(sidebarEventHub.$emit).not.toHaveBeenCalledWith('sidebar.closeAll');
+ expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledTimes(1);
+ expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledWith(
+ expect.any(Object),
+ mockIssue,
+ );
+ });
+ });
});
});
});
diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js
index 858efea99ad..32499bd5480 100644
--- a/spec/frontend/boards/components/board_form_spec.js
+++ b/spec/frontend/boards/components/board_form_spec.js
@@ -8,6 +8,7 @@ import { formType } from '~/boards/constants';
import createBoardMutation from '~/boards/graphql/board_create.mutation.graphql';
import destroyBoardMutation from '~/boards/graphql/board_destroy.mutation.graphql';
import updateBoardMutation from '~/boards/graphql/board_update.mutation.graphql';
+import { createStore } from '~/boards/stores';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility';
@@ -48,6 +49,13 @@ describe('BoardForm', () => {
const findDeleteConfirmation = () => wrapper.find('[data-testid="delete-confirmation-message"]');
const findInput = () => wrapper.find('#board-new-name');
+ const store = createStore({
+ getters: {
+ isGroupBoard: () => true,
+ isProjectBoard: () => false,
+ },
+ });
+
const createComponent = (props, data) => {
wrapper = shallowMount(BoardForm, {
propsData: { ...defaultProps, ...props },
@@ -64,6 +72,7 @@ describe('BoardForm', () => {
mutate,
},
},
+ store,
attachTo: document.body,
});
};
diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js
index f30e3792435..d2dfb4148b3 100644
--- a/spec/frontend/boards/components/board_list_header_spec.js
+++ b/spec/frontend/boards/components/board_list_header_spec.js
@@ -1,5 +1,6 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { mockLabelList } from 'jest/boards/mock_data';
import BoardListHeader from '~/boards/components/board_list_header.vue';
@@ -14,6 +15,7 @@ describe('Board List Header Component', () => {
let store;
const updateListSpy = jest.fn();
+ const toggleListCollapsedSpy = jest.fn();
afterEach(() => {
wrapper.destroy();
@@ -43,38 +45,39 @@ describe('Board List Header Component', () => {
if (withLocalStorage) {
localStorage.setItem(
- `boards.${boardId}.${listMock.listType}.${listMock.id}.expanded`,
- (!collapsed).toString(),
+ `boards.${boardId}.${listMock.listType}.${listMock.id}.collapsed`,
+ collapsed.toString(),
);
}
store = new Vuex.Store({
state: {},
- actions: { updateList: updateListSpy },
- getters: {},
+ actions: { updateList: updateListSpy, toggleListCollapsed: toggleListCollapsedSpy },
+ getters: { isEpicBoard: () => false },
});
- wrapper = shallowMount(BoardListHeader, {
- store,
- localVue,
- propsData: {
- disabled: false,
- list: listMock,
- },
- provide: {
- boardId,
- weightFeatureAvailable: false,
- currentUserId,
- },
- });
+ wrapper = extendedWrapper(
+ shallowMount(BoardListHeader, {
+ store,
+ localVue,
+ propsData: {
+ disabled: false,
+ list: listMock,
+ },
+ provide: {
+ boardId,
+ weightFeatureAvailable: false,
+ currentUserId,
+ },
+ }),
+ );
};
const isCollapsed = () => wrapper.vm.list.collapsed;
- const isExpanded = () => !isCollapsed;
const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' });
const findTitle = () => wrapper.find('.board-title');
- const findCaret = () => wrapper.find('.board-title-caret');
+ const findCaret = () => wrapper.findByTestId('board-title-caret');
describe('Add issue button', () => {
const hasNoAddButton = [ListType.closed];
@@ -114,40 +117,29 @@ describe('Board List Header Component', () => {
});
describe('expanding / collapsing the column', () => {
- it('does not collapse when clicking the header', async () => {
+ it('should display collapse icon when column is expanded', async () => {
createComponent();
- expect(isCollapsed()).toBe(false);
-
- wrapper.find('[data-testid="board-list-header"]').trigger('click');
+ const icon = findCaret();
- await wrapper.vm.$nextTick();
-
- expect(isCollapsed()).toBe(false);
+ expect(icon.props('icon')).toBe('chevron-right');
});
- it('collapses expanded Column when clicking the collapse icon', async () => {
- createComponent();
-
- expect(isCollapsed()).toBe(false);
-
- findCaret().vm.$emit('click');
+ it('should display expand icon when column is collapsed', async () => {
+ createComponent({ collapsed: true });
- await wrapper.vm.$nextTick();
+ const icon = findCaret();
- expect(isCollapsed()).toBe(true);
+ expect(icon.props('icon')).toBe('chevron-down');
});
- it('expands collapsed Column when clicking the expand icon', async () => {
- createComponent({ collapsed: true });
-
- expect(isCollapsed()).toBe(true);
+ it('should dispatch toggleListCollapse when clicking the collapse icon', async () => {
+ createComponent();
findCaret().vm.$emit('click');
await wrapper.vm.$nextTick();
-
- expect(isCollapsed()).toBe(false);
+ expect(toggleListCollapsedSpy).toHaveBeenCalledTimes(1);
});
it("when logged in it calls list update and doesn't set localStorage", async () => {
@@ -157,7 +149,7 @@ describe('Board List Header Component', () => {
await wrapper.vm.$nextTick();
expect(updateListSpy).toHaveBeenCalledTimes(1);
- expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null);
+ expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.collapsed`)).toBe(null);
});
it("when logged out it doesn't call list update and sets localStorage", async () => {
@@ -167,7 +159,7 @@ describe('Board List Header Component', () => {
await wrapper.vm.$nextTick();
expect(updateListSpy).not.toHaveBeenCalled();
- expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded()));
+ expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.collapsed`)).toBe(String(isCollapsed()));
});
});
diff --git a/spec/frontend/boards/components/board_new_issue_spec.js b/spec/frontend/boards/components/board_new_issue_spec.js
index ce8c95527e9..737a18294bc 100644
--- a/spec/frontend/boards/components/board_new_issue_spec.js
+++ b/spec/frontend/boards/components/board_new_issue_spec.js
@@ -2,7 +2,6 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import BoardNewIssue from '~/boards/components/board_new_issue.vue';
-import '~/boards/models/list';
import { mockList, mockGroupProjects } from '../mock_data';
const localVue = createLocalVue();
@@ -31,7 +30,7 @@ describe('Issue boards new issue form', () => {
const store = new Vuex.Store({
state: { selectedProject: mockGroupProjects[0] },
actions: { addListNewIssue: addListNewIssuesSpy },
- getters: {},
+ getters: { isGroupBoard: () => false, isProjectBoard: () => true },
});
wrapper = shallowMount(BoardNewIssue, {
diff --git a/spec/frontend/boards/components/filtered_search_spec.js b/spec/frontend/boards/components/filtered_search_spec.js
new file mode 100644
index 00000000000..7f238aa671f
--- /dev/null
+++ b/spec/frontend/boards/components/filtered_search_spec.js
@@ -0,0 +1,65 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import Vuex from 'vuex';
+import FilteredSearch from '~/boards/components/filtered_search.vue';
+import { createStore } from '~/boards/stores';
+import * as commonUtils from '~/lib/utils/common_utils';
+import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('FilteredSearch', () => {
+ let wrapper;
+ let store;
+
+ const createComponent = () => {
+ wrapper = shallowMount(FilteredSearch, {
+ localVue,
+ propsData: { search: '' },
+ store,
+ attachTo: document.body,
+ });
+ };
+
+ beforeEach(() => {
+ // this needed for actions call for performSearch
+ window.gon = { features: {} };
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ store = createStore();
+
+ jest.spyOn(store, 'dispatch');
+
+ createComponent();
+ });
+
+ it('finds FilteredSearch', () => {
+ expect(wrapper.find(FilteredSearchBarRoot).exists()).toBe(true);
+ });
+
+ describe('when onFilter is emitted', () => {
+ it('calls performSearch', () => {
+ wrapper.find(FilteredSearchBarRoot).vm.$emit('onFilter', [{ value: { data: '' } }]);
+
+ expect(store.dispatch).toHaveBeenCalledWith('performSearch');
+ });
+
+ it('calls historyPushState', () => {
+ commonUtils.historyPushState = jest.fn();
+ wrapper
+ .find(FilteredSearchBarRoot)
+ .vm.$emit('onFilter', [{ value: { data: 'searchQuery' } }]);
+
+ expect(commonUtils.historyPushState).toHaveBeenCalledWith(
+ 'http://test.host/?search=searchQuery',
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/boards/components/issue_count_spec.js b/spec/frontend/boards/components/item_count_spec.js
index f1870e9cc9e..45980c36f1c 100644
--- a/spec/frontend/boards/components/issue_count_spec.js
+++ b/spec/frontend/boards/components/item_count_spec.js
@@ -1,10 +1,10 @@
import { shallowMount } from '@vue/test-utils';
-import IssueCount from '~/boards/components/issue_count.vue';
+import IssueCount from '~/boards/components/item_count.vue';
describe('IssueCount', () => {
let vm;
let maxIssueCount;
- let issuesSize;
+ let itemsSize;
const createComponent = (props) => {
vm = shallowMount(IssueCount, { propsData: props });
@@ -12,20 +12,20 @@ describe('IssueCount', () => {
afterEach(() => {
maxIssueCount = 0;
- issuesSize = 0;
+ itemsSize = 0;
if (vm) vm.destroy();
});
describe('when maxIssueCount is zero', () => {
beforeEach(() => {
- issuesSize = 3;
+ itemsSize = 3;
- createComponent({ maxIssueCount: 0, issuesSize });
+ createComponent({ maxIssueCount: 0, itemsSize });
});
it('contains issueSize in the template', () => {
- expect(vm.find('.js-issue-size').text()).toEqual(String(issuesSize));
+ expect(vm.find('[data-testid="board-items-count"]').text()).toEqual(String(itemsSize));
});
it('does not contains maxIssueCount in the template', () => {
@@ -36,9 +36,9 @@ describe('IssueCount', () => {
describe('when maxIssueCount is greater than zero', () => {
beforeEach(() => {
maxIssueCount = 2;
- issuesSize = 1;
+ itemsSize = 1;
- createComponent({ maxIssueCount, issuesSize });
+ createComponent({ maxIssueCount, itemsSize });
});
afterEach(() => {
@@ -46,7 +46,7 @@ describe('IssueCount', () => {
});
it('contains issueSize in the template', () => {
- expect(vm.find('.js-issue-size').text()).toEqual(String(issuesSize));
+ expect(vm.find('[data-testid="board-items-count"]').text()).toEqual(String(itemsSize));
});
it('contains maxIssueCount in the template', () => {
@@ -60,10 +60,10 @@ describe('IssueCount', () => {
describe('when issueSize is greater than maxIssueCount', () => {
beforeEach(() => {
- issuesSize = 3;
+ itemsSize = 3;
maxIssueCount = 2;
- createComponent({ maxIssueCount, issuesSize });
+ createComponent({ maxIssueCount, itemsSize });
});
afterEach(() => {
@@ -71,7 +71,7 @@ describe('IssueCount', () => {
});
it('contains issueSize in the template', () => {
- expect(vm.find('.js-issue-size').text()).toEqual(String(issuesSize));
+ expect(vm.find('[data-testid="board-items-count"]').text()).toEqual(String(itemsSize));
});
it('contains maxIssueCount in the template', () => {
@@ -79,7 +79,7 @@ describe('IssueCount', () => {
});
it('has text-danger class', () => {
- expect(vm.find('.text-danger').text()).toEqual(String(issuesSize));
+ expect(vm.find('.text-danger').text()).toEqual(String(itemsSize));
});
});
});
diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js
index 7838b5a0b2f..8fd178a0856 100644
--- a/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js
+++ b/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js
@@ -24,7 +24,7 @@ describe('~/boards/components/sidebar/board_sidebar_due_date.vue', () => {
const createWrapper = ({ dueDate = null } = {}) => {
store = createStore();
- store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, dueDate } };
+ store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, dueDate } };
store.state.activeId = TEST_ISSUE.id;
wrapper = shallowMount(BoardSidebarDueDate, {
@@ -61,7 +61,7 @@ describe('~/boards/components/sidebar/board_sidebar_due_date.vue', () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => {
- store.state.issues[TEST_ISSUE.id].dueDate = TEST_DUE_DATE;
+ store.state.boardItems[TEST_ISSUE.id].dueDate = TEST_DUE_DATE;
});
findDatePicker().vm.$emit('input', TEST_PARSED_DATE);
await wrapper.vm.$nextTick();
@@ -86,7 +86,7 @@ describe('~/boards/components/sidebar/board_sidebar_due_date.vue', () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => {
- store.state.issues[TEST_ISSUE.id].dueDate = null;
+ store.state.boardItems[TEST_ISSUE.id].dueDate = null;
});
findDatePicker().vm.$emit('clear');
await wrapper.vm.$nextTick();
@@ -104,7 +104,7 @@ describe('~/boards/components/sidebar/board_sidebar_due_date.vue', () => {
createWrapper({ dueDate: TEST_DUE_DATE });
jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => {
- store.state.issues[TEST_ISSUE.id].dueDate = null;
+ store.state.boardItems[TEST_ISSUE.id].dueDate = null;
});
findResetButton().vm.$emit('click');
await wrapper.vm.$nextTick();
diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js
index bc7df1c76c6..723d0345f76 100644
--- a/spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js
+++ b/spec/frontend/boards/components/sidebar/board_sidebar_issue_title_spec.js
@@ -34,7 +34,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => {
const createWrapper = (issue = TEST_ISSUE_A) => {
store = createStore();
- store.state.issues = { [issue.id]: { ...issue } };
+ store.state.boardItems = { [issue.id]: { ...issue } };
store.dispatch('setActiveId', { id: issue.id });
wrapper = shallowMount(BoardSidebarIssueTitle, {
@@ -74,7 +74,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => {
- store.state.issues[TEST_ISSUE_A.id].title = TEST_TITLE;
+ store.state.boardItems[TEST_ISSUE_A.id].title = TEST_TITLE;
});
findFormInput().vm.$emit('input', TEST_TITLE);
findForm().vm.$emit('submit', { preventDefault: () => {} });
@@ -147,7 +147,7 @@ describe('~/boards/components/sidebar/board_sidebar_issue_title.vue', () => {
createWrapper(TEST_ISSUE_B);
jest.spyOn(wrapper.vm, 'setActiveIssueTitle').mockImplementation(() => {
- store.state.issues[TEST_ISSUE_B.id].title = TEST_TITLE;
+ store.state.boardItems[TEST_ISSUE_B.id].title = TEST_TITLE;
});
findFormInput().vm.$emit('input', TEST_TITLE);
findCancelButton().vm.$emit('click');
diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js
index 12b873ba7d8..98ac211238c 100644
--- a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js
+++ b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js
@@ -25,7 +25,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => {
const createWrapper = ({ labels = [] } = {}) => {
store = createStore();
- store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, labels } };
+ store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, labels } };
store.state.activeId = TEST_ISSUE.id;
wrapper = shallowMount(BoardSidebarLabelsSelect, {
@@ -66,7 +66,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => {
jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => TEST_LABELS);
findLabelsSelect().vm.$emit('updateSelectedLabels', TEST_LABELS_PAYLOAD);
- store.state.issues[TEST_ISSUE.id].labels = TEST_LABELS;
+ store.state.boardItems[TEST_ISSUE.id].labels = TEST_LABELS;
await wrapper.vm.$nextTick();
});
diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js
index 8820ec7ae63..8706424a296 100644
--- a/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js
+++ b/spec/frontend/boards/components/sidebar/board_sidebar_milestone_select_spec.js
@@ -22,7 +22,7 @@ describe('~/boards/components/sidebar/board_sidebar_milestone_select.vue', () =>
const createWrapper = ({ milestone = null, loading = false } = {}) => {
store = createStore();
- store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, milestone } };
+ store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, milestone } };
store.state.activeId = TEST_ISSUE.id;
wrapper = shallowMount(BoardSidebarMilestoneSelect, {
@@ -113,7 +113,7 @@ describe('~/boards/components/sidebar/board_sidebar_milestone_select.vue', () =>
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => {
- store.state.issues[TEST_ISSUE.id].milestone = TEST_MILESTONE;
+ store.state.boardItems[TEST_ISSUE.id].milestone = TEST_MILESTONE;
});
findDropdownItem().vm.$emit('click');
await wrapper.vm.$nextTick();
@@ -137,7 +137,7 @@ describe('~/boards/components/sidebar/board_sidebar_milestone_select.vue', () =>
createWrapper({ milestone: TEST_MILESTONE });
jest.spyOn(wrapper.vm, 'setActiveIssueMilestone').mockImplementation(() => {
- store.state.issues[TEST_ISSUE.id].milestone = null;
+ store.state.boardItems[TEST_ISSUE.id].milestone = null;
});
findUnsetMilestoneItem().vm.$emit('click');
await wrapper.vm.$nextTick();
diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js
index 3e6b0be0267..cfd7f32b2cc 100644
--- a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js
+++ b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js
@@ -22,7 +22,7 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () =
const createComponent = (activeIssue = { ...mockActiveIssue }) => {
store = createStore();
- store.state.issues = { [activeIssue.id]: activeIssue };
+ store.state.boardItems = { [activeIssue.id]: activeIssue };
store.state.activeId = activeIssue.id;
wrapper = mount(BoardSidebarSubscription, {
@@ -45,6 +45,12 @@ describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () =
expect(findNotificationHeader().text()).toBe('Notifications');
});
+ it('renders toggle with label', () => {
+ createComponent();
+
+ expect(findToggle().props('label')).toBe(BoardSidebarSubscription.i18n.header.title);
+ });
+
it('renders toggle as "off" when currently not subscribed', () => {
createComponent();
diff --git a/spec/frontend/boards/components/sidebar/remove_issue_spec.js b/spec/frontend/boards/components/sidebar/remove_issue_spec.js
deleted file mode 100644
index 1f740c10106..00000000000
--- a/spec/frontend/boards/components/sidebar/remove_issue_spec.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import { GlButton } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-
-import RemoveIssue from '~/boards/components/sidebar/remove_issue.vue';
-
-describe('boards sidebar remove issue', () => {
- let wrapper;
-
- const findButton = () => wrapper.find(GlButton);
-
- const createComponent = (propsData) => {
- wrapper = shallowMount(RemoveIssue, {
- propsData: {
- issue: {},
- list: {},
- ...propsData,
- },
- });
- };
-
- beforeEach(() => {
- createComponent();
- });
-
- it('renders remove button', () => {
- expect(findButton().exists()).toBe(true);
- });
-});
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index e106b9235d6..500240d00fc 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -351,6 +351,7 @@ export const issues = {
[mockIssue4.id]: mockIssue4,
};
+// The response from group project REST API
export const mockRawGroupProjects = [
{
id: 0,
@@ -366,17 +367,34 @@ export const mockRawGroupProjects = [
},
];
-export const mockGroupProjects = [
- {
- id: 0,
- name: 'Example Project',
- nameWithNamespace: 'Awesome Group / Example Project',
- fullPath: 'awesome-group/example-project',
- },
- {
- id: 1,
- name: 'Foobar Project',
- nameWithNamespace: 'Awesome Group / Foobar Project',
- fullPath: 'awesome-group/foobar-project',
- },
+// The response from GraphQL endpoint
+export const mockGroupProject1 = {
+ id: 0,
+ name: 'Example Project',
+ nameWithNamespace: 'Awesome Group / Example Project',
+ fullPath: 'awesome-group/example-project',
+ archived: false,
+};
+
+export const mockGroupProject2 = {
+ id: 1,
+ name: 'Foobar Project',
+ nameWithNamespace: 'Awesome Group / Foobar Project',
+ fullPath: 'awesome-group/foobar-project',
+ archived: false,
+};
+
+export const mockArchivedGroupProject = {
+ id: 2,
+ name: 'Archived Project',
+ nameWithNamespace: 'Awesome Group / Archived Project',
+ fullPath: 'awesome-group/archived-project',
+ archived: true,
+};
+
+export const mockGroupProjects = [mockGroupProject1, mockGroupProject2];
+
+export const mockActiveGroupProjects = [
+ { ...mockGroupProject1, archived: false },
+ { ...mockGroupProject2, archived: false },
];
diff --git a/spec/frontend/boards/project_select_deprecated_spec.js b/spec/frontend/boards/project_select_deprecated_spec.js
index 9042c4bf9ba..37f519ef5b9 100644
--- a/spec/frontend/boards/project_select_deprecated_spec.js
+++ b/spec/frontend/boards/project_select_deprecated_spec.js
@@ -27,6 +27,7 @@ const mockDefaultFetchOptions = {
with_shared: false,
include_subgroups: true,
order_by: 'similarity',
+ archived: false,
};
const itemsPerPage = 20;
diff --git a/spec/frontend/boards/project_select_spec.js b/spec/frontend/boards/project_select_spec.js
index aa71952c42b..de823094630 100644
--- a/spec/frontend/boards/project_select_spec.js
+++ b/spec/frontend/boards/project_select_spec.js
@@ -1,30 +1,17 @@
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
-import { createLocalVue, mount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
+import Vue from 'vue';
import Vuex from 'vuex';
import ProjectSelect from '~/boards/components/project_select.vue';
import defaultState from '~/boards/stores/state';
-import { mockList, mockGroupProjects } from './mock_data';
+import { mockList, mockActiveGroupProjects } from './mock_data';
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-const actions = {
- fetchGroupProjects: jest.fn(),
- setSelectedProject: jest.fn(),
-};
-
-const createStore = (state = defaultState) => {
- return new Vuex.Store({
- state,
- actions,
- });
-};
-
-const mockProjectsList1 = mockGroupProjects.slice(0, 1);
+const mockProjectsList1 = mockActiveGroupProjects.slice(0, 1);
describe('ProjectSelect component', () => {
let wrapper;
+ let store;
const findLabel = () => wrapper.find("[data-testid='header-label']");
const findGlDropdown = () => wrapper.find(GlDropdown);
@@ -36,20 +23,37 @@ describe('ProjectSelect component', () => {
const findInMenuLoadingIcon = () => wrapper.find("[data-testid='dropdown-text-loading-icon']");
const findEmptySearchMessage = () => wrapper.find("[data-testid='empty-result-message']");
- const createWrapper = (state = {}) => {
- const store = createStore({
- groupProjects: [],
- groupProjectsFlags: {
- isLoading: false,
- pageInfo: {
- hasNextPage: false,
+ const createStore = ({ state, activeGroupProjects }) => {
+ Vue.use(Vuex);
+
+ store = new Vuex.Store({
+ state: {
+ defaultState,
+ groupProjectsFlags: {
+ isLoading: false,
+ pageInfo: {
+ hasNextPage: false,
+ },
},
+ ...state,
+ },
+ actions: {
+ fetchGroupProjects: jest.fn(),
+ setSelectedProject: jest.fn(),
},
- ...state,
+ getters: {
+ activeGroupProjects: () => activeGroupProjects,
+ },
+ });
+ };
+
+ const createWrapper = ({ state = {}, activeGroupProjects = [] } = {}) => {
+ createStore({
+ state,
+ activeGroupProjects,
});
wrapper = mount(ProjectSelect, {
- localVue,
propsData: {
list: mockList,
},
@@ -93,7 +97,7 @@ describe('ProjectSelect component', () => {
describe('when dropdown menu is open', () => {
describe('by default', () => {
beforeEach(() => {
- createWrapper({ groupProjects: mockGroupProjects });
+ createWrapper({ activeGroupProjects: mockActiveGroupProjects });
});
it('shows GlSearchBoxByType with default attributes', () => {
@@ -128,7 +132,7 @@ describe('ProjectSelect component', () => {
describe('when a project is selected', () => {
beforeEach(() => {
- createWrapper({ groupProjects: mockProjectsList1 });
+ createWrapper({ activeGroupProjects: mockProjectsList1 });
findFirstGlDropdownItem().find('button').trigger('click');
});
@@ -142,7 +146,7 @@ describe('ProjectSelect component', () => {
describe('when projects are loading', () => {
beforeEach(() => {
- createWrapper({ groupProjectsFlags: { isLoading: true } });
+ createWrapper({ state: { groupProjectsFlags: { isLoading: true } } });
});
it('displays and hides gl-loading-icon while and after fetching data', () => {
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index 32d0e7ae886..69d2c8977fb 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -5,7 +5,7 @@ import {
formatBoardLists,
formatIssueInput,
} from '~/boards/boards_util';
-import { inactiveId } from '~/boards/constants';
+import { inactiveId, ISSUABLE } from '~/boards/constants';
import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql';
import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql';
import issueMoveListMutation from '~/boards/graphql/issue_move_list.mutation.graphql';
@@ -112,6 +112,15 @@ describe('setActiveId', () => {
});
describe('fetchLists', () => {
+ it('should dispatch fetchIssueLists action', () => {
+ testAction({
+ action: actions.fetchLists,
+ expectedActions: [{ type: 'fetchIssueLists' }],
+ });
+ });
+});
+
+describe('fetchIssueLists', () => {
const state = {
fullPath: 'gitlab-org',
boardId: '1',
@@ -138,7 +147,7 @@ describe('fetchLists', () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
testAction(
- actions.fetchLists,
+ actions.fetchIssueLists,
{},
state,
[
@@ -152,6 +161,23 @@ describe('fetchLists', () => {
);
});
+ it('should commit mutations RECEIVE_BOARD_LISTS_FAILURE on failure', (done) => {
+ jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject());
+
+ testAction(
+ actions.fetchIssueLists,
+ {},
+ state,
+ [
+ {
+ type: types.RECEIVE_BOARD_LISTS_FAILURE,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+
it('dispatch createList action when backlog list does not exist and is not hidden', (done) => {
queryResponse = {
data: {
@@ -168,7 +194,7 @@ describe('fetchLists', () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
testAction(
- actions.fetchLists,
+ actions.fetchIssueLists,
{},
state,
[
@@ -184,6 +210,16 @@ describe('fetchLists', () => {
});
describe('createList', () => {
+ it('should dispatch createIssueList action', () => {
+ testAction({
+ action: actions.createList,
+ payload: { backlog: true },
+ expectedActions: [{ type: 'createIssueList', payload: { backlog: true } }],
+ });
+ });
+});
+
+describe('createIssueList', () => {
let commit;
let dispatch;
let getters;
@@ -223,7 +259,7 @@ describe('createList', () => {
}),
);
- await actions.createList({ getters, state, commit, dispatch }, { backlog: true });
+ await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true });
expect(dispatch).toHaveBeenCalledWith('addList', backlogList);
});
@@ -245,7 +281,7 @@ describe('createList', () => {
},
});
- await actions.createList({ getters, state, commit, dispatch }, { labelId: '4' });
+ await actions.createIssueList({ getters, state, commit, dispatch }, { labelId: '4' });
expect(dispatch).toHaveBeenCalledWith('addList', list);
expect(dispatch).toHaveBeenCalledWith('highlightList', list.id);
@@ -257,15 +293,15 @@ describe('createList', () => {
data: {
boardListCreate: {
list: {},
- errors: [{ foo: 'bar' }],
+ errors: ['foo'],
},
},
}),
);
- await actions.createList({ getters, state, commit, dispatch }, { backlog: true });
+ await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true });
- expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE);
+ expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE, 'foo');
});
it('highlights list and does not re-query if it already exists', async () => {
@@ -280,7 +316,7 @@ describe('createList', () => {
getListByLabelId: jest.fn().mockReturnValue(existingList),
};
- await actions.createList({ getters, state, commit, dispatch }, { backlog: true });
+ await actions.createIssueList({ getters, state, commit, dispatch }, { backlog: true });
expect(dispatch).toHaveBeenCalledWith('highlightList', existingList.id);
expect(dispatch).toHaveBeenCalledTimes(1);
@@ -301,11 +337,15 @@ describe('fetchLabels', () => {
};
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
- await testAction({
- action: actions.fetchLabels,
- state: { boardType: 'group' },
- expectedMutations: [{ type: types.RECEIVE_LABELS_SUCCESS, payload: labels }],
- });
+ const commit = jest.fn();
+ const getters = {
+ shouldUseGraphQL: () => true,
+ };
+ const state = { boardType: 'group' };
+
+ await actions.fetchLabels({ getters, state, commit });
+
+ expect(commit).toHaveBeenCalledWith(types.RECEIVE_LABELS_SUCCESS, labels);
});
});
@@ -412,6 +452,22 @@ describe('updateList', () => {
});
});
+describe('toggleListCollapsed', () => {
+ it('should commit TOGGLE_LIST_COLLAPSED mutation', async () => {
+ const payload = { listId: 'gid://gitlab/List/1', collapsed: true };
+ await testAction({
+ action: actions.toggleListCollapsed,
+ payload,
+ expectedMutations: [
+ {
+ type: types.TOGGLE_LIST_COLLAPSED,
+ payload,
+ },
+ ],
+ });
+ });
+});
+
describe('removeList', () => {
let state;
const list = mockLists[0];
@@ -490,7 +546,7 @@ describe('removeList', () => {
});
});
-describe('fetchIssuesForList', () => {
+describe('fetchItemsForList', () => {
const listId = mockLists[0].id;
const state = {
@@ -533,21 +589,21 @@ describe('fetchIssuesForList', () => {
[listId]: pageInfo,
};
- it('should commit mutations REQUEST_ISSUES_FOR_LIST and RECEIVE_ISSUES_FOR_LIST_SUCCESS on success', (done) => {
+ it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_SUCCESS on success', (done) => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
testAction(
- actions.fetchIssuesForList,
+ actions.fetchItemsForList,
{ listId },
state,
[
{
- type: types.REQUEST_ISSUES_FOR_LIST,
+ type: types.REQUEST_ITEMS_FOR_LIST,
payload: { listId, fetchNext: false },
},
{
- type: types.RECEIVE_ISSUES_FOR_LIST_SUCCESS,
- payload: { listIssues: formattedIssues, listPageInfo, listId },
+ type: types.RECEIVE_ITEMS_FOR_LIST_SUCCESS,
+ payload: { listItems: formattedIssues, listPageInfo, listId },
},
],
[],
@@ -555,19 +611,19 @@ describe('fetchIssuesForList', () => {
);
});
- it('should commit mutations REQUEST_ISSUES_FOR_LIST and RECEIVE_ISSUES_FOR_LIST_FAILURE on failure', (done) => {
+ it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_FAILURE on failure', (done) => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject());
testAction(
- actions.fetchIssuesForList,
+ actions.fetchItemsForList,
{ listId },
state,
[
{
- type: types.REQUEST_ISSUES_FOR_LIST,
+ type: types.REQUEST_ITEMS_FOR_LIST,
payload: { listId, fetchNext: false },
},
- { type: types.RECEIVE_ISSUES_FOR_LIST_FAILURE, payload: listId },
+ { type: types.RECEIVE_ITEMS_FOR_LIST_FAILURE, payload: listId },
],
[],
done,
@@ -581,6 +637,15 @@ describe('resetIssues', () => {
});
});
+describe('moveItem', () => {
+ it('should dispatch moveIssue action', () => {
+ testAction({
+ action: actions.moveItem,
+ expectedActions: [{ type: 'moveIssue' }],
+ });
+ });
+});
+
describe('moveIssue', () => {
const listIssues = {
'gid://gitlab/List/1': [436, 437],
@@ -598,8 +663,8 @@ describe('moveIssue', () => {
boardType: 'group',
disabled: false,
boardLists: mockLists,
- issuesByListId: listIssues,
- issues,
+ boardItemsByListId: listIssues,
+ boardItems: issues,
};
it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_SUCCESS mutation when successful', (done) => {
@@ -615,9 +680,9 @@ describe('moveIssue', () => {
testAction(
actions.moveIssue,
{
- issueId: '436',
- issueIid: mockIssue.iid,
- issuePath: mockIssue.referencePath,
+ itemId: '436',
+ itemIid: mockIssue.iid,
+ itemPath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
},
@@ -666,9 +731,9 @@ describe('moveIssue', () => {
actions.moveIssue(
{ state, commit: () => {} },
{
- issueId: mockIssue.id,
- issueIid: mockIssue.iid,
- issuePath: mockIssue.referencePath,
+ itemId: mockIssue.id,
+ itemIid: mockIssue.iid,
+ itemPath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
},
@@ -690,9 +755,9 @@ describe('moveIssue', () => {
testAction(
actions.moveIssue,
{
- issueId: '436',
- issueIid: mockIssue.iid,
- issuePath: mockIssue.referencePath,
+ itemId: '436',
+ itemIid: mockIssue.iid,
+ itemPath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
},
@@ -879,7 +944,7 @@ describe('addListIssue', () => {
});
describe('setActiveIssueLabels', () => {
- const state = { issues: { [mockIssue.id]: mockIssue } };
+ const state = { boardItems: { [mockIssue.id]: mockIssue } };
const getters = { activeIssue: mockIssue };
const testLabelIds = labels.map((label) => label.id);
const input = {
@@ -924,7 +989,7 @@ describe('setActiveIssueLabels', () => {
});
describe('setActiveIssueDueDate', () => {
- const state = { issues: { [mockIssue.id]: mockIssue } };
+ const state = { boardItems: { [mockIssue.id]: mockIssue } };
const getters = { activeIssue: mockIssue };
const testDueDate = '2020-02-20';
const input = {
@@ -975,7 +1040,7 @@ describe('setActiveIssueDueDate', () => {
});
describe('setActiveIssueSubscribed', () => {
- const state = { issues: { [mockActiveIssue.id]: mockActiveIssue } };
+ const state = { boardItems: { [mockActiveIssue.id]: mockActiveIssue } };
const getters = { activeIssue: mockActiveIssue };
const subscribedState = true;
const input = {
@@ -1026,7 +1091,7 @@ describe('setActiveIssueSubscribed', () => {
});
describe('setActiveIssueMilestone', () => {
- const state = { issues: { [mockIssue.id]: mockIssue } };
+ const state = { boardItems: { [mockIssue.id]: mockIssue } };
const getters = { activeIssue: mockIssue };
const testMilestone = {
...mockMilestone,
@@ -1080,7 +1145,7 @@ describe('setActiveIssueMilestone', () => {
});
describe('setActiveIssueTitle', () => {
- const state = { issues: { [mockIssue.id]: mockIssue } };
+ const state = { boardItems: { [mockIssue.id]: mockIssue } };
const getters = { activeIssue: mockIssue };
const testTitle = 'Test Title';
const input = {
@@ -1220,6 +1285,7 @@ describe('setSelectedProject', () => {
describe('toggleBoardItemMultiSelection', () => {
const boardItem = mockIssue;
+ const boardItem2 = mockIssue2;
it('should commit mutation ADD_BOARD_ITEM_TO_SELECTION if item is not on selection state', () => {
testAction(
@@ -1250,6 +1316,66 @@ describe('toggleBoardItemMultiSelection', () => {
[],
);
});
+
+ it('should additionally commit mutation ADD_BOARD_ITEM_TO_SELECTION for active issue and dispatch unsetActiveId', () => {
+ testAction(
+ actions.toggleBoardItemMultiSelection,
+ boardItem2,
+ { activeId: mockActiveIssue.id, activeIssue: mockActiveIssue, selectedBoardItems: [] },
+ [
+ {
+ type: types.ADD_BOARD_ITEM_TO_SELECTION,
+ payload: mockActiveIssue,
+ },
+ {
+ type: types.ADD_BOARD_ITEM_TO_SELECTION,
+ payload: boardItem2,
+ },
+ ],
+ [{ type: 'unsetActiveId' }],
+ );
+ });
+});
+
+describe('resetBoardItemMultiSelection', () => {
+ it('should commit mutation RESET_BOARD_ITEM_SELECTION', () => {
+ testAction({
+ action: actions.resetBoardItemMultiSelection,
+ state: { selectedBoardItems: [mockIssue] },
+ expectedMutations: [
+ {
+ type: types.RESET_BOARD_ITEM_SELECTION,
+ },
+ ],
+ });
+ });
+});
+
+describe('toggleBoardItem', () => {
+ it('should dispatch resetBoardItemMultiSelection and unsetActiveId when boardItem is the active item', () => {
+ testAction({
+ action: actions.toggleBoardItem,
+ payload: { boardItem: mockIssue },
+ state: {
+ activeId: mockIssue.id,
+ },
+ expectedActions: [{ type: 'resetBoardItemMultiSelection' }, { type: 'unsetActiveId' }],
+ });
+ });
+
+ it('should dispatch resetBoardItemMultiSelection and setActiveId when boardItem is not the active item', () => {
+ testAction({
+ action: actions.toggleBoardItem,
+ payload: { boardItem: mockIssue },
+ state: {
+ activeId: inactiveId,
+ },
+ expectedActions: [
+ { type: 'resetBoardItemMultiSelection' },
+ { type: 'setActiveId', payload: { id: mockIssue.id, sidebarType: ISSUABLE } },
+ ],
+ });
+ });
});
describe('fetchBacklog', () => {
diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js
index d5a19bf613f..32d73d861bc 100644
--- a/spec/frontend/boards/stores/getters_spec.js
+++ b/spec/frontend/boards/stores/getters_spec.js
@@ -7,9 +7,47 @@ import {
mockIssuesByListId,
issues,
mockLists,
+ mockGroupProject1,
+ mockArchivedGroupProject,
} from '../mock_data';
describe('Boards - Getters', () => {
+ describe('isGroupBoard', () => {
+ it('returns true when boardType on state is group', () => {
+ const state = {
+ boardType: 'group',
+ };
+
+ expect(getters.isGroupBoard(state)).toBe(true);
+ });
+
+ it('returns false when boardType on state is not group', () => {
+ const state = {
+ boardType: 'project',
+ };
+
+ expect(getters.isGroupBoard(state)).toBe(false);
+ });
+ });
+
+ describe('isProjectBoard', () => {
+ it('returns true when boardType on state is project', () => {
+ const state = {
+ boardType: 'project',
+ };
+
+ expect(getters.isProjectBoard(state)).toBe(true);
+ });
+
+ it('returns false when boardType on state is not project', () => {
+ const state = {
+ boardType: 'group',
+ };
+
+ expect(getters.isProjectBoard(state)).toBe(false);
+ });
+ });
+
describe('isSidebarOpen', () => {
it('returns true when activeId is not equal to 0', () => {
const state = {
@@ -38,15 +76,15 @@ describe('Boards - Getters', () => {
});
});
- describe('getIssueById', () => {
- const state = { issues: { 1: 'issue' } };
+ describe('getBoardItemById', () => {
+ const state = { boardItems: { 1: 'issue' } };
it.each`
id | expected
${'1'} | ${'issue'}
${''} | ${{}}
`('returns $expected when $id is passed to state', ({ id, expected }) => {
- expect(getters.getIssueById(state)(id)).toEqual(expected);
+ expect(getters.getBoardItemById(state)(id)).toEqual(expected);
});
});
@@ -56,7 +94,7 @@ describe('Boards - Getters', () => {
${'1'} | ${'issue'}
${''} | ${{}}
`('returns $expected when $id is passed to state', ({ id, expected }) => {
- const state = { issues: { 1: 'issue' }, activeId: id };
+ const state = { boardItems: { 1: 'issue' }, activeId: id };
expect(getters.activeIssue(state)).toEqual(expected);
});
@@ -94,17 +132,18 @@ describe('Boards - Getters', () => {
});
});
- describe('getIssuesByList', () => {
+ describe('getBoardItemsByList', () => {
const boardsState = {
- issuesByListId: mockIssuesByListId,
- issues,
+ boardItemsByListId: mockIssuesByListId,
+ boardItems: issues,
};
it('returns issues for a given listId', () => {
- const getIssueById = (issueId) => [mockIssue, mockIssue2].find(({ id }) => id === issueId);
+ const getBoardItemById = (issueId) =>
+ [mockIssue, mockIssue2].find(({ id }) => id === issueId);
- expect(getters.getIssuesByList(boardsState, { getIssueById })('gid://gitlab/List/2')).toEqual(
- mockIssues,
- );
+ expect(
+ getters.getBoardItemsByList(boardsState, { getBoardItemById })('gid://gitlab/List/2'),
+ ).toEqual(mockIssues);
});
});
@@ -128,4 +167,14 @@ describe('Boards - Getters', () => {
expect(getters.getListByTitle(boardsState)('To Do')).toEqual(mockLists[1]);
});
});
+
+ describe('activeGroupProjects', () => {
+ const state = {
+ groupProjects: [mockGroupProject1, mockArchivedGroupProject],
+ };
+
+ it('returns only returns non-archived group projects', () => {
+ expect(getters.activeGroupProjects(state)).toEqual([mockGroupProject1]);
+ });
+ });
});
diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js
index 9423f2ed583..33897cc0250 100644
--- a/spec/frontend/boards/stores/mutations_spec.js
+++ b/spec/frontend/boards/stores/mutations_spec.js
@@ -1,3 +1,4 @@
+import { issuableTypes } from '~/boards/constants';
import * as types from '~/boards/stores/mutation_types';
import mutations from '~/boards/stores/mutations';
import defaultState from '~/boards/stores/state';
@@ -37,6 +38,7 @@ describe('Board Store Mutations', () => {
const boardConfig = {
milestoneTitle: 'Milestone 1',
};
+ const issuableType = issuableTypes.issue;
mutations[types.SET_INITIAL_BOARD_DATA](state, {
boardId,
@@ -44,6 +46,7 @@ describe('Board Store Mutations', () => {
boardType,
disabled,
boardConfig,
+ issuableType,
});
expect(state.boardId).toEqual(boardId);
@@ -51,6 +54,7 @@ describe('Board Store Mutations', () => {
expect(state.boardType).toEqual(boardType);
expect(state.disabled).toEqual(disabled);
expect(state.boardConfig).toEqual(boardConfig);
+ expect(state.issuableType).toEqual(issuableType);
});
});
@@ -106,11 +110,31 @@ describe('Board Store Mutations', () => {
});
});
+ describe('RECEIVE_LABELS_REQUEST', () => {
+ it('sets labelsLoading on state', () => {
+ mutations.RECEIVE_LABELS_REQUEST(state);
+
+ expect(state.labelsLoading).toEqual(true);
+ });
+ });
+
describe('RECEIVE_LABELS_SUCCESS', () => {
it('sets labels on state', () => {
mutations.RECEIVE_LABELS_SUCCESS(state, labels);
expect(state.labels).toEqual(labels);
+ expect(state.labelsLoading).toEqual(false);
+ });
+ });
+
+ describe('RECEIVE_LABELS_FAILURE', () => {
+ it('sets error message', () => {
+ mutations.RECEIVE_LABELS_FAILURE(state);
+
+ expect(state.error).toEqual(
+ 'An error occurred while fetching labels. Please reload the page.',
+ );
+ expect(state.labelsLoading).toEqual(false);
});
});
@@ -179,6 +203,24 @@ describe('Board Store Mutations', () => {
});
});
+ describe('TOGGLE_LIST_COLLAPSED', () => {
+ it('updates collapsed attribute of list in boardLists state', () => {
+ const listId = 'gid://gitlab/List/1';
+ state = {
+ ...state,
+ boardLists: {
+ [listId]: mockLists[0],
+ },
+ };
+
+ expect(state.boardLists[listId].collapsed).toEqual(false);
+
+ mutations.TOGGLE_LIST_COLLAPSED(state, { listId, collapsed: true });
+
+ expect(state.boardLists[listId].collapsed).toEqual(true);
+ });
+ });
+
describe('REMOVE_LIST', () => {
it('removes list from boardLists', () => {
const [list, secondList] = mockLists;
@@ -219,24 +261,24 @@ describe('Board Store Mutations', () => {
});
describe('RESET_ISSUES', () => {
- it('should remove issues from issuesByListId state', () => {
- const issuesByListId = {
+ it('should remove issues from boardItemsByListId state', () => {
+ const boardItemsByListId = {
'gid://gitlab/List/1': [mockIssue.id],
};
state = {
...state,
- issuesByListId,
+ boardItemsByListId,
};
mutations[types.RESET_ISSUES](state);
- expect(state.issuesByListId).toEqual({ 'gid://gitlab/List/1': [] });
+ expect(state.boardItemsByListId).toEqual({ 'gid://gitlab/List/1': [] });
});
});
- describe('RECEIVE_ISSUES_FOR_LIST_SUCCESS', () => {
- it('updates issuesByListId and issues on state', () => {
+ describe('RECEIVE_ITEMS_FOR_LIST_SUCCESS', () => {
+ it('updates boardItemsByListId and issues on state', () => {
const listIssues = {
'gid://gitlab/List/1': [mockIssue.id],
};
@@ -246,10 +288,10 @@ describe('Board Store Mutations', () => {
state = {
...state,
- issuesByListId: {
+ boardItemsByListId: {
'gid://gitlab/List/1': [],
},
- issues: {},
+ boardItems: {},
boardLists: initialBoardListsState,
};
@@ -260,18 +302,18 @@ describe('Board Store Mutations', () => {
},
};
- mutations.RECEIVE_ISSUES_FOR_LIST_SUCCESS(state, {
- listIssues: { listData: listIssues, issues },
+ mutations.RECEIVE_ITEMS_FOR_LIST_SUCCESS(state, {
+ listItems: { listData: listIssues, boardItems: issues },
listPageInfo,
listId: 'gid://gitlab/List/1',
});
- expect(state.issuesByListId).toEqual(listIssues);
- expect(state.issues).toEqual(issues);
+ expect(state.boardItemsByListId).toEqual(listIssues);
+ expect(state.boardItems).toEqual(issues);
});
});
- describe('RECEIVE_ISSUES_FOR_LIST_FAILURE', () => {
+ describe('RECEIVE_ITEMS_FOR_LIST_FAILURE', () => {
it('sets error message', () => {
state = {
...state,
@@ -281,7 +323,7 @@ describe('Board Store Mutations', () => {
const listId = 'gid://gitlab/List/1';
- mutations.RECEIVE_ISSUES_FOR_LIST_FAILURE(state, listId);
+ mutations.RECEIVE_ITEMS_FOR_LIST_FAILURE(state, listId);
expect(state.error).toEqual(
'An error occurred while fetching the board issues. Please reload the page.',
@@ -303,7 +345,7 @@ describe('Board Store Mutations', () => {
state = {
...state,
error: undefined,
- issues: {
+ boardItems: {
...issue,
},
};
@@ -317,7 +359,7 @@ describe('Board Store Mutations', () => {
value,
});
- expect(state.issues[issueId]).toEqual({ ...issue[issueId], id: '2' });
+ expect(state.boardItems[issueId]).toEqual({ ...issue[issueId], id: '2' });
});
});
@@ -343,7 +385,7 @@ describe('Board Store Mutations', () => {
});
describe('MOVE_ISSUE', () => {
- it('updates issuesByListId, moving issue between lists', () => {
+ it('updates boardItemsByListId, moving issue between lists', () => {
const listIssues = {
'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id],
'gid://gitlab/List/2': [],
@@ -356,9 +398,9 @@ describe('Board Store Mutations', () => {
state = {
...state,
- issuesByListId: listIssues,
+ boardItemsByListId: listIssues,
boardLists: initialBoardListsState,
- issues,
+ boardItems: issues,
};
mutations.MOVE_ISSUE(state, {
@@ -372,7 +414,7 @@ describe('Board Store Mutations', () => {
'gid://gitlab/List/2': [mockIssue2.id],
};
- expect(state.issuesByListId).toEqual(updatedListIssues);
+ expect(state.boardItemsByListId).toEqual(updatedListIssues);
});
});
@@ -384,19 +426,19 @@ describe('Board Store Mutations', () => {
state = {
...state,
- issues,
+ boardItems: issues,
};
mutations.MOVE_ISSUE_SUCCESS(state, {
issue: rawIssue,
});
- expect(state.issues).toEqual({ 436: { ...mockIssue, id: 436 } });
+ expect(state.boardItems).toEqual({ 436: { ...mockIssue, id: 436 } });
});
});
describe('MOVE_ISSUE_FAILURE', () => {
- it('updates issuesByListId, reverting moving issue between lists, and sets error message', () => {
+ it('updates boardItemsByListId, reverting moving issue between lists, and sets error message', () => {
const listIssues = {
'gid://gitlab/List/1': [mockIssue.id],
'gid://gitlab/List/2': [mockIssue2.id],
@@ -404,7 +446,7 @@ describe('Board Store Mutations', () => {
state = {
...state,
- issuesByListId: listIssues,
+ boardItemsByListId: listIssues,
boardLists: initialBoardListsState,
};
@@ -420,7 +462,7 @@ describe('Board Store Mutations', () => {
'gid://gitlab/List/2': [],
};
- expect(state.issuesByListId).toEqual(updatedListIssues);
+ expect(state.boardItemsByListId).toEqual(updatedListIssues);
expect(state.error).toEqual('An error occurred while moving the issue. Please try again.');
});
});
@@ -446,7 +488,7 @@ describe('Board Store Mutations', () => {
});
describe('ADD_ISSUE_TO_LIST', () => {
- it('adds issue to issues state and issue id in list in issuesByListId', () => {
+ it('adds issue to issues state and issue id in list in boardItemsByListId', () => {
const listIssues = {
'gid://gitlab/List/1': [mockIssue.id],
};
@@ -456,8 +498,8 @@ describe('Board Store Mutations', () => {
state = {
...state,
- issuesByListId: listIssues,
- issues,
+ boardItemsByListId: listIssues,
+ boardItems: issues,
boardLists: initialBoardListsState,
};
@@ -465,14 +507,14 @@ describe('Board Store Mutations', () => {
mutations.ADD_ISSUE_TO_LIST(state, { list: mockLists[0], issue: mockIssue2 });
- expect(state.issuesByListId['gid://gitlab/List/1']).toContain(mockIssue2.id);
- expect(state.issues[mockIssue2.id]).toEqual(mockIssue2);
+ expect(state.boardItemsByListId['gid://gitlab/List/1']).toContain(mockIssue2.id);
+ expect(state.boardItems[mockIssue2.id]).toEqual(mockIssue2);
expect(state.boardLists['gid://gitlab/List/1'].issuesCount).toBe(2);
});
});
describe('ADD_ISSUE_TO_LIST_FAILURE', () => {
- it('removes issue id from list in issuesByListId and sets error message', () => {
+ it('removes issue id from list in boardItemsByListId and sets error message', () => {
const listIssues = {
'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id],
};
@@ -483,20 +525,20 @@ describe('Board Store Mutations', () => {
state = {
...state,
- issuesByListId: listIssues,
- issues,
+ boardItemsByListId: listIssues,
+ boardItems: issues,
boardLists: initialBoardListsState,
};
mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issueId: mockIssue2.id });
- expect(state.issuesByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id);
+ expect(state.boardItemsByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id);
expect(state.error).toBe('An error occurred while creating the issue. Please try again.');
});
});
describe('REMOVE_ISSUE_FROM_LIST', () => {
- it('removes issue id from list in issuesByListId and deletes issue from state', () => {
+ it('removes issue id from list in boardItemsByListId and deletes issue from state', () => {
const listIssues = {
'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id],
};
@@ -507,15 +549,15 @@ describe('Board Store Mutations', () => {
state = {
...state,
- issuesByListId: listIssues,
- issues,
+ boardItemsByListId: listIssues,
+ boardItems: issues,
boardLists: initialBoardListsState,
};
mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issueId: mockIssue2.id });
- expect(state.issuesByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id);
- expect(state.issues).not.toContain(mockIssue2);
+ expect(state.boardItemsByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id);
+ expect(state.boardItems).not.toContain(mockIssue2);
});
});
@@ -607,14 +649,21 @@ describe('Board Store Mutations', () => {
describe('REMOVE_BOARD_ITEM_FROM_SELECTION', () => {
it('Should remove boardItem to selectedBoardItems state', () => {
- state = {
- ...state,
- selectedBoardItems: [mockIssue],
- };
+ state.selectedBoardItems = [mockIssue];
mutations[types.REMOVE_BOARD_ITEM_FROM_SELECTION](state, mockIssue);
expect(state.selectedBoardItems).toEqual([]);
});
});
+
+ describe('RESET_BOARD_ITEM_SELECTION', () => {
+ it('Should reset selectedBoardItems state', () => {
+ state.selectedBoardItems = [mockIssue];
+
+ mutations[types.RESET_BOARD_ITEM_SELECTION](state, mockIssue);
+
+ expect(state.selectedBoardItems).toEqual([]);
+ });
+ });
});
diff --git a/spec/frontend/bootstrap_linked_tabs_spec.js b/spec/frontend/bootstrap_linked_tabs_spec.js
index 2d8939e6480..30fb140bc69 100644
--- a/spec/frontend/bootstrap_linked_tabs_spec.js
+++ b/spec/frontend/bootstrap_linked_tabs_spec.js
@@ -1,8 +1,6 @@
import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
describe('Linked Tabs', () => {
- preloadFixtures('static/linked_tabs.html');
-
beforeEach(() => {
loadFixtures('static/linked_tabs.html');
});
diff --git a/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js b/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js
new file mode 100644
index 00000000000..df81b78d010
--- /dev/null
+++ b/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js
@@ -0,0 +1,119 @@
+import MockAdapter from 'axios-mock-adapter';
+
+import { registerCaptchaModalInterceptor } from '~/captcha/captcha_modal_axios_interceptor';
+import { waitForCaptchaToBeSolved } from '~/captcha/wait_for_captcha_to_be_solved';
+import axios from '~/lib/utils/axios_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
+
+jest.mock('~/captcha/wait_for_captcha_to_be_solved');
+
+describe('registerCaptchaModalInterceptor', () => {
+ const SPAM_LOG_ID = 'SPAM_LOG_ID';
+ const CAPTCHA_SITE_KEY = 'CAPTCHA_SITE_KEY';
+ const CAPTCHA_SUCCESS = 'CAPTCHA_SUCCESS';
+ const CAPTCHA_RESPONSE = 'CAPTCHA_RESPONSE';
+ const AXIOS_RESPONSE = { text: 'AXIOS_RESPONSE' };
+ const NEEDS_CAPTCHA_RESPONSE = {
+ needs_captcha_response: true,
+ captcha_site_key: CAPTCHA_SITE_KEY,
+ spam_log_id: SPAM_LOG_ID,
+ };
+
+ const unsupportedMethods = ['delete', 'get', 'head', 'options'];
+ const supportedMethods = ['patch', 'post', 'put'];
+
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onAny('/no-captcha').reply(200, AXIOS_RESPONSE);
+ mock.onAny('/error').reply(404, AXIOS_RESPONSE);
+ mock.onAny('/captcha').reply((config) => {
+ if (!supportedMethods.includes(config.method)) {
+ return [httpStatusCodes.METHOD_NOT_ALLOWED, { method: config.method }];
+ }
+
+ try {
+ const { captcha_response, spam_log_id, ...rest } = JSON.parse(config.data);
+ // eslint-disable-next-line babel/camelcase
+ if (captcha_response === CAPTCHA_RESPONSE && spam_log_id === SPAM_LOG_ID) {
+ return [httpStatusCodes.OK, { ...rest, method: config.method, CAPTCHA_SUCCESS }];
+ }
+ } catch (e) {
+ return [httpStatusCodes.BAD_REQUEST, { method: config.method }];
+ }
+
+ return [httpStatusCodes.CONFLICT, NEEDS_CAPTCHA_RESPONSE];
+ });
+
+ axios.interceptors.response.handlers = [];
+ registerCaptchaModalInterceptor(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe.each([...supportedMethods, ...unsupportedMethods])('For HTTP method %s', (method) => {
+ it('successful requests are passed through', async () => {
+ const { data, status } = await axios[method]('/no-captcha');
+
+ expect(status).toEqual(httpStatusCodes.OK);
+ expect(data).toEqual(AXIOS_RESPONSE);
+ expect(mock.history[method]).toHaveLength(1);
+ });
+
+ it('error requests without needs_captcha_response_errors are passed through', async () => {
+ await expect(() => axios[method]('/error')).rejects.toThrow(
+ expect.objectContaining({
+ response: expect.objectContaining({
+ status: httpStatusCodes.NOT_FOUND,
+ data: AXIOS_RESPONSE,
+ }),
+ }),
+ );
+ expect(mock.history[method]).toHaveLength(1);
+ });
+ });
+
+ describe.each(supportedMethods)('For HTTP method %s', (method) => {
+ describe('error requests with needs_captcha_response_errors', () => {
+ const submittedData = { ID: 12345 };
+
+ it('re-submits request if captcha was solved correctly', async () => {
+ waitForCaptchaToBeSolved.mockResolvedValue(CAPTCHA_RESPONSE);
+ const { data: returnedData } = await axios[method]('/captcha', submittedData);
+
+ expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY);
+
+ expect(returnedData).toEqual({ ...submittedData, CAPTCHA_SUCCESS, method });
+ expect(mock.history[method]).toHaveLength(2);
+ });
+
+ it('does not re-submit request if captcha was not solved', async () => {
+ const error = new Error('Captcha not solved');
+ waitForCaptchaToBeSolved.mockRejectedValue(error);
+ await expect(() => axios[method]('/captcha', submittedData)).rejects.toThrow(error);
+
+ expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY);
+ expect(mock.history[method]).toHaveLength(1);
+ });
+ });
+ });
+
+ describe.each(unsupportedMethods)('For HTTP method %s', (method) => {
+ it('ignores captcha response', async () => {
+ await expect(() => axios[method]('/captcha')).rejects.toThrow(
+ expect.objectContaining({
+ response: expect.objectContaining({
+ status: httpStatusCodes.METHOD_NOT_ALLOWED,
+ data: { method },
+ }),
+ }),
+ );
+
+ expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled();
+ expect(mock.history[method]).toHaveLength(1);
+ });
+ });
+});
diff --git a/spec/frontend/captcha/wait_for_captcha_to_be_solved_spec.js b/spec/frontend/captcha/wait_for_captcha_to_be_solved_spec.js
new file mode 100644
index 00000000000..08d031a4fa7
--- /dev/null
+++ b/spec/frontend/captcha/wait_for_captcha_to_be_solved_spec.js
@@ -0,0 +1,56 @@
+import CaptchaModal from '~/captcha/captcha_modal.vue';
+import { waitForCaptchaToBeSolved } from '~/captcha/wait_for_captcha_to_be_solved';
+
+jest.mock('~/captcha/captcha_modal.vue', () => ({
+ mounted: jest.fn(),
+ render(h) {
+ return h('div', { attrs: { id: 'mock-modal' } });
+ },
+}));
+
+describe('waitForCaptchaToBeSolved', () => {
+ const response = 'CAPTCHA_RESPONSE';
+
+ const findModal = () => document.querySelector('#mock-modal');
+
+ it('opens a modal, resolves with captcha response on success', async () => {
+ CaptchaModal.mounted.mockImplementationOnce(function mounted() {
+ requestAnimationFrame(() => {
+ this.$emit('receivedCaptchaResponse', response);
+ this.$emit('hidden');
+ });
+ });
+
+ expect(findModal()).toBeNull();
+
+ const promise = waitForCaptchaToBeSolved('FOO');
+
+ expect(findModal()).not.toBeNull();
+
+ const result = await promise;
+ expect(result).toEqual(response);
+
+ expect(findModal()).toBeNull();
+ expect(document.body.innerHTML).toEqual('');
+ });
+
+ it("opens a modal, rejects with error in case the captcha isn't solved", async () => {
+ CaptchaModal.mounted.mockImplementationOnce(function mounted() {
+ requestAnimationFrame(() => {
+ this.$emit('receivedCaptchaResponse', null);
+ this.$emit('hidden');
+ });
+ });
+
+ expect(findModal()).toBeNull();
+
+ const promise = waitForCaptchaToBeSolved('FOO');
+
+ expect(findModal()).not.toBeNull();
+
+ await expect(promise).rejects.toThrow(/You must solve the CAPTCHA in order to submit/);
+
+ expect(findModal()).toBeNull();
+ expect(document.body.innerHTML).toEqual('');
+ });
+});
diff --git a/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js
index ad1bdec1735..1bca21b1d57 100644
--- a/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js
+++ b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js
@@ -4,9 +4,6 @@ import VariableList from '~/ci_variable_list/ci_variable_list';
const HIDE_CLASS = 'hide';
describe('VariableList', () => {
- preloadFixtures('pipeline_schedules/edit.html');
- preloadFixtures('pipeline_schedules/edit_with_variables.html');
-
let $wrapper;
let variableList;
diff --git a/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js b/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js
index 4982b68fa81..eee1362440d 100644
--- a/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js
+++ b/spec/frontend/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js
@@ -2,8 +2,6 @@ import $ from 'jquery';
import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list';
describe('NativeFormVariableList', () => {
- preloadFixtures('pipeline_schedules/edit.html');
-
let $wrapper;
beforeEach(() => {
diff --git a/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js b/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js
index 75c6e8e4540..5c5ea102f12 100644
--- a/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js
+++ b/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdownItem, GlIcon } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue';
@@ -10,6 +10,9 @@ describe('Ci environments dropdown', () => {
let wrapper;
let store;
+ const enterSearchTerm = (value) =>
+ wrapper.find('[data-testid="ci-environment-search"]').setValue(value);
+
const createComponent = (term) => {
store = new Vuex.Store({
getters: {
@@ -24,11 +27,12 @@ describe('Ci environments dropdown', () => {
value: term,
},
});
+ enterSearchTerm(term);
};
- const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem);
- const findDropdownItemByIndex = (index) => wrapper.findAll(GlDropdownItem).at(index);
- const findActiveIconByIndex = (index) => findDropdownItemByIndex(index).find(GlIcon);
+ const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findDropdownItemByIndex = (index) => wrapper.findAllComponents(GlDropdownItem).at(index);
+ const findActiveIconByIndex = (index) => findDropdownItemByIndex(index).findComponent(GlIcon);
afterEach(() => {
wrapper.destroy();
@@ -68,8 +72,9 @@ describe('Ci environments dropdown', () => {
});
describe('Environments found', () => {
- beforeEach(() => {
+ beforeEach(async () => {
createComponent('prod');
+ await wrapper.vm.$nextTick();
});
it('renders only the environment searched for', () => {
@@ -84,21 +89,29 @@ describe('Ci environments dropdown', () => {
});
it('should not display empty results message', () => {
- expect(wrapper.find({ ref: 'noMatchingResults' }).exists()).toBe(false);
+ expect(wrapper.findComponent({ ref: 'noMatchingResults' }).exists()).toBe(false);
});
it('should display active checkmark if active', () => {
expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(false);
});
+ it('should clear the search term when showing the dropdown', () => {
+ wrapper.findComponent(GlDropdown).trigger('click');
+
+ expect(wrapper.find('[data-testid="ci-environment-search"]').text()).toBe('');
+ });
+
describe('Custom events', () => {
it('should emit selectEnvironment if an environment is clicked', () => {
findDropdownItemByIndex(0).vm.$emit('click');
expect(wrapper.emitted('selectEnvironment')).toEqual([['prod']]);
});
- it('should emit createClicked if an environment is clicked', () => {
+ it('should emit createClicked if an environment is clicked', async () => {
createComponent('newscope');
+
+ await wrapper.vm.$nextTick();
findDropdownItemByIndex(1).vm.$emit('click');
expect(wrapper.emitted('createClicked')).toEqual([['newscope']]);
});
diff --git a/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js b/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js
index fd6d9854868..f83a350a27c 100644
--- a/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js
+++ b/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js
@@ -59,6 +59,12 @@ describe('IngressModsecuritySettings', () => {
});
});
+ it('renders toggle with label', () => {
+ expect(findModSecurityToggle().props('label')).toBe(
+ IngressModsecuritySettings.i18n.modSecurityEnabled,
+ );
+ });
+
it('renders save and cancel buttons', () => {
expect(findSaveButton().exists()).toBe(true);
expect(findCancelButton().exists()).toBe(true);
diff --git a/spec/frontend/clusters/forms/components/integration_form_spec.js b/spec/frontend/clusters/forms/components/integration_form_spec.js
index 0323245244d..c5cec4c4fdb 100644
--- a/spec/frontend/clusters/forms/components/integration_form_spec.js
+++ b/spec/frontend/clusters/forms/components/integration_form_spec.js
@@ -45,8 +45,12 @@ describe('ClusterIntegrationForm', () => {
beforeEach(() => createWrapper());
it('enables toggle if editable is true', () => {
- expect(findGlToggle().props('disabled')).toBe(false);
+ expect(findGlToggle().props()).toMatchObject({
+ disabled: false,
+ label: IntegrationForm.i18n.toggleLabel,
+ });
});
+
it('sets the envScope to default', () => {
expect(wrapper.find('[id="cluster_environment_scope"]').attributes('value')).toBe('*');
});
diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js
index f398d7a0965..941a3adb625 100644
--- a/spec/frontend/clusters_list/components/clusters_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_spec.js
@@ -4,12 +4,12 @@ import {
GlDeprecatedSkeletonLoading as GlSkeletonLoading,
GlTable,
} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Clusters from '~/clusters_list/components/clusters.vue';
import ClusterStore from '~/clusters_list/store';
import axios from '~/lib/utils/axios_utils';
-import * as Sentry from '~/sentry/wrapper';
import { apiData } from '../mock_data';
describe('Clusters', () => {
diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js
index 00b998166aa..b2ef3c2138a 100644
--- a/spec/frontend/clusters_list/store/actions_spec.js
+++ b/spec/frontend/clusters_list/store/actions_spec.js
@@ -1,3 +1,4 @@
+import * as Sentry from '@sentry/browser';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -7,7 +8,6 @@ import * as types from '~/clusters_list/store/mutation_types';
import { deprecatedCreateFlash as flashError } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll';
-import * as Sentry from '~/sentry/wrapper';
import { apiData } from '../mock_data';
jest.mock('~/flash.js');
diff --git a/spec/frontend/collapsed_sidebar_todo_spec.js b/spec/frontend/collapsed_sidebar_todo_spec.js
index ef53cc9e103..7c659822672 100644
--- a/spec/frontend/collapsed_sidebar_todo_spec.js
+++ b/spec/frontend/collapsed_sidebar_todo_spec.js
@@ -14,9 +14,6 @@ describe('Issuable right sidebar collapsed todo toggle', () => {
const jsonFixtureName = 'todos/todos.json';
let mock;
- preloadFixtures(fixtureName);
- preloadFixtures(jsonFixtureName);
-
beforeEach(() => {
const todoData = getJSONFixture(jsonFixtureName);
new Sidebar();
diff --git a/spec/frontend/commit/pipelines/pipelines_spec.js b/spec/frontend/commit/pipelines/pipelines_spec.js
index f8bdd00f5da..bbe02daa24b 100644
--- a/spec/frontend/commit/pipelines/pipelines_spec.js
+++ b/spec/frontend/commit/pipelines/pipelines_spec.js
@@ -13,14 +13,10 @@ describe('Pipelines table in Commits and Merge requests', () => {
let vm;
const props = {
endpoint: 'endpoint.json',
- helpPagePath: 'foo',
emptyStateSvgPath: 'foo',
errorStateSvgPath: 'foo',
- autoDevopsHelpPath: 'foo',
};
- preloadFixtures(jsonFixtureName);
-
const findRunPipelineBtn = () => vm.$el.querySelector('[data-testid="run_pipeline_button"]');
const findRunPipelineBtnMobile = () =>
vm.$el.querySelector('[data-testid="run_pipeline_button_mobile"]');
@@ -275,7 +271,6 @@ describe('Pipelines table in Commits and Merge requests', () => {
setImmediate(() => {
expect(vm.$el.querySelector('.js-pipelines-error-state')).toBeDefined();
expect(vm.$el.querySelector('.realtime-loading')).toBe(null);
- expect(vm.$el.querySelector('.js-empty-state')).toBe(null);
expect(vm.$el.querySelector('.ci-table')).toBe(null);
done();
});
diff --git a/spec/frontend/create_item_dropdown_spec.js b/spec/frontend/create_item_dropdown_spec.js
index 7314eb5eee8..56c09cd731e 100644
--- a/spec/frontend/create_item_dropdown_spec.js
+++ b/spec/frontend/create_item_dropdown_spec.js
@@ -20,8 +20,6 @@ const DROPDOWN_ITEM_DATA = [
];
describe('CreateItemDropdown', () => {
- preloadFixtures('static/create_item_dropdown.html');
-
let $wrapperEl;
let createItemDropdown;
diff --git a/spec/frontend/deprecated_jquery_dropdown_spec.js b/spec/frontend/deprecated_jquery_dropdown_spec.js
index 6070532a1bf..7858f88f8c3 100644
--- a/spec/frontend/deprecated_jquery_dropdown_spec.js
+++ b/spec/frontend/deprecated_jquery_dropdown_spec.js
@@ -10,8 +10,6 @@ jest.mock('~/lib/utils/url_utility', () => ({
}));
describe('deprecatedJQueryDropdown', () => {
- preloadFixtures('static/deprecated_jquery_dropdown.html');
-
const NON_SELECTABLE_CLASSES =
'.divider, .separator, .dropdown-header, .dropdown-menu-empty-item';
const SEARCH_INPUT_SELECTOR = '.dropdown-input-field';
diff --git a/spec/frontend/design_management/components/delete_button_spec.js b/spec/frontend/design_management/components/delete_button_spec.js
index 8f7d8e0b214..f5a841d35b8 100644
--- a/spec/frontend/design_management/components/delete_button_spec.js
+++ b/spec/frontend/design_management/components/delete_button_spec.js
@@ -36,7 +36,7 @@ describe('Batch delete button component', () => {
expect(findButton().attributes('disabled')).toBeTruthy();
});
- it('emits `deleteSelectedDesigns` event on modal ok click', () => {
+ it('emits `delete-selected-designs` event on modal ok click', () => {
createComponent();
findButton().vm.$emit('click');
return wrapper.vm
@@ -46,7 +46,7 @@ describe('Batch delete button component', () => {
return wrapper.vm.$nextTick();
})
.then(() => {
- expect(wrapper.emitted().deleteSelectedDesigns).toBeTruthy();
+ expect(wrapper.emitted('delete-selected-designs')).toBeTruthy();
});
});
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 92e188f4bcc..efadb9b717d 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
@@ -93,7 +93,7 @@ describe('Design discussions component', () => {
});
it('does not render a checkbox in reply form', () => {
- findReplyPlaceholder().vm.$emit('onClick');
+ findReplyPlaceholder().vm.$emit('focus');
return wrapper.vm.$nextTick().then(() => {
expect(findResolveCheckbox().exists()).toBe(false);
@@ -124,7 +124,7 @@ describe('Design discussions component', () => {
});
it('renders a checkbox with Resolve thread text in reply form', () => {
- findReplyPlaceholder().vm.$emit('onClick');
+ findReplyPlaceholder().vm.$emit('focus');
wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id });
return wrapper.vm.$nextTick().then(() => {
@@ -193,7 +193,7 @@ describe('Design discussions component', () => {
});
it('renders a checkbox with Unresolve thread text in reply form', () => {
- findReplyPlaceholder().vm.$emit('onClick');
+ findReplyPlaceholder().vm.$emit('focus');
wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id });
return wrapper.vm.$nextTick().then(() => {
@@ -205,7 +205,7 @@ describe('Design discussions component', () => {
it('hides reply placeholder and opens form on placeholder click', () => {
createComponent();
- findReplyPlaceholder().vm.$emit('onClick');
+ findReplyPlaceholder().vm.$emit('focus');
wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id });
return wrapper.vm.$nextTick().then(() => {
@@ -307,7 +307,7 @@ describe('Design discussions component', () => {
it('emits openForm event on opening the form', () => {
createComponent();
- findReplyPlaceholder().vm.$emit('onClick');
+ findReplyPlaceholder().vm.$emit('focus');
expect(wrapper.emitted('open-form')).toBeTruthy();
});
diff --git a/spec/frontend/design_management/components/list/item_spec.js b/spec/frontend/design_management/components/list/item_spec.js
index caf0f8bb5bc..58636ece91e 100644
--- a/spec/frontend/design_management/components/list/item_spec.js
+++ b/spec/frontend/design_management/components/list/item_spec.js
@@ -8,7 +8,7 @@ const localVue = createLocalVue();
localVue.use(VueRouter);
const router = new VueRouter();
-// Referenced from: doc/api/graphql/reference/gitlab_schema.graphql:DesignVersionEvent
+// Referenced from: gitlab_schema.graphql:DesignVersionEvent
const DESIGN_VERSION_EVENT = {
CREATION: 'CREATION',
DELETION: 'DELETION',
diff --git a/spec/frontend/design_management/components/toolbar/index_spec.js b/spec/frontend/design_management/components/toolbar/index_spec.js
index 44c865d976d..009ffe57744 100644
--- a/spec/frontend/design_management/components/toolbar/index_spec.js
+++ b/spec/frontend/design_management/components/toolbar/index_spec.js
@@ -106,11 +106,11 @@ describe('Design management toolbar component', () => {
});
});
- it('emits `delete` event on deleteButton `deleteSelectedDesigns` event', () => {
+ it('emits `delete` event on deleteButton `delete-selected-designs` event', () => {
createComponent();
return wrapper.vm.$nextTick().then(() => {
- wrapper.find(DeleteButton).vm.$emit('deleteSelectedDesigns');
+ wrapper.find(DeleteButton).vm.$emit('delete-selected-designs');
expect(wrapper.emitted().delete).toBeTruthy();
});
});
diff --git a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap
index 2f857247303..904bb2022ca 100644
--- a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap
+++ b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap
@@ -19,7 +19,7 @@ exports[`Design management upload button component renders inverted upload desig
<input
accept="image/*"
- class="hide"
+ class="gl-display-none"
multiple="multiple"
name="design_file"
type="file"
@@ -44,7 +44,7 @@ exports[`Design management upload button component renders upload design button
<input
accept="image/*"
- class="hide"
+ class="gl-display-none"
multiple="multiple"
name="design_file"
type="file"
diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js
index 4f162ca8e7f..95cb1ac943c 100644
--- a/spec/frontend/design_management/pages/index_spec.js
+++ b/spec/frontend/design_management/pages/index_spec.js
@@ -97,7 +97,7 @@ describe('Design management index page', () => {
let moveDesignHandler;
const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox');
- const findSelectAllButton = () => wrapper.find('.js-select-all');
+ const findSelectAllButton = () => wrapper.find('[data-testid="select-all-designs-button"');
const findToolbar = () => wrapper.find('.qa-selector-toolbar');
const findDesignCollectionIsCopying = () =>
wrapper.find('[data-testid="design-collection-is-copying"');
@@ -542,7 +542,9 @@ describe('Design management index page', () => {
await nextTick();
expect(findDeleteButton().exists()).toBe(true);
expect(findSelectAllButton().text()).toBe('Deselect all');
- findDeleteButton().vm.$emit('deleteSelectedDesigns');
+
+ findDeleteButton().vm.$emit('delete-selected-designs');
+
const [{ variables }] = mutate.mock.calls[0];
expect(variables.filenames).toStrictEqual([mockDesigns[0].filename, mockDesigns[1].filename]);
});
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index d2b5338a0cc..34547238c23 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -14,9 +14,6 @@ import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue';
import NoChanges from '~/diffs/components/no_changes.vue';
import TreeList from '~/diffs/components/tree_list.vue';
-import { EVT_VIEW_FILE_BY_FILE } from '~/diffs/constants';
-
-import eventHub from '~/diffs/event_hub';
import axios from '~/lib/utils/axios_utils';
import * as urlUtils from '~/lib/utils/url_utility';
import createDiffsStore from '../create_diffs_store';
@@ -699,24 +696,5 @@ describe('diffs/components/app', () => {
},
);
});
-
- describe('control via event stream', () => {
- it.each`
- setting
- ${true}
- ${false}
- `(
- 'triggers the action with the new fileByFile setting - $setting - when the event with that setting is received',
- async ({ setting }) => {
- createComponent();
- await nextTick();
-
- eventHub.$emit(EVT_VIEW_FILE_BY_FILE, { setting });
- await nextTick();
-
- expect(store.state.diffs.viewDiffsFileByFile).toBe(setting);
- },
- );
- });
});
});
diff --git a/spec/frontend/diffs/components/inline_diff_table_row_spec.js b/spec/frontend/diffs/components/inline_diff_table_row_spec.js
index 7e6f75ad6f8..28b3055b58c 100644
--- a/spec/frontend/diffs/components/inline_diff_table_row_spec.js
+++ b/spec/frontend/diffs/components/inline_diff_table_row_spec.js
@@ -215,14 +215,14 @@ describe('InlineDiffTableRow', () => {
const TEST_LINE_NUMBER = 1;
describe.each`
- lineProps | findLineNumber | expectedHref | expectedClickArg
- ${{ line_code: TEST_LINE_CODE, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${`#${TEST_LINE_CODE}`} | ${TEST_LINE_CODE}
- ${{ line_code: undefined, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${undefined}
- ${{ line_code: undefined, left: { line_code: TEST_LINE_CODE }, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${TEST_LINE_CODE}
- ${{ line_code: undefined, right: { line_code: TEST_LINE_CODE }, new_line: TEST_LINE_NUMBER }} | ${findLineNumberNew} | ${'#'} | ${TEST_LINE_CODE}
+ lineProps | findLineNumber | expectedHref | expectedClickArg | expectedQaSelector
+ ${{ line_code: TEST_LINE_CODE, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${`#${TEST_LINE_CODE}`} | ${TEST_LINE_CODE} | ${undefined}
+ ${{ line_code: undefined, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${undefined} | ${undefined}
+ ${{ line_code: undefined, left: { line_code: TEST_LINE_CODE }, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${TEST_LINE_CODE} | ${undefined}
+ ${{ line_code: undefined, right: { line_code: TEST_LINE_CODE }, new_line: TEST_LINE_NUMBER }} | ${findLineNumberNew} | ${'#'} | ${TEST_LINE_CODE} | ${'new_diff_line_link'}
`(
'with line ($lineProps)',
- ({ lineProps, findLineNumber, expectedHref, expectedClickArg }) => {
+ ({ lineProps, findLineNumber, expectedHref, expectedClickArg, expectedQaSelector }) => {
beforeEach(() => {
jest.spyOn(store, 'dispatch').mockImplementation();
createComponent({
@@ -235,6 +235,7 @@ describe('InlineDiffTableRow', () => {
expect(findLineNumber().attributes()).toEqual({
href: expectedHref,
'data-linenumber': TEST_LINE_NUMBER.toString(),
+ 'data-qa-selector': expectedQaSelector,
});
});
diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js
index 99fa83b64f1..feac88cb802 100644
--- a/spec/frontend/diffs/components/settings_dropdown_spec.js
+++ b/spec/frontend/diffs/components/settings_dropdown_spec.js
@@ -1,82 +1,66 @@
-import { mount, createLocalVue } from '@vue/test-utils';
-import Vuex from 'vuex';
+import { mount } from '@vue/test-utils';
+
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+
import SettingsDropdown from '~/diffs/components/settings_dropdown.vue';
-import {
- EVT_VIEW_FILE_BY_FILE,
- PARALLEL_DIFF_VIEW_TYPE,
- INLINE_DIFF_VIEW_TYPE,
-} from '~/diffs/constants';
+import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constants';
import eventHub from '~/diffs/event_hub';
-import diffModule from '~/diffs/store/modules';
-const localVue = createLocalVue();
-localVue.use(Vuex);
+import createDiffsStore from '../create_diffs_store';
describe('Diff settings dropdown component', () => {
let wrapper;
let vm;
- let actions;
+ let store;
function createComponent(extendStore = () => {}) {
- const store = new Vuex.Store({
- modules: {
- diffs: {
- namespaced: true,
- actions,
- state: diffModule().state,
- getters: diffModule().getters,
- },
- },
- });
+ store = createDiffsStore();
extendStore(store);
- wrapper = mount(SettingsDropdown, {
- localVue,
- store,
- });
+ wrapper = extendedWrapper(
+ mount(SettingsDropdown, {
+ store,
+ }),
+ );
vm = wrapper.vm;
}
function getFileByFileCheckbox(vueWrapper) {
- return vueWrapper.find('[data-testid="file-by-file"]');
+ return vueWrapper.findByTestId('file-by-file');
+ }
+
+ function setup({ storeUpdater } = {}) {
+ createComponent(storeUpdater);
+ jest.spyOn(store, 'dispatch').mockImplementation(() => {});
}
beforeEach(() => {
- actions = {
- setInlineDiffViewType: jest.fn(),
- setParallelDiffViewType: jest.fn(),
- setRenderTreeList: jest.fn(),
- setShowWhitespace: jest.fn(),
- };
+ setup();
});
afterEach(() => {
+ store.dispatch.mockRestore();
wrapper.destroy();
});
describe('tree view buttons', () => {
it('list view button dispatches setRenderTreeList with false', () => {
- createComponent();
-
wrapper.find('.js-list-view').trigger('click');
- expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), false);
+ expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', false);
});
it('tree view button dispatches setRenderTreeList with true', () => {
- createComponent();
-
wrapper.find('.js-tree-view').trigger('click');
- expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), true);
+ expect(store.dispatch).toHaveBeenCalledWith('diffs/setRenderTreeList', true);
});
it('sets list button as selected when renderTreeList is false', () => {
- createComponent((store) => {
- Object.assign(store.state.diffs, {
- renderTreeList: false,
- });
+ setup({
+ storeUpdater: (origStore) =>
+ Object.assign(origStore.state.diffs, { renderTreeList: false }),
});
expect(wrapper.find('.js-list-view').classes('selected')).toBe(true);
@@ -84,10 +68,8 @@ describe('Diff settings dropdown component', () => {
});
it('sets tree button as selected when renderTreeList is true', () => {
- createComponent((store) => {
- Object.assign(store.state.diffs, {
- renderTreeList: true,
- });
+ setup({
+ storeUpdater: (origStore) => Object.assign(origStore.state.diffs, { renderTreeList: true }),
});
expect(wrapper.find('.js-list-view').classes('selected')).toBe(false);
@@ -97,10 +79,9 @@ describe('Diff settings dropdown component', () => {
describe('compare changes', () => {
it('sets inline button as selected', () => {
- createComponent((store) => {
- Object.assign(store.state.diffs, {
- diffViewType: INLINE_DIFF_VIEW_TYPE,
- });
+ setup({
+ storeUpdater: (origStore) =>
+ Object.assign(origStore.state.diffs, { diffViewType: INLINE_DIFF_VIEW_TYPE }),
});
expect(wrapper.find('.js-inline-diff-button').classes('selected')).toBe(true);
@@ -108,10 +89,9 @@ describe('Diff settings dropdown component', () => {
});
it('sets parallel button as selected', () => {
- createComponent((store) => {
- Object.assign(store.state.diffs, {
- diffViewType: PARALLEL_DIFF_VIEW_TYPE,
- });
+ setup({
+ storeUpdater: (origStore) =>
+ Object.assign(origStore.state.diffs, { diffViewType: PARALLEL_DIFF_VIEW_TYPE }),
});
expect(wrapper.find('.js-inline-diff-button').classes('selected')).toBe(false);
@@ -119,53 +99,49 @@ describe('Diff settings dropdown component', () => {
});
it('calls setInlineDiffViewType when clicking inline button', () => {
- createComponent();
-
wrapper.find('.js-inline-diff-button').trigger('click');
- expect(actions.setInlineDiffViewType).toHaveBeenCalled();
+ expect(store.dispatch).toHaveBeenCalledWith('diffs/setInlineDiffViewType', expect.anything());
});
it('calls setParallelDiffViewType when clicking parallel button', () => {
- createComponent();
-
wrapper.find('.js-parallel-diff-button').trigger('click');
- expect(actions.setParallelDiffViewType).toHaveBeenCalled();
+ expect(store.dispatch).toHaveBeenCalledWith(
+ 'diffs/setParallelDiffViewType',
+ expect.anything(),
+ );
});
});
describe('whitespace toggle', () => {
it('does not set as checked when showWhitespace is false', () => {
- createComponent((store) => {
- Object.assign(store.state.diffs, {
- showWhitespace: false,
- });
+ setup({
+ storeUpdater: (origStore) =>
+ Object.assign(origStore.state.diffs, { showWhitespace: false }),
});
- expect(wrapper.find('#show-whitespace').element.checked).toBe(false);
+ expect(wrapper.findByTestId('show-whitespace').element.checked).toBe(false);
});
it('sets as checked when showWhitespace is true', () => {
- createComponent((store) => {
- Object.assign(store.state.diffs, {
- showWhitespace: true,
- });
+ setup({
+ storeUpdater: (origStore) => Object.assign(origStore.state.diffs, { showWhitespace: true }),
});
- expect(wrapper.find('#show-whitespace').element.checked).toBe(true);
+ expect(wrapper.findByTestId('show-whitespace').element.checked).toBe(true);
});
- it('calls setShowWhitespace on change', () => {
- createComponent();
+ it('calls setShowWhitespace on change', async () => {
+ const checkbox = wrapper.findByTestId('show-whitespace');
+ const { checked } = checkbox.element;
- const checkbox = wrapper.find('#show-whitespace');
+ checkbox.trigger('click');
- checkbox.element.checked = true;
- checkbox.trigger('change');
+ await vm.$nextTick();
- expect(actions.setShowWhitespace).toHaveBeenCalledWith(expect.anything(), {
- showWhitespace: true,
+ expect(store.dispatch).toHaveBeenCalledWith('diffs/setShowWhitespace', {
+ showWhitespace: !checked,
pushState: true,
});
});
@@ -182,39 +158,35 @@ describe('Diff settings dropdown component', () => {
${false} | ${false}
`(
'sets the checkbox to { checked: $checked } if the fileByFile setting is $fileByFile',
- async ({ fileByFile, checked }) => {
- createComponent((store) => {
- Object.assign(store.state.diffs, {
- viewDiffsFileByFile: fileByFile,
- });
+ ({ fileByFile, checked }) => {
+ setup({
+ storeUpdater: (origStore) =>
+ Object.assign(origStore.state.diffs, { viewDiffsFileByFile: fileByFile }),
});
- await vm.$nextTick();
-
expect(getFileByFileCheckbox(wrapper).element.checked).toBe(checked);
},
);
it.each`
- start | emit
+ start | setting
${true} | ${false}
${false} | ${true}
`(
- 'when the file by file setting starts as $start, toggling the checkbox should emit an event set to $emit',
- async ({ start, emit }) => {
- createComponent((store) => {
- Object.assign(store.state.diffs, {
- viewDiffsFileByFile: start,
- });
+ 'when the file by file setting starts as $start, toggling the checkbox should call setFileByFile with $setting',
+ async ({ start, setting }) => {
+ setup({
+ storeUpdater: (origStore) =>
+ Object.assign(origStore.state.diffs, { viewDiffsFileByFile: start }),
});
- await vm.$nextTick();
-
getFileByFileCheckbox(wrapper).trigger('click');
await vm.$nextTick();
- expect(eventHub.$emit).toHaveBeenCalledWith(EVT_VIEW_FILE_BY_FILE, { setting: emit });
+ expect(store.dispatch).toHaveBeenCalledWith('diffs/setFileByFile', {
+ fileByFile: setting,
+ });
},
);
});
diff --git a/spec/frontend/diffs/mock_data/diff_with_commit.js b/spec/frontend/diffs/mock_data/diff_with_commit.js
index d646294ee84..f3b39bd3577 100644
--- a/spec/frontend/diffs/mock_data/diff_with_commit.js
+++ b/spec/frontend/diffs/mock_data/diff_with_commit.js
@@ -1,7 +1,5 @@
const FIXTURE = 'merge_request_diffs/with_commit.json';
-preloadFixtures(FIXTURE);
-
export default function getDiffWithCommit() {
return getJSONFixture(FIXTURE);
}
diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js
index dcb58f7a380..6af38590610 100644
--- a/spec/frontend/diffs/store/utils_spec.js
+++ b/spec/frontend/diffs/store/utils_spec.js
@@ -275,24 +275,28 @@ describe('DiffsStoreUtils', () => {
describe('trimFirstCharOfLineContent', () => {
it('trims the line when it starts with a space', () => {
+ // eslint-disable-next-line import/no-deprecated
expect(utils.trimFirstCharOfLineContent({ rich_text: ' diff' })).toEqual({
rich_text: 'diff',
});
});
it('trims the line when it starts with a +', () => {
+ // eslint-disable-next-line import/no-deprecated
expect(utils.trimFirstCharOfLineContent({ rich_text: '+diff' })).toEqual({
rich_text: 'diff',
});
});
it('trims the line when it starts with a -', () => {
+ // eslint-disable-next-line import/no-deprecated
expect(utils.trimFirstCharOfLineContent({ rich_text: '-diff' })).toEqual({
rich_text: 'diff',
});
});
it('does not trims the line when it starts with a letter', () => {
+ // eslint-disable-next-line import/no-deprecated
expect(utils.trimFirstCharOfLineContent({ rich_text: 'diff' })).toEqual({
rich_text: 'diff',
});
@@ -303,12 +307,14 @@ describe('DiffsStoreUtils', () => {
rich_text: ' diff',
};
+ // eslint-disable-next-line import/no-deprecated
utils.trimFirstCharOfLineContent(lineObj);
expect(lineObj).toEqual({ rich_text: ' diff' });
});
it('handles a undefined or null parameter', () => {
+ // eslint-disable-next-line import/no-deprecated
expect(utils.trimFirstCharOfLineContent()).toEqual({});
});
});
diff --git a/spec/frontend/diffs/utils/file_reviews_spec.js b/spec/frontend/diffs/utils/file_reviews_spec.js
index a58c19a7245..230ec12409c 100644
--- a/spec/frontend/diffs/utils/file_reviews_spec.js
+++ b/spec/frontend/diffs/utils/file_reviews_spec.js
@@ -49,11 +49,11 @@ describe('File Review(s) utilities', () => {
it.each`
mrReviews | files | fileReviews
- ${{}} | ${[file1, file2]} | ${[false, false]}
- ${{ abc: ['123'] }} | ${[file1, file2]} | ${[true, false]}
- ${{ abc: ['098'] }} | ${[file1, file2]} | ${[false, true]}
- ${{ def: ['123'] }} | ${[file1, file2]} | ${[false, false]}
- ${{ abc: ['123'], def: ['098'] }} | ${[]} | ${[]}
+ ${{}} | ${[file1, file2]} | ${{ 123: false, '098': false }}
+ ${{ abc: ['123'] }} | ${[file1, file2]} | ${{ 123: true, '098': false }}
+ ${{ abc: ['098'] }} | ${[file1, file2]} | ${{ 123: false, '098': true }}
+ ${{ def: ['123'] }} | ${[file1, file2]} | ${{ 123: false, '098': false }}
+ ${{ abc: ['123'], def: ['098'] }} | ${[]} | ${{}}
`(
'returns $fileReviews based on the diff files in state and the existing reviews $reviews',
({ mrReviews, files, fileReviews }) => {
diff --git a/spec/frontend/diffs/utils/preferences_spec.js b/spec/frontend/diffs/utils/preferences_spec.js
index b09db2c1003..2dcc71dc188 100644
--- a/spec/frontend/diffs/utils/preferences_spec.js
+++ b/spec/frontend/diffs/utils/preferences_spec.js
@@ -5,32 +5,25 @@ import {
DIFF_VIEW_ALL_FILES,
} from '~/diffs/constants';
import { fileByFile } from '~/diffs/utils/preferences';
-import { getParameterValues } from '~/lib/utils/url_utility';
-
-jest.mock('~/lib/utils/url_utility');
describe('diffs preferences', () => {
describe('fileByFile', () => {
+ afterEach(() => {
+ Cookies.remove(DIFF_FILE_BY_FILE_COOKIE_NAME);
+ });
+
it.each`
- result | preference | cookie | searchParam
- ${false} | ${false} | ${undefined} | ${undefined}
- ${true} | ${true} | ${undefined} | ${undefined}
- ${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE} | ${undefined}
- ${false} | ${true} | ${DIFF_VIEW_ALL_FILES} | ${undefined}
- ${true} | ${false} | ${undefined} | ${[DIFF_VIEW_FILE_BY_FILE]}
- ${false} | ${true} | ${undefined} | ${[DIFF_VIEW_ALL_FILES]}
- ${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE} | ${[DIFF_VIEW_FILE_BY_FILE]}
- ${true} | ${true} | ${DIFF_VIEW_ALL_FILES} | ${[DIFF_VIEW_FILE_BY_FILE]}
- ${false} | ${false} | ${DIFF_VIEW_ALL_FILES} | ${[DIFF_VIEW_ALL_FILES]}
- ${false} | ${true} | ${DIFF_VIEW_FILE_BY_FILE} | ${[DIFF_VIEW_ALL_FILES]}
+ result | preference | cookie
+ ${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE}
+ ${false} | ${true} | ${DIFF_VIEW_ALL_FILES}
+ ${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE}
+ ${false} | ${true} | ${DIFF_VIEW_ALL_FILES}
+ ${false} | ${false} | ${DIFF_VIEW_ALL_FILES}
+ ${true} | ${true} | ${DIFF_VIEW_FILE_BY_FILE}
`(
- 'should return $result when { preference: $preference, cookie: $cookie, search: $searchParam }',
- ({ result, preference, cookie, searchParam }) => {
- if (cookie) {
- Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, cookie);
- }
-
- getParameterValues.mockReturnValue(searchParam);
+ 'should return $result when { preference: $preference, cookie: $cookie }',
+ ({ result, preference, cookie }) => {
+ Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, cookie);
expect(fileByFile(preference)).toBe(result);
},
diff --git a/spec/frontend/emoji/components/category_spec.js b/spec/frontend/emoji/components/category_spec.js
new file mode 100644
index 00000000000..afd36a1eb88
--- /dev/null
+++ b/spec/frontend/emoji/components/category_spec.js
@@ -0,0 +1,49 @@
+import { GlIntersectionObserver } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import Category from '~/emoji/components/category.vue';
+import EmojiGroup from '~/emoji/components/emoji_group.vue';
+
+let wrapper;
+function factory(propsData = {}) {
+ wrapper = shallowMount(Category, { propsData });
+}
+
+describe('Emoji category component', () => {
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ beforeEach(() => {
+ factory({
+ category: 'Activity',
+ emojis: [['thumbsup'], ['thumbsdown']],
+ });
+ });
+
+ it('renders emoji groups', () => {
+ expect(wrapper.findAll(EmojiGroup).length).toBe(2);
+ });
+
+ it('renders group', async () => {
+ await wrapper.setData({ renderGroup: true });
+
+ expect(wrapper.find(EmojiGroup).attributes('rendergroup')).toBe('true');
+ });
+
+ it('renders group on appear', async () => {
+ wrapper.find(GlIntersectionObserver).vm.$emit('appear');
+
+ await nextTick();
+
+ expect(wrapper.find(EmojiGroup).attributes('rendergroup')).toBe('true');
+ });
+
+ it('emits appear event on appear', async () => {
+ wrapper.find(GlIntersectionObserver).vm.$emit('appear');
+
+ await nextTick();
+
+ expect(wrapper.emitted().appear[0]).toEqual(['Activity']);
+ });
+});
diff --git a/spec/frontend/emoji/components/emoji_group_spec.js b/spec/frontend/emoji/components/emoji_group_spec.js
new file mode 100644
index 00000000000..1aca2fbb8fc
--- /dev/null
+++ b/spec/frontend/emoji/components/emoji_group_spec.js
@@ -0,0 +1,56 @@
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import EmojiGroup from '~/emoji/components/emoji_group.vue';
+
+Vue.config.ignoredElements = ['gl-emoji'];
+
+let wrapper;
+function factory(propsData = {}) {
+ wrapper = extendedWrapper(
+ shallowMount(EmojiGroup, {
+ propsData,
+ }),
+ );
+}
+
+describe('Emoji group component', () => {
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('does not render any buttons', () => {
+ factory({
+ emojis: [],
+ renderGroup: false,
+ clickEmoji: jest.fn(),
+ });
+
+ expect(wrapper.findByTestId('emoji-button').exists()).toBe(false);
+ });
+
+ it('renders emojis', () => {
+ factory({
+ emojis: ['thumbsup', 'thumbsdown'],
+ renderGroup: true,
+ clickEmoji: jest.fn(),
+ });
+
+ expect(wrapper.findAllByTestId('emoji-button').exists()).toBe(true);
+ expect(wrapper.findAllByTestId('emoji-button').length).toBe(2);
+ });
+
+ it('calls clickEmoji', () => {
+ const clickEmoji = jest.fn();
+
+ factory({
+ emojis: ['thumbsup', 'thumbsdown'],
+ renderGroup: true,
+ clickEmoji,
+ });
+
+ wrapper.findByTestId('emoji-button').trigger('click');
+
+ expect(clickEmoji).toHaveBeenCalledWith('thumbsup');
+ });
+});
diff --git a/spec/frontend/emoji/components/emoji_list_spec.js b/spec/frontend/emoji/components/emoji_list_spec.js
new file mode 100644
index 00000000000..9dc73ef191e
--- /dev/null
+++ b/spec/frontend/emoji/components/emoji_list_spec.js
@@ -0,0 +1,73 @@
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import EmojiList from '~/emoji/components/emoji_list.vue';
+
+jest.mock('~/emoji', () => ({
+ initEmojiMap: jest.fn(() => Promise.resolve()),
+ searchEmoji: jest.fn((search) => [{ emoji: { name: search } }]),
+ getEmojiCategoryMap: jest.fn(() =>
+ Promise.resolve({
+ activity: ['thumbsup', 'thumbsdown'],
+ }),
+ ),
+}));
+
+let wrapper;
+async function factory(render, propsData = { searchValue: '' }) {
+ wrapper = extendedWrapper(
+ shallowMount(EmojiList, {
+ propsData,
+ scopedSlots: {
+ default: '<div data-testid="default-slot">{{props.filteredCategories}}</div>',
+ },
+ }),
+ );
+
+ // Wait for categories to be set
+ await nextTick();
+
+ if (render) {
+ wrapper.setData({ render: true });
+
+ // Wait for component to render
+ await nextTick();
+ }
+}
+
+const findDefaultSlot = () => wrapper.findByTestId('default-slot');
+
+describe('Emoji list component', () => {
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('does not render until render is set', async () => {
+ await factory(false);
+
+ expect(findDefaultSlot().exists()).toBe(false);
+ });
+
+ it('renders with none filtered list', async () => {
+ await factory(true);
+
+ expect(JSON.parse(findDefaultSlot().text())).toEqual({
+ activity: {
+ emojis: [['thumbsup', 'thumbsdown']],
+ height: expect.any(Number),
+ top: expect.any(Number),
+ },
+ });
+ });
+
+ it('renders filtered list of emojis', async () => {
+ await factory(true, { searchValue: 'smile' });
+
+ expect(JSON.parse(findDefaultSlot().text())).toEqual({
+ search: {
+ emojis: [['smile']],
+ height: expect.any(Number),
+ },
+ });
+ });
+});
diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js
index 50d84b19ce8..542cf58b079 100644
--- a/spec/frontend/environments/environments_app_spec.js
+++ b/spec/frontend/environments/environments_app_spec.js
@@ -97,13 +97,21 @@ describe('Environment', () => {
jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {});
wrapper.find('.gl-pagination li:nth-child(3) .page-link').trigger('click');
- expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: 'available', page: '2' });
+ expect(wrapper.vm.updateContent).toHaveBeenCalledWith({
+ scope: 'available',
+ page: '2',
+ nested: true,
+ });
});
it('should make an API request when using tabs', () => {
jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {});
findEnvironmentsTabStopped().trigger('click');
- expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: 'stopped', page: '1' });
+ expect(wrapper.vm.updateContent).toHaveBeenCalledWith({
+ scope: 'stopped',
+ page: '1',
+ nested: true,
+ });
});
it('should not make the same API request when clicking on the current scope tab', () => {
diff --git a/spec/frontend/environments/folder/environments_folder_view_spec.js b/spec/frontend/environments/folder/environments_folder_view_spec.js
index 3943e89c6cf..d02ed8688c6 100644
--- a/spec/frontend/environments/folder/environments_folder_view_spec.js
+++ b/spec/frontend/environments/folder/environments_folder_view_spec.js
@@ -103,13 +103,18 @@ describe('Environments Folder View', () => {
expect(wrapper.vm.updateContent).toHaveBeenCalledWith({
scope: wrapper.vm.scope,
page: '10',
+ nested: true,
});
});
it('should make an API request when using tabs', () => {
jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {});
findEnvironmentsTabStopped().trigger('click');
- expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: 'stopped', page: '1' });
+ expect(wrapper.vm.updateContent).toHaveBeenCalledWith({
+ scope: 'stopped',
+ page: '1',
+ nested: true,
+ });
});
});
});
@@ -161,7 +166,11 @@ describe('Environments Folder View', () => {
it('should set page to 1', () => {
jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {});
wrapper.vm.onChangeTab('stopped');
- expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: 'stopped', page: '1' });
+ expect(wrapper.vm.updateContent).toHaveBeenCalledWith({
+ scope: 'stopped',
+ page: '1',
+ nested: true,
+ });
});
});
@@ -172,6 +181,7 @@ describe('Environments Folder View', () => {
expect(wrapper.vm.updateContent).toHaveBeenCalledWith({
scope: wrapper.vm.scope,
page: '4',
+ nested: true,
});
});
});
diff --git a/spec/frontend/experimentation/experiment_tracking_spec.js b/spec/frontend/experimentation/experiment_tracking_spec.js
new file mode 100644
index 00000000000..20f45a7015a
--- /dev/null
+++ b/spec/frontend/experimentation/experiment_tracking_spec.js
@@ -0,0 +1,80 @@
+import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
+import ExperimentTracking from '~/experimentation/experiment_tracking';
+import { getExperimentData } from '~/experimentation/utils';
+import Tracking from '~/tracking';
+
+let experimentTracking;
+let label;
+let property;
+
+jest.mock('~/tracking');
+jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() }));
+
+const setup = () => {
+ experimentTracking = new ExperimentTracking('sidebar_experiment', { label, property });
+};
+
+beforeEach(() => {
+ document.body.dataset.page = 'issues-page';
+});
+
+afterEach(() => {
+ label = undefined;
+ property = undefined;
+});
+
+describe('event', () => {
+ beforeEach(() => {
+ getExperimentData.mockReturnValue(undefined);
+ });
+
+ describe('when experiment data exists for experimentName', () => {
+ beforeEach(() => {
+ getExperimentData.mockReturnValue('experiment-data');
+ setup();
+ });
+
+ describe('when providing options', () => {
+ label = 'sidebar-drawer';
+ property = 'dark-mode';
+
+ it('passes them to the tracking call', () => {
+ experimentTracking.event('click_sidebar_close');
+
+ expect(Tracking.event).toHaveBeenCalledTimes(1);
+ expect(Tracking.event).toHaveBeenCalledWith('issues-page', 'click_sidebar_close', {
+ label: 'sidebar-drawer',
+ property: 'dark-mode',
+ context: {
+ schema: TRACKING_CONTEXT_SCHEMA,
+ data: 'experiment-data',
+ },
+ });
+ });
+ });
+
+ it('tracks with the correct context', () => {
+ experimentTracking.event('click_sidebar_trigger');
+
+ expect(Tracking.event).toHaveBeenCalledTimes(1);
+ expect(Tracking.event).toHaveBeenCalledWith('issues-page', 'click_sidebar_trigger', {
+ context: {
+ schema: TRACKING_CONTEXT_SCHEMA,
+ data: 'experiment-data',
+ },
+ });
+ });
+ });
+
+ describe('when experiment data does NOT exists for the experimentName', () => {
+ beforeEach(() => {
+ setup();
+ });
+
+ it('does not track', () => {
+ experimentTracking.event('click_sidebar_close');
+
+ expect(Tracking.event).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/experimentation/utils_spec.js b/spec/frontend/experimentation/utils_spec.js
new file mode 100644
index 00000000000..87dd2d595ba
--- /dev/null
+++ b/spec/frontend/experimentation/utils_spec.js
@@ -0,0 +1,38 @@
+import * as experimentUtils from '~/experimentation/utils';
+
+const TEST_KEY = 'abc';
+
+describe('experiment Utilities', () => {
+ const oldGon = window.gon;
+
+ afterEach(() => {
+ window.gon = oldGon;
+ });
+
+ describe('getExperimentData', () => {
+ it.each`
+ gon | input | output
+ ${{ experiment: { [TEST_KEY]: '_data_' } }} | ${[TEST_KEY]} | ${'_data_'}
+ ${{}} | ${[TEST_KEY]} | ${undefined}
+ `('with input=$input and gon=$gon, returns $output', ({ gon, input, output }) => {
+ window.gon = gon;
+
+ expect(experimentUtils.getExperimentData(...input)).toEqual(output);
+ });
+ });
+
+ describe('isExperimentVariant', () => {
+ it.each`
+ gon | input | output
+ ${{ experiment: { [TEST_KEY]: { variant: 'control' } } }} | ${[TEST_KEY, 'control']} | ${true}
+ ${{ experiment: { [TEST_KEY]: { variant: '_variant_name' } } }} | ${[TEST_KEY, '_variant_name']} | ${true}
+ ${{ experiment: { [TEST_KEY]: { variant: '_variant_name' } } }} | ${[TEST_KEY, '_bogus_name']} | ${false}
+ ${{ experiment: { [TEST_KEY]: { variant: '_variant_name' } } }} | ${['boguskey', '_variant_name']} | ${false}
+ ${{}} | ${[TEST_KEY, '_variant_name']} | ${false}
+ `('with input=$input and gon=$gon, returns $output', ({ gon, input, output }) => {
+ window.gon = gon;
+
+ expect(experimentUtils.isExperimentVariant(...input)).toEqual(output);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js
index 84e71ffd204..27ec6a7280f 100644
--- a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js
+++ b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js
@@ -32,8 +32,9 @@ describe('Configure Feature Flags Modal', () => {
});
};
- const findGlModal = () => wrapper.find(GlModal);
+ const findGlModal = () => wrapper.findComponent(GlModal);
const findPrimaryAction = () => findGlModal().props('actionPrimary');
+ const findSecondaryAction = () => findGlModal().props('actionSecondary');
const findProjectNameInput = () => wrapper.find('#project_name_verification');
const findDangerGlAlert = () =>
wrapper.findAll(GlAlert).filter((c) => c.props('variant') === 'danger');
@@ -42,18 +43,18 @@ describe('Configure Feature Flags Modal', () => {
afterEach(() => wrapper.destroy());
beforeEach(factory);
- it('should have Primary and Cancel actions', () => {
- expect(findGlModal().props('actionCancel').text).toBe('Close');
- expect(findPrimaryAction().text).toBe('Regenerate instance ID');
+ it('should have Primary and Secondary actions', () => {
+ expect(findPrimaryAction().text).toBe('Close');
+ expect(findSecondaryAction().text).toBe('Regenerate instance ID');
});
- it('should default disable the primary action', async () => {
- const [{ disabled }] = findPrimaryAction().attributes;
+ it('should default disable the primary action', () => {
+ const [{ disabled }] = findSecondaryAction().attributes;
expect(disabled).toBe(true);
});
it('should emit a `token` event when clicking on the Primary action', async () => {
- findGlModal().vm.$emit('primary', mockEvent);
+ findGlModal().vm.$emit('secondary', mockEvent);
await wrapper.vm.$nextTick();
expect(wrapper.emitted('token')).toEqual([[]]);
expect(mockEvent.preventDefault).toHaveBeenCalled();
@@ -112,10 +113,10 @@ describe('Configure Feature Flags Modal', () => {
afterEach(() => wrapper.destroy());
beforeEach(factory);
- it('should enable the primary action', async () => {
+ it('should enable the secondary action', async () => {
findProjectNameInput().vm.$emit('input', provide.projectName);
await wrapper.vm.$nextTick();
- const [{ disabled }] = findPrimaryAction().attributes;
+ const [{ disabled }] = findSecondaryAction().attributes;
expect(disabled).toBe(false);
});
});
@@ -124,8 +125,8 @@ describe('Configure Feature Flags Modal', () => {
afterEach(() => wrapper.destroy());
beforeEach(factory.bind(null, { canUserRotateToken: false }));
- it('should not display the primary action', async () => {
- expect(findPrimaryAction()).toBe(null);
+ it('should not display the primary action', () => {
+ expect(findSecondaryAction()).toBe(null);
});
it('should not display regenerating instance ID', async () => {
diff --git a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js
index e2717b98ea9..2fd8e524e7a 100644
--- a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js
+++ b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js
@@ -150,5 +150,12 @@ describe('Edit feature flag form', () => {
label: 'feature_flag_toggle',
});
});
+
+ it('should render the toggle with a visually hidden label', () => {
+ expect(wrapper.find(GlToggle).props()).toMatchObject({
+ label: 'Feature flag status',
+ labelPosition: 'hidden',
+ });
+ });
});
});
diff --git a/spec/frontend/feature_flags/components/feature_flags_table_spec.js b/spec/frontend/feature_flags/components/feature_flags_table_spec.js
index 8f4d39d4a11..816bc9b9707 100644
--- a/spec/frontend/feature_flags/components/feature_flags_table_spec.js
+++ b/spec/frontend/feature_flags/components/feature_flags_table_spec.js
@@ -129,7 +129,10 @@ describe('Feature flag table', () => {
it('should have a toggle', () => {
expect(toggle.exists()).toBe(true);
- expect(toggle.props('value')).toBe(true);
+ expect(toggle.props()).toMatchObject({
+ label: FeatureFlagsTable.i18n.toggleLabel,
+ value: true,
+ });
});
it('should trigger a toggle event', () => {
diff --git a/spec/frontend/filtered_search/dropdown_user_spec.js b/spec/frontend/filtered_search/dropdown_user_spec.js
index 0e2d2ee6c09..961587f7146 100644
--- a/spec/frontend/filtered_search/dropdown_user_spec.js
+++ b/spec/frontend/filtered_search/dropdown_user_spec.js
@@ -78,7 +78,6 @@ describe('Dropdown User', () => {
describe('hideCurrentUser', () => {
const fixtureTemplate = 'issues/issue_list.html';
- preloadFixtures(fixtureTemplate);
let dropdown;
let authorFilterDropdownElement;
diff --git a/spec/frontend/filtered_search/dropdown_utils_spec.js b/spec/frontend/filtered_search/dropdown_utils_spec.js
index 32d1f909d0b..49e14f58630 100644
--- a/spec/frontend/filtered_search/dropdown_utils_spec.js
+++ b/spec/frontend/filtered_search/dropdown_utils_spec.js
@@ -5,7 +5,6 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered
describe('Dropdown Utils', () => {
const issueListFixture = 'issues/issue_list.html';
- preloadFixtures(issueListFixture);
describe('getEscapedText', () => {
it('should return same word when it has no space', () => {
diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js
index a2082271efe..772fa7d07ed 100644
--- a/spec/frontend/filtered_search/visual_token_value_spec.js
+++ b/spec/frontend/filtered_search/visual_token_value_spec.js
@@ -133,8 +133,6 @@ describe('Filtered Search Visual Tokens', () => {
const jsonFixtureName = 'labels/project_labels.json';
const dummyEndpoint = '/dummy/endpoint';
- preloadFixtures(jsonFixtureName);
-
let labelData;
beforeAll(() => {
diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb
index a027247bd0d..d6f6ed97626 100644
--- a/spec/frontend/fixtures/issues.rb
+++ b/spec/frontend/fixtures/issues.rb
@@ -16,6 +16,8 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :contr
end
before do
+ stub_feature_flags(boards_filtered_search: false)
+
project.add_maintainer(user)
sign_in(user)
end
diff --git a/spec/frontend/fixtures/pipelines.rb b/spec/frontend/fixtures/pipelines.rb
index b4b7f0e332f..2a538352abe 100644
--- a/spec/frontend/fixtures/pipelines.rb
+++ b/spec/frontend/fixtures/pipelines.rb
@@ -5,16 +5,22 @@ require 'spec_helper'
RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :controller do
include JavaScriptFixturesHelpers
- let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
- let(:project) { create(:project, :repository, namespace: namespace, path: 'pipelines-project') }
- let(:commit) { create(:commit, project: project) }
- let(:commit_without_author) { RepoHelpers.another_sample_commit }
- let!(:user) { create(:user, developer_projects: [project], email: commit.author_email) }
- let!(:pipeline) { create(:ci_pipeline, project: project, sha: commit.id, user: user) }
+ let_it_be(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
+ let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'pipelines-project') }
+
+ let_it_be(:commit_without_author) { RepoHelpers.another_sample_commit }
let!(:pipeline_without_author) { create(:ci_pipeline, project: project, sha: commit_without_author.id) }
- let!(:pipeline_without_commit) { create(:ci_pipeline, status: :success, project: project, sha: '0000') }
+ let!(:build_pipeline_without_author) { create(:ci_build, pipeline: pipeline_without_author, stage: 'test') }
- render_views
+ let_it_be(:pipeline_without_commit) { create(:ci_pipeline, status: :success, project: project, sha: '0000') }
+ let!(:build_pipeline_without_commit) { create(:ci_build, pipeline: pipeline_without_commit, stage: 'test') }
+
+ let(:commit) { create(:commit, project: project) }
+ let(:user) { create(:user, developer_projects: [project], email: commit.author_email) }
+ let!(:pipeline) { create(:ci_pipeline, :with_test_reports, project: project, sha: commit.id, user: user) }
+ let!(:build_success) { create(:ci_build, pipeline: pipeline, stage: 'build') }
+ let!(:build_test) { create(:ci_build, pipeline: pipeline, stage: 'test') }
+ let!(:build_deploy_failed) { create(:ci_build, status: :failed, pipeline: pipeline, stage: 'deploy') }
before(:all) do
clean_frontend_fixtures('pipelines/')
@@ -32,4 +38,14 @@ RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :co
expect(response).to be_successful
end
+
+ it "pipelines/test_report.json" do
+ get :test_report, params: {
+ namespace_id: namespace,
+ project_id: project,
+ id: pipeline.id
+ }, format: :json
+
+ expect(response).to be_successful
+ end
end
diff --git a/spec/frontend/fixtures/projects.rb b/spec/frontend/fixtures/projects.rb
index aa2f7dbed36..778ae218160 100644
--- a/spec/frontend/fixtures/projects.rb
+++ b/spec/frontend/fixtures/projects.rb
@@ -3,13 +3,14 @@
require 'spec_helper'
RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
+ include ApiHelpers
include JavaScriptFixturesHelpers
runners_token = 'runnerstoken:intabulasreferre'
let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
- let(:project) { create(:project, namespace: namespace, path: 'builds-project', runners_token: runners_token) }
- let(:project_with_repo) { create(:project, :repository, description: 'Code and stuff') }
+ let(:project) { create(:project, namespace: namespace, path: 'builds-project', runners_token: runners_token, avatar: fixture_file_upload('spec/fixtures/dk.png', 'image/png')) }
+ let(:project_with_repo) { create(:project, :repository, description: 'Code and stuff', avatar: fixture_file_upload('spec/fixtures/dk.png', 'image/png')) }
let(:project_variable_populated) { create(:project, namespace: namespace, path: 'builds-project2', runners_token: runners_token) }
let(:user) { project.owner }
@@ -22,7 +23,6 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
before do
project_with_repo.add_maintainer(user)
sign_in(user)
- allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon')
end
after do
@@ -48,4 +48,31 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
expect(response).to be_successful
end
end
+
+ describe GraphQL::Query, type: :request do
+ include GraphqlHelpers
+
+ context 'access token projects query' do
+ before do
+ project_variable_populated.add_maintainer(user)
+ end
+
+ before(:all) do
+ clean_frontend_fixtures('graphql/projects/access_tokens')
+ end
+
+ fragment_paths = ['graphql_shared/fragments/pageInfo.fragment.graphql']
+ base_input_path = 'access_tokens/graphql/queries/'
+ base_output_path = 'graphql/projects/access_tokens/'
+ query_name = 'get_projects.query.graphql'
+
+ it "#{base_output_path}#{query_name}.json" do
+ query = get_graphql_query_as_string("#{base_input_path}#{query_name}", fragment_paths)
+
+ post_graphql(query, current_user: user, variables: { search: '', first: 2 })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+ end
end
diff --git a/spec/frontend/fixtures/test_report.rb b/spec/frontend/fixtures/test_report.rb
deleted file mode 100644
index 3d09078ba68..00000000000
--- a/spec/frontend/fixtures/test_report.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-require "spec_helper"
-
-RSpec.describe Projects::PipelinesController, "(JavaScript fixtures)", type: :controller do
- include JavaScriptFixturesHelpers
-
- let(:namespace) { create(:namespace, name: "frontend-fixtures") }
- let(:project) { create(:project, :repository, namespace: namespace, path: "pipelines-project") }
- let(:commit) { create(:commit, project: project) }
- let(:user) { create(:user, developer_projects: [project], email: commit.author_email) }
- let(:pipeline) { create(:ci_pipeline, :with_test_reports, project: project, user: user) }
-
- render_views
-
- before do
- sign_in(user)
- end
-
- it "pipelines/test_report.json" do
- get :test_report, params: {
- namespace_id: project.namespace,
- project_id: project,
- id: pipeline.id
- }, format: :json
-
- expect(response).to be_successful
- end
-end
diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js
index 08368e1f2ca..13dbda9cf55 100644
--- a/spec/frontend/gfm_auto_complete_spec.js
+++ b/spec/frontend/gfm_auto_complete_spec.js
@@ -576,55 +576,95 @@ describe('GfmAutoComplete', () => {
});
});
- describe('Members.templateFunction', () => {
- it('should return html with avatarTag and username', () => {
- expect(
- GfmAutoComplete.Members.templateFunction({
- avatarTag: 'IMG',
- username: 'my-group',
- title: '',
- icon: '',
- availabilityStatus: '',
- }),
- ).toBe('<li>IMG my-group <small></small> </li>');
- });
+ describe('GfmAutoComplete.Members', () => {
+ const member = {
+ name: 'Marge Simpson',
+ username: 'msimpson',
+ search: 'MargeSimpson msimpson',
+ };
- it('should add icon if icon is set', () => {
- expect(
- GfmAutoComplete.Members.templateFunction({
- avatarTag: 'IMG',
- username: 'my-group',
- title: '',
- icon: '<i class="icon"/>',
- availabilityStatus: '',
- }),
- ).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>');
- });
+ describe('templateFunction', () => {
+ it('should return html with avatarTag and username', () => {
+ expect(
+ GfmAutoComplete.Members.templateFunction({
+ avatarTag: 'IMG',
+ username: 'my-group',
+ title: '',
+ icon: '',
+ availabilityStatus: '',
+ }),
+ ).toBe('<li>IMG my-group <small></small> </li>');
+ });
- it('should add escaped title if title is set', () => {
- expect(
- GfmAutoComplete.Members.templateFunction({
- avatarTag: 'IMG',
- username: 'my-group',
- title: 'MyGroup+',
- icon: '<i class="icon"/>',
- availabilityStatus: '',
- }),
- ).toBe('<li>IMG my-group <small>MyGroup+</small> <i class="icon"/></li>');
- });
+ it('should add icon if icon is set', () => {
+ expect(
+ GfmAutoComplete.Members.templateFunction({
+ avatarTag: 'IMG',
+ username: 'my-group',
+ title: '',
+ icon: '<i class="icon"/>',
+ availabilityStatus: '',
+ }),
+ ).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>');
+ });
- it('should add user availability status if availabilityStatus is set', () => {
- expect(
- GfmAutoComplete.Members.templateFunction({
- avatarTag: 'IMG',
- username: 'my-group',
- title: '',
- icon: '<i class="icon"/>',
- availabilityStatus: '<span class="gl-text-gray-500"> (Busy)</span>',
- }),
- ).toBe(
- '<li>IMG my-group <small><span class="gl-text-gray-500"> (Busy)</span></small> <i class="icon"/></li>',
- );
+ it('should add escaped title if title is set', () => {
+ expect(
+ GfmAutoComplete.Members.templateFunction({
+ avatarTag: 'IMG',
+ username: 'my-group',
+ title: 'MyGroup+',
+ icon: '<i class="icon"/>',
+ availabilityStatus: '',
+ }),
+ ).toBe('<li>IMG my-group <small>MyGroup+</small> <i class="icon"/></li>');
+ });
+
+ it('should add user availability status if availabilityStatus is set', () => {
+ expect(
+ GfmAutoComplete.Members.templateFunction({
+ avatarTag: 'IMG',
+ username: 'my-group',
+ title: '',
+ icon: '<i class="icon"/>',
+ availabilityStatus: '<span class="gl-text-gray-500"> (Busy)</span>',
+ }),
+ ).toBe(
+ '<li>IMG my-group <small><span class="gl-text-gray-500"> (Busy)</span></small> <i class="icon"/></li>',
+ );
+ });
+
+ describe('nameOrUsernameStartsWith', () => {
+ it.each`
+ query | result
+ ${'mar'} | ${true}
+ ${'msi'} | ${true}
+ ${'margesimpson'} | ${true}
+ ${'msimpson'} | ${true}
+ ${'arge'} | ${false}
+ ${'rgesimp'} | ${false}
+ ${'maria'} | ${false}
+ ${'homer'} | ${false}
+ `('returns $result for $query', ({ query, result }) => {
+ expect(GfmAutoComplete.Members.nameOrUsernameStartsWith(member, query)).toBe(result);
+ });
+ });
+
+ describe('nameOrUsernameIncludes', () => {
+ it.each`
+ query | result
+ ${'mar'} | ${true}
+ ${'msi'} | ${true}
+ ${'margesimpson'} | ${true}
+ ${'msimpson'} | ${true}
+ ${'arge'} | ${true}
+ ${'rgesimp'} | ${true}
+ ${'maria'} | ${false}
+ ${'homer'} | ${false}
+ `('returns $result for $query', ({ query, result }) => {
+ expect(GfmAutoComplete.Members.nameOrUsernameIncludes(member, query)).toBe(result);
+ });
+ });
});
});
diff --git a/spec/frontend/gl_field_errors_spec.js b/spec/frontend/gl_field_errors_spec.js
index a1737211252..ada3b34e6b1 100644
--- a/spec/frontend/gl_field_errors_spec.js
+++ b/spec/frontend/gl_field_errors_spec.js
@@ -8,8 +8,6 @@ describe('GL Style Field Errors', () => {
testContext = {};
});
- preloadFixtures('static/gl_field_errors.html');
-
beforeEach(() => {
loadFixtures('static/gl_field_errors.html');
const $form = $('form.gl-show-field-errors');
diff --git a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
index 0fc4343ec3c..2e02159a20c 100644
--- a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
+++ b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
@@ -31,8 +31,11 @@ exports[`grafana integration component default state to match the default snapsh
class="js-section-sub-header"
>
- Embed Grafana charts in GitLab issues.
-
+ Set up Grafana authentication to embed Grafana panels in GitLab Flavored Markdown.
+
+ <gl-link-stub>
+ Learn more.
+ </gl-link-stub>
</p>
</div>
@@ -56,13 +59,13 @@ exports[`grafana integration component default state to match the default snapsh
>
<gl-form-input-stub
id="grafana-url"
- placeholder="https://my-url.grafana.net/"
+ placeholder="https://my-grafana.example.com/"
value="http://test.host"
/>
</gl-form-group-stub>
<gl-form-group-stub
- label="API Token"
+ label="API token"
label-for="grafana-token"
>
<gl-form-input-stub
@@ -74,7 +77,7 @@ exports[`grafana integration component default state to match the default snapsh
class="form-text text-muted"
>
- Enter the Grafana API Token.
+ Enter the Grafana API token.
<a
href="https://grafana.com/docs/http_api/auth/#create-api-token"
@@ -82,7 +85,7 @@ exports[`grafana integration component default state to match the default snapsh
target="_blank"
>
- More information
+ More information.
<gl-icon-stub
class="vertical-align-middle"
@@ -101,7 +104,7 @@ exports[`grafana integration component default state to match the default snapsh
variant="success"
>
- Save Changes
+ Save changes
</gl-button-stub>
</form>
diff --git a/spec/frontend/grafana_integration/components/grafana_integration_spec.js b/spec/frontend/grafana_integration/components/grafana_integration_spec.js
index ad1260d8030..f1a8e6fe2dc 100644
--- a/spec/frontend/grafana_integration/components/grafana_integration_spec.js
+++ b/spec/frontend/grafana_integration/components/grafana_integration_spec.js
@@ -62,7 +62,7 @@ describe('grafana integration component', () => {
wrapper = shallowMount(GrafanaIntegration, { store });
expect(wrapper.find('.js-section-sub-header').text()).toContain(
- 'Embed Grafana charts in GitLab issues.',
+ 'Set up Grafana authentication to embed Grafana panels in GitLab Flavored Markdown.\n Learn more.',
);
});
});
diff --git a/spec/frontend/graphql_shared/utils_spec.js b/spec/frontend/graphql_shared/utils_spec.js
index d392b0f0575..56bfb02ea4a 100644
--- a/spec/frontend/graphql_shared/utils_spec.js
+++ b/spec/frontend/graphql_shared/utils_spec.js
@@ -2,6 +2,8 @@ import {
getIdFromGraphQLId,
convertToGraphQLId,
convertToGraphQLIds,
+ convertFromGraphQLIds,
+ convertNodeIdsFromGraphQLIds,
} from '~/graphql_shared/utils';
const mockType = 'Group';
@@ -81,3 +83,35 @@ describe('convertToGraphQLIds', () => {
expect(() => convertToGraphQLIds(type, ids)).toThrow(new TypeError(message));
});
});
+
+describe('convertFromGraphQLIds', () => {
+ it.each`
+ ids | expected
+ ${[mockGid]} | ${[mockId]}
+ ${[mockGid, 'invalid id']} | ${[mockId, null]}
+ `('converts $ids from GraphQL Ids', ({ ids, expected }) => {
+ expect(convertFromGraphQLIds(ids)).toEqual(expected);
+ });
+
+ it("throws TypeError if `ids` parameter isn't an array", () => {
+ expect(() => convertFromGraphQLIds('invalid')).toThrow(
+ new TypeError('ids must be an array; got string'),
+ );
+ });
+});
+
+describe('convertNodeIdsFromGraphQLIds', () => {
+ it.each`
+ nodes | expected
+ ${[{ id: mockGid, name: 'foo bar' }, { id: mockGid, name: 'baz' }]} | ${[{ id: mockId, name: 'foo bar' }, { id: mockId, name: 'baz' }]}
+ ${[{ name: 'foo bar' }]} | ${[{ name: 'foo bar' }]}
+ `('converts `id` properties in $nodes from GraphQL Id', ({ nodes, expected }) => {
+ expect(convertNodeIdsFromGraphQLIds(nodes)).toEqual(expected);
+ });
+
+ it("throws TypeError if `nodes` parameter isn't an array", () => {
+ expect(() => convertNodeIdsFromGraphQLIds('invalid')).toThrow(
+ new TypeError('nodes must be an array; got string'),
+ );
+ });
+});
diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js
index 4fcc9bafa46..5a9f640392f 100644
--- a/spec/frontend/groups/components/group_item_spec.js
+++ b/spec/frontend/groups/components/group_item_spec.js
@@ -188,7 +188,7 @@ describe('GroupItemComponent', () => {
});
it('should render component template correctly', () => {
- const visibilityIconEl = vm.$el.querySelector('.item-visibility');
+ const visibilityIconEl = vm.$el.querySelector('[data-testid="group-visibility-icon"]');
expect(vm.$el.getAttribute('id')).toBe('group-55');
expect(vm.$el.classList.contains('group-row')).toBeTruthy();
@@ -209,8 +209,7 @@ describe('GroupItemComponent', () => {
expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined();
expect(visibilityIconEl).not.toBe(null);
- expect(visibilityIconEl.title).toBe(vm.visibilityTooltip);
- expect(visibilityIconEl.querySelectorAll('svg').length).toBeGreaterThan(0);
+ expect(visibilityIconEl.getAttribute('title')).toBe(vm.visibilityTooltip);
expect(vm.$el.querySelector('.access-type')).toBeDefined();
expect(vm.$el.querySelector('.description')).toBeDefined();
diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js
index 27305abfafa..4ca6d7259bd 100644
--- a/spec/frontend/header_spec.js
+++ b/spec/frontend/header_spec.js
@@ -15,7 +15,6 @@ describe('Header', () => {
$(document).trigger('todo:toggle', newCount);
}
- preloadFixtures(fixtureTemplate);
beforeEach(() => {
initTodoToggle();
loadFixtures(fixtureTemplate);
diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js
index 2b567816ce8..083a2a73b24 100644
--- a/spec/frontend/ide/components/commit_sidebar/form_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js
@@ -14,6 +14,7 @@ import {
createBranchChangedCommitError,
branchAlreadyExistsCommitError,
} from '~/ide/lib/errors';
+import { MSG_CANNOT_PUSH_CODE_SHORT } from '~/ide/messages';
import { createStore } from '~/ide/stores';
import { COMMIT_TO_NEW_BRANCH } from '~/ide/stores/modules/commit/constants';
@@ -84,8 +85,8 @@ describe('IDE commit form', () => {
${'when there are no changes'} | ${[]} | ${{ pushCode: true }} | ${goToEditView} | ${findBeginCommitButtonData} | ${true} | ${''}
${'when there are changes'} | ${['test']} | ${{ pushCode: true }} | ${goToEditView} | ${findBeginCommitButtonData} | ${false} | ${''}
${'when there are changes'} | ${['test']} | ${{ pushCode: true }} | ${goToCommitView} | ${findCommitButtonData} | ${false} | ${''}
- ${'when user cannot push'} | ${['test']} | ${{ pushCode: false }} | ${goToEditView} | ${findBeginCommitButtonData} | ${true} | ${CommitForm.MSG_CANNOT_PUSH_CODE}
- ${'when user cannot push'} | ${['test']} | ${{ pushCode: false }} | ${goToCommitView} | ${findCommitButtonData} | ${true} | ${CommitForm.MSG_CANNOT_PUSH_CODE}
+ ${'when user cannot push'} | ${['test']} | ${{ pushCode: false }} | ${goToEditView} | ${findBeginCommitButtonData} | ${true} | ${MSG_CANNOT_PUSH_CODE_SHORT}
+ ${'when user cannot push'} | ${['test']} | ${{ pushCode: false }} | ${goToCommitView} | ${findCommitButtonData} | ${true} | ${MSG_CANNOT_PUSH_CODE_SHORT}
`('$desc', ({ stagedFiles, userPermissions, viewFn, buttonFn, disabled, tooltip }) => {
beforeEach(async () => {
store.state.stagedFiles = stagedFiles;
diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js
index c9d19c18d03..bd251f78654 100644
--- a/spec/frontend/ide/components/ide_spec.js
+++ b/spec/frontend/ide/components/ide_spec.js
@@ -4,6 +4,7 @@ import Vuex from 'vuex';
import waitForPromises from 'helpers/wait_for_promises';
import ErrorMessage from '~/ide/components/error_message.vue';
import Ide from '~/ide/components/ide.vue';
+import { MSG_CANNOT_PUSH_CODE } from '~/ide/messages';
import { createStore } from '~/ide/stores';
import { file } from '../helpers';
import { projectData } from '../mock_data';
@@ -158,7 +159,7 @@ describe('WebIDE', () => {
expect(findAlert().props()).toMatchObject({
dismissible: false,
});
- expect(findAlert().text()).toBe(Ide.MSG_CANNOT_PUSH_CODE);
+ expect(findAlert().text()).toBe(MSG_CANNOT_PUSH_CODE);
});
it.each`
diff --git a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap
index efa58a4a47b..194a619c4aa 100644
--- a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap
+++ b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap
@@ -10,7 +10,6 @@ exports[`IDE pipelines list when loaded renders empty state when no latestPipeli
cansetci="true"
class="mb-auto mt-auto"
emptystatesvgpath="http://test.host"
- helppagepath="http://test.host"
/>
</div>
`;
diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js
index 58d8c0629fb..a917f4c0230 100644
--- a/spec/frontend/ide/components/pipelines/list_spec.js
+++ b/spec/frontend/ide/components/pipelines/list_spec.js
@@ -19,7 +19,6 @@ describe('IDE pipelines list', () => {
let wrapper;
const defaultState = {
- links: { ciHelpPagePath: TEST_HOST },
pipelinesEmptyStateSvgPath: TEST_HOST,
};
const defaultPipelinesState = {
diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js
index 1985feb1615..a3b327343e5 100644
--- a/spec/frontend/ide/components/repo_editor_spec.js
+++ b/spec/frontend/ide/components/repo_editor_spec.js
@@ -1,11 +1,15 @@
+import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
-import { Range } from 'monaco-editor';
+import { editor as monacoEditor, Range } from 'monaco-editor';
import Vue from 'vue';
import Vuex from 'vuex';
import '~/behaviors/markdown/render_gfm';
-import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
import waitForPromises from 'helpers/wait_for_promises';
import waitUsingRealTimer from 'helpers/wait_using_real_timer';
+import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data';
+import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN } from '~/editor/constants';
+import EditorLite from '~/editor/editor_lite';
+import { EditorWebIdeExtension } from '~/editor/extensions/editor_lite_webide_ext';
import RepoEditor from '~/ide/components/repo_editor.vue';
import {
leftSidebarViews,
@@ -13,733 +17,723 @@ import {
FILE_VIEW_MODE_PREVIEW,
viewerTypes,
} from '~/ide/constants';
-import Editor from '~/ide/lib/editor';
+import ModelManager from '~/ide/lib/common/model_manager';
import service from '~/ide/services';
import { createStoreOptions } from '~/ide/stores';
import axios from '~/lib/utils/axios_utils';
+import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import { file } from '../helpers';
-import { exampleConfigs, exampleFiles } from '../lib/editorconfig/mock_data';
+
+const defaultFileProps = {
+ ...file('file.txt'),
+ content: 'hello world',
+ active: true,
+ tempFile: true,
+};
+const createActiveFile = (props) => {
+ return {
+ ...defaultFileProps,
+ ...props,
+ };
+};
+
+const dummyFile = {
+ markdown: (() =>
+ createActiveFile({
+ projectId: 'namespace/project',
+ path: 'sample.md',
+ name: 'sample.md',
+ }))(),
+ binary: (() =>
+ createActiveFile({
+ name: 'file.dat',
+ content: '🐱', // non-ascii binary content,
+ }))(),
+ empty: (() =>
+ createActiveFile({
+ tempFile: false,
+ content: '',
+ raw: '',
+ }))(),
+};
+
+const prepareStore = (state, activeFile) => {
+ const localState = {
+ openFiles: [activeFile],
+ projects: {
+ 'gitlab-org/gitlab': {
+ branches: {
+ master: {
+ name: 'master',
+ commit: {
+ id: 'abcdefgh',
+ },
+ },
+ },
+ },
+ },
+ currentProjectId: 'gitlab-org/gitlab',
+ currentBranchId: 'master',
+ entries: {
+ [activeFile.path]: activeFile,
+ },
+ };
+ const storeOptions = createStoreOptions();
+ return new Vuex.Store({
+ ...createStoreOptions(),
+ state: {
+ ...storeOptions.state,
+ ...localState,
+ ...state,
+ },
+ });
+};
describe('RepoEditor', () => {
+ let wrapper;
let vm;
- let store;
+ let createInstanceSpy;
+ let createDiffInstanceSpy;
+ let createModelSpy;
const waitForEditorSetup = () =>
new Promise((resolve) => {
vm.$once('editorSetup', resolve);
});
- const createComponent = () => {
- if (vm) {
- throw new Error('vm already exists');
- }
- vm = createComponentWithStore(Vue.extend(RepoEditor), store, {
- file: store.state.openFiles[0],
+ const createComponent = async ({ state = {}, activeFile = defaultFileProps } = {}) => {
+ const store = prepareStore(state, activeFile);
+ wrapper = shallowMount(RepoEditor, {
+ store,
+ propsData: {
+ file: store.state.openFiles[0],
+ },
+ mocks: {
+ ContentViewer,
+ },
});
-
+ await waitForPromises();
+ vm = wrapper.vm;
jest.spyOn(vm, 'getFileData').mockResolvedValue();
jest.spyOn(vm, 'getRawFileData').mockResolvedValue();
-
- vm.$mount();
};
- const createOpenFile = (path) => {
- const origFile = store.state.openFiles[0];
- const newFile = { ...origFile, path, key: path, name: 'myfile.txt', content: 'hello world' };
-
- store.state.entries[path] = newFile;
-
- store.state.openFiles = [newFile];
- };
+ const findEditor = () => wrapper.find('[data-testid="editor-container"]');
+ const findTabs = () => wrapper.findAll('.ide-mode-tabs .nav-links li');
+ const findPreviewTab = () => wrapper.find('[data-testid="preview-tab"]');
beforeEach(() => {
- const f = {
- ...file('file.txt'),
- content: 'hello world',
- };
-
- const storeOptions = createStoreOptions();
- store = new Vuex.Store(storeOptions);
-
- f.active = true;
- f.tempFile = true;
-
- store.state.openFiles.push(f);
- store.state.projects = {
- 'gitlab-org/gitlab': {
- branches: {
- master: {
- name: 'master',
- commit: {
- id: 'abcdefgh',
- },
- },
- },
- },
- };
- store.state.currentProjectId = 'gitlab-org/gitlab';
- store.state.currentBranchId = 'master';
-
- Vue.set(store.state.entries, f.path, f);
+ createInstanceSpy = jest.spyOn(EditorLite.prototype, EDITOR_CODE_INSTANCE_FN);
+ createDiffInstanceSpy = jest.spyOn(EditorLite.prototype, EDITOR_DIFF_INSTANCE_FN);
+ createModelSpy = jest.spyOn(monacoEditor, 'createModel');
+ jest.spyOn(service, 'getFileData').mockResolvedValue();
+ jest.spyOn(service, 'getRawFileData').mockResolvedValue();
});
afterEach(() => {
- vm.$destroy();
- vm = null;
-
- Editor.editorInstance.dispose();
+ jest.clearAllMocks();
+ // create a new model each time, otherwise tests conflict with each other
+ // because of same model being used in multiple tests
+ // eslint-disable-next-line no-undef
+ monaco.editor.getModels().forEach((model) => model.dispose());
+ wrapper.destroy();
+ wrapper = null;
});
- const findEditor = () => vm.$el.querySelector('.multi-file-editor-holder');
- const changeViewMode = (viewMode) =>
- store.dispatch('editor/updateFileEditor', { path: vm.file.path, data: { viewMode } });
-
describe('default', () => {
- beforeEach(() => {
- createComponent();
-
- return waitForEditorSetup();
+ it.each`
+ boolVal | textVal
+ ${true} | ${'all'}
+ ${false} | ${'none'}
+ `('sets renderWhitespace to "$textVal"', async ({ boolVal, textVal } = {}) => {
+ await createComponent({
+ state: {
+ renderWhitespaceInCode: boolVal,
+ },
+ });
+ expect(vm.editorOptions.renderWhitespace).toEqual(textVal);
});
- it('sets renderWhitespace to `all`', () => {
- vm.$store.state.renderWhitespaceInCode = true;
-
- expect(vm.editorOptions.renderWhitespace).toEqual('all');
+ it('renders an ide container', async () => {
+ await createComponent();
+ expect(findEditor().isVisible()).toBe(true);
});
- it('sets renderWhitespace to `none`', () => {
- vm.$store.state.renderWhitespaceInCode = false;
+ it('renders only an edit tab', async () => {
+ await createComponent();
+ const tabs = findTabs();
- expect(vm.editorOptions.renderWhitespace).toEqual('none');
+ expect(tabs).toHaveLength(1);
+ expect(tabs.at(0).text()).toBe('Edit');
});
+ });
- it('renders an ide container', () => {
- expect(vm.shouldHideEditor).toBeFalsy();
- expect(vm.showEditor).toBe(true);
- expect(findEditor()).not.toHaveCss({ display: 'none' });
- });
+ describe('when file is markdown', () => {
+ let mock;
+ let activeFile;
- it('renders only an edit tab', (done) => {
- Vue.nextTick(() => {
- const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li');
+ beforeEach(() => {
+ activeFile = dummyFile.markdown;
- expect(tabs.length).toBe(1);
- expect(tabs[0].textContent.trim()).toBe('Edit');
+ mock = new MockAdapter(axios);
- done();
+ mock.onPost(/(.*)\/preview_markdown/).reply(200, {
+ body: `<p>${defaultFileProps.content}</p>`,
});
});
- describe('when file is markdown', () => {
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
-
- mock.onPost(/(.*)\/preview_markdown/).reply(200, {
- body: '<p>testing 123</p>',
- });
-
- Vue.set(vm, 'file', {
- ...vm.file,
- projectId: 'namespace/project',
- path: 'sample.md',
- name: 'sample.md',
- content: 'testing 123',
- });
-
- vm.$store.state.entries[vm.file.path] = vm.file;
+ afterEach(() => {
+ mock.restore();
+ });
- return vm.$nextTick();
- });
+ it('renders an Edit and a Preview Tab', async () => {
+ await createComponent({ activeFile });
+ const tabs = findTabs();
- afterEach(() => {
- mock.restore();
- });
+ expect(tabs).toHaveLength(2);
+ expect(tabs.at(0).text()).toBe('Edit');
+ expect(tabs.at(1).text()).toBe('Preview Markdown');
+ });
- it('renders an Edit and a Preview Tab', (done) => {
- Vue.nextTick(() => {
- const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li');
+ it('renders markdown for tempFile', async () => {
+ // by default files created in the spec are temp: no need for explicitly sending the param
+ await createComponent({ activeFile });
- expect(tabs.length).toBe(2);
- expect(tabs[0].textContent.trim()).toBe('Edit');
- expect(tabs[1].textContent.trim()).toBe('Preview Markdown');
+ findPreviewTab().trigger('click');
+ await waitForPromises();
+ expect(wrapper.find(ContentViewer).html()).toContain(defaultFileProps.content);
+ });
- done();
- });
+ it('shows no tabs when not in Edit mode', async () => {
+ await createComponent({
+ state: {
+ currentActivityView: leftSidebarViews.review.name,
+ },
+ activeFile,
});
+ expect(findTabs()).toHaveLength(0);
+ });
+ });
- it('renders markdown for tempFile', (done) => {
- vm.file.tempFile = true;
-
- vm.$nextTick()
- .then(() => {
- vm.$el.querySelectorAll('.ide-mode-tabs .nav-links a')[1].click();
- })
- .then(waitForPromises)
- .then(() => {
- expect(vm.$el.querySelector('.preview-container').innerHTML).toContain(
- '<p>testing 123</p>',
- );
- })
- .then(done)
- .catch(done.fail);
- });
+ describe('when file is binary and not raw', () => {
+ beforeEach(async () => {
+ const activeFile = dummyFile.binary;
+ await createComponent({ activeFile });
+ });
- describe('when not in edit mode', () => {
- beforeEach(async () => {
- await vm.$nextTick();
+ it('does not render the IDE', () => {
+ expect(findEditor().isVisible()).toBe(false);
+ });
- vm.$store.state.currentActivityView = leftSidebarViews.review.name;
+ it('does not create an instance', () => {
+ expect(createInstanceSpy).not.toHaveBeenCalled();
+ expect(createDiffInstanceSpy).not.toHaveBeenCalled();
+ });
+ });
- return vm.$nextTick();
+ describe('createEditorInstance', () => {
+ it.each`
+ viewer | diffInstance
+ ${viewerTypes.edit} | ${undefined}
+ ${viewerTypes.diff} | ${true}
+ ${viewerTypes.mr} | ${true}
+ `(
+ 'creates instance of correct type when viewer is $viewer',
+ async ({ viewer, diffInstance }) => {
+ await createComponent({
+ state: { viewer },
});
+ const isDiff = () => {
+ return diffInstance ? { isDiff: true } : {};
+ };
+ expect(createInstanceSpy).toHaveBeenCalledWith(expect.objectContaining(isDiff()));
+ expect(createDiffInstanceSpy).toHaveBeenCalledTimes((diffInstance && 1) || 0);
+ },
+ );
- it('shows no tabs', () => {
- expect(vm.$el.querySelectorAll('.ide-mode-tabs .nav-links a')).toHaveLength(0);
+ it('installs the WebIDE extension', async () => {
+ const extensionSpy = jest.spyOn(EditorLite, 'instanceApplyExtension');
+ await createComponent();
+ expect(extensionSpy).toHaveBeenCalled();
+ Reflect.ownKeys(EditorWebIdeExtension.prototype)
+ .filter((fn) => fn !== 'constructor')
+ .forEach((fn) => {
+ expect(vm.editor[fn]).toBe(EditorWebIdeExtension.prototype[fn]);
});
- });
});
+ });
- describe('when open file is binary and not raw', () => {
- beforeEach((done) => {
- vm.file.name = 'file.dat';
- vm.file.content = '🐱'; // non-ascii binary content
- jest.spyOn(vm.editor, 'createInstance').mockImplementation();
- jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation();
-
- vm.$nextTick(done);
- });
-
- it('does not render the IDE', () => {
- expect(vm.shouldHideEditor).toBeTruthy();
- });
-
- it('does not call createInstance', async () => {
- // Mirror the act's in the `createEditorInstance`
- vm.createEditorInstance();
-
- await vm.$nextTick();
+ describe('setupEditor', () => {
+ beforeEach(async () => {
+ await createComponent();
+ });
- expect(vm.editor.createInstance).not.toHaveBeenCalled();
- expect(vm.editor.createDiffInstance).not.toHaveBeenCalled();
- });
+ it('creates new model on load', () => {
+ // We always create two models per file to be able to build a diff of changes
+ expect(createModelSpy).toHaveBeenCalledTimes(2);
+ // The model with the most recent changes is the last one
+ const [content] = createModelSpy.mock.calls[1];
+ expect(content).toBe(defaultFileProps.content);
});
- describe('createEditorInstance', () => {
- it('calls createInstance when viewer is editor', (done) => {
- jest.spyOn(vm.editor, 'createInstance').mockImplementation();
+ it('does not create a new model on subsequent calls to setupEditor and re-uses the already-existing model', () => {
+ const existingModel = vm.model;
+ createModelSpy.mockClear();
- vm.createEditorInstance();
+ vm.setupEditor();
- vm.$nextTick(() => {
- expect(vm.editor.createInstance).toHaveBeenCalled();
+ expect(createModelSpy).not.toHaveBeenCalled();
+ expect(vm.model).toBe(existingModel);
+ });
- done();
- });
- });
+ it('adds callback methods', () => {
+ jest.spyOn(vm.editor, 'onPositionChange');
+ jest.spyOn(vm.model, 'onChange');
+ jest.spyOn(vm.model, 'updateOptions');
- it('calls createDiffInstance when viewer is diff', (done) => {
- vm.$store.state.viewer = 'diff';
+ vm.setupEditor();
- jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation();
+ expect(vm.editor.onPositionChange).toHaveBeenCalledTimes(1);
+ expect(vm.model.onChange).toHaveBeenCalledTimes(1);
+ expect(vm.model.updateOptions).toHaveBeenCalledWith(vm.rules);
+ });
- vm.createEditorInstance();
+ it('updates state with the value of the model', () => {
+ const newContent = 'As Gregor Samsa\n awoke one morning\n';
+ vm.model.setValue(newContent);
- vm.$nextTick(() => {
- expect(vm.editor.createDiffInstance).toHaveBeenCalled();
+ vm.setupEditor();
- done();
- });
- });
+ expect(vm.file.content).toBe(newContent);
+ });
- it('calls createDiffInstance when viewer is a merge request diff', (done) => {
- vm.$store.state.viewer = 'mrdiff';
+ it('sets head model as staged file', () => {
+ vm.modelManager.dispose();
+ const addModelSpy = jest.spyOn(ModelManager.prototype, 'addModel');
- jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation();
+ vm.$store.state.stagedFiles.push({ ...vm.file, key: 'staged' });
+ vm.file.staged = true;
+ vm.file.key = `unstaged-${vm.file.key}`;
- vm.createEditorInstance();
+ vm.setupEditor();
- vm.$nextTick(() => {
- expect(vm.editor.createDiffInstance).toHaveBeenCalled();
+ expect(addModelSpy).toHaveBeenCalledWith(vm.file, vm.$store.state.stagedFiles[0]);
+ });
+ });
- done();
- });
- });
+ describe('editor updateDimensions', () => {
+ let updateDimensionsSpy;
+ let updateDiffViewSpy;
+ beforeEach(async () => {
+ await createComponent();
+ updateDimensionsSpy = jest.spyOn(vm.editor, 'updateDimensions');
+ updateDiffViewSpy = jest.spyOn(vm.editor, 'updateDiffView').mockImplementation();
});
- describe('setupEditor', () => {
- it('creates new model', () => {
- jest.spyOn(vm.editor, 'createModel');
+ it('calls updateDimensions only when panelResizing is false', async () => {
+ expect(updateDimensionsSpy).not.toHaveBeenCalled();
+ expect(updateDiffViewSpy).not.toHaveBeenCalled();
+ expect(vm.$store.state.panelResizing).toBe(false); // default value
- Editor.editorInstance.modelManager.dispose();
+ vm.$store.state.panelResizing = true;
+ await vm.$nextTick();
- vm.setupEditor();
+ expect(updateDimensionsSpy).not.toHaveBeenCalled();
+ expect(updateDiffViewSpy).not.toHaveBeenCalled();
- expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, null);
- expect(vm.model).not.toBeNull();
- });
+ vm.$store.state.panelResizing = false;
+ await vm.$nextTick();
- it('attaches model to editor', () => {
- jest.spyOn(vm.editor, 'attachModel');
+ expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
+ expect(updateDiffViewSpy).toHaveBeenCalledTimes(1);
- Editor.editorInstance.modelManager.dispose();
+ vm.$store.state.panelResizing = true;
+ await vm.$nextTick();
- vm.setupEditor();
+ expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
+ expect(updateDiffViewSpy).toHaveBeenCalledTimes(1);
+ });
- expect(vm.editor.attachModel).toHaveBeenCalledWith(vm.model);
- });
+ it('calls updateDimensions when rightPane is toggled', async () => {
+ expect(updateDimensionsSpy).not.toHaveBeenCalled();
+ expect(updateDiffViewSpy).not.toHaveBeenCalled();
+ expect(vm.$store.state.rightPane.isOpen).toBe(false); // default value
- it('attaches model to merge request editor', () => {
- vm.$store.state.viewer = 'mrdiff';
- vm.file.mrChange = true;
- jest.spyOn(vm.editor, 'attachMergeRequestModel').mockImplementation();
+ vm.$store.state.rightPane.isOpen = true;
+ await vm.$nextTick();
- Editor.editorInstance.modelManager.dispose();
+ expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
+ expect(updateDiffViewSpy).toHaveBeenCalledTimes(1);
- vm.setupEditor();
+ vm.$store.state.rightPane.isOpen = false;
+ await vm.$nextTick();
- expect(vm.editor.attachMergeRequestModel).toHaveBeenCalledWith(vm.model);
- });
+ expect(updateDimensionsSpy).toHaveBeenCalledTimes(2);
+ expect(updateDiffViewSpy).toHaveBeenCalledTimes(2);
+ });
+ });
- it('does not attach model to merge request editor when not a MR change', () => {
- vm.$store.state.viewer = 'mrdiff';
- vm.file.mrChange = false;
- jest.spyOn(vm.editor, 'attachMergeRequestModel').mockImplementation();
+ describe('editor tabs', () => {
+ beforeEach(async () => {
+ await createComponent();
+ });
- Editor.editorInstance.modelManager.dispose();
+ it.each`
+ mode | isVisible
+ ${'edit'} | ${true}
+ ${'review'} | ${false}
+ ${'commit'} | ${false}
+ `('tabs in $mode are $isVisible', async ({ mode, isVisible } = {}) => {
+ vm.$store.state.currentActivityView = leftSidebarViews[mode].name;
- vm.setupEditor();
+ await vm.$nextTick();
+ expect(wrapper.find('.nav-links').exists()).toBe(isVisible);
+ });
+ });
- expect(vm.editor.attachMergeRequestModel).not.toHaveBeenCalledWith(vm.model);
+ describe('files in preview mode', () => {
+ let updateDimensionsSpy;
+ const changeViewMode = (viewMode) =>
+ vm.$store.dispatch('editor/updateFileEditor', {
+ path: vm.file.path,
+ data: { viewMode },
});
- it('adds callback methods', () => {
- jest.spyOn(vm.editor, 'onPositionChange');
-
- Editor.editorInstance.modelManager.dispose();
-
- vm.setupEditor();
-
- expect(vm.editor.onPositionChange).toHaveBeenCalled();
- expect(vm.model.events.size).toBe(2);
+ beforeEach(async () => {
+ await createComponent({
+ activeFile: dummyFile.markdown,
});
- it('updates state with the value of the model', () => {
- vm.model.setValue('testing 1234\n');
-
- vm.setupEditor();
-
- expect(vm.file.content).toBe('testing 1234\n');
- });
+ updateDimensionsSpy = jest.spyOn(vm.editor, 'updateDimensions');
- it('sets head model as staged file', () => {
- jest.spyOn(vm.editor, 'createModel');
+ changeViewMode(FILE_VIEW_MODE_PREVIEW);
+ await vm.$nextTick();
+ });
- Editor.editorInstance.modelManager.dispose();
+ it('do not show the editor', () => {
+ expect(vm.showEditor).toBe(false);
+ expect(findEditor().isVisible()).toBe(false);
+ });
- vm.$store.state.stagedFiles.push({ ...vm.file, key: 'staged' });
- vm.file.staged = true;
- vm.file.key = `unstaged-${vm.file.key}`;
+ it('updates dimensions when switching view back to edit', async () => {
+ expect(updateDimensionsSpy).not.toHaveBeenCalled();
- vm.setupEditor();
+ changeViewMode(FILE_VIEW_MODE_EDITOR);
+ await vm.$nextTick();
- expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, vm.$store.state.stagedFiles[0]);
- });
+ expect(updateDimensionsSpy).toHaveBeenCalled();
});
+ });
- describe('editor updateDimensions', () => {
- beforeEach(() => {
- jest.spyOn(vm.editor, 'updateDimensions');
- jest.spyOn(vm.editor, 'updateDiffView').mockImplementation();
- });
-
- it('calls updateDimensions when panelResizing is false', (done) => {
- vm.$store.state.panelResizing = true;
-
- vm.$nextTick()
- .then(() => {
- vm.$store.state.panelResizing = false;
- })
- .then(vm.$nextTick)
- .then(() => {
- expect(vm.editor.updateDimensions).toHaveBeenCalled();
- expect(vm.editor.updateDiffView).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('does not call updateDimensions when panelResizing is true', (done) => {
- vm.$store.state.panelResizing = true;
+ describe('initEditor', () => {
+ const hideEditorAndRunFn = async () => {
+ jest.clearAllMocks();
+ jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true);
- vm.$nextTick(() => {
- expect(vm.editor.updateDimensions).not.toHaveBeenCalled();
- expect(vm.editor.updateDiffView).not.toHaveBeenCalled();
+ vm.initEditor();
+ await vm.$nextTick();
+ };
- done();
- });
+ it('does not fetch file information for temp entries', async () => {
+ await createComponent({
+ activeFile: createActiveFile(),
});
- it('calls updateDimensions when rightPane is opened', (done) => {
- vm.$store.state.rightPane.isOpen = true;
-
- vm.$nextTick(() => {
- expect(vm.editor.updateDimensions).toHaveBeenCalled();
- expect(vm.editor.updateDiffView).toHaveBeenCalled();
-
- done();
- });
- });
+ expect(vm.getFileData).not.toHaveBeenCalled();
});
- describe('show tabs', () => {
- it('shows tabs in edit mode', () => {
- expect(vm.$el.querySelector('.nav-links')).not.toBe(null);
+ it('is being initialised for files without content even if shouldHideEditor is `true`', async () => {
+ await createComponent({
+ activeFile: dummyFile.empty,
});
- it('hides tabs in review mode', (done) => {
- vm.$store.state.currentActivityView = leftSidebarViews.review.name;
+ await hideEditorAndRunFn();
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.nav-links')).toBe(null);
+ expect(vm.getFileData).toHaveBeenCalled();
+ expect(vm.getRawFileData).toHaveBeenCalled();
+ });
- done();
- });
+ it('does not initialize editor for files already with content when shouldHideEditor is `true`', async () => {
+ await createComponent({
+ activeFile: createActiveFile(),
});
- it('hides tabs in commit mode', (done) => {
- vm.$store.state.currentActivityView = leftSidebarViews.commit.name;
+ await hideEditorAndRunFn();
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.nav-links')).toBe(null);
+ expect(vm.getFileData).not.toHaveBeenCalled();
+ expect(vm.getRawFileData).not.toHaveBeenCalled();
+ expect(createInstanceSpy).not.toHaveBeenCalled();
+ });
+ });
- done();
- });
+ describe('updates on file changes', () => {
+ beforeEach(async () => {
+ await createComponent({
+ activeFile: createActiveFile({
+ content: 'foo', // need to prevent full cycle of initEditor
+ }),
});
+ jest.spyOn(vm, 'initEditor').mockImplementation();
});
- describe('when files view mode is preview', () => {
- beforeEach((done) => {
- jest.spyOn(vm.editor, 'updateDimensions').mockImplementation();
- changeViewMode(FILE_VIEW_MODE_PREVIEW);
- vm.file.name = 'myfile.md';
- vm.file.content = 'hello world';
+ it('calls removePendingTab when old file is pending', async () => {
+ jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true);
+ jest.spyOn(vm, 'removePendingTab').mockImplementation();
- vm.$nextTick(done);
- });
+ const origFile = vm.file;
+ vm.file.pending = true;
+ await vm.$nextTick();
- it('should hide editor', () => {
- expect(vm.showEditor).toBe(false);
- expect(findEditor()).toHaveCss({ display: 'none' });
+ wrapper.setProps({
+ file: file('testing'),
});
+ vm.file.content = 'foo'; // need to prevent full cycle of initEditor
+ await vm.$nextTick();
- describe('when file view mode changes to editor', () => {
- it('should update dimensions', () => {
- changeViewMode(FILE_VIEW_MODE_EDITOR);
-
- return vm.$nextTick().then(() => {
- expect(vm.editor.updateDimensions).toHaveBeenCalled();
- });
- });
- });
+ expect(vm.removePendingTab).toHaveBeenCalledWith(origFile);
});
- describe('initEditor', () => {
- beforeEach(() => {
- vm.file.tempFile = false;
- jest.spyOn(vm.editor, 'createInstance').mockImplementation();
- jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true);
- });
+ it('does not call initEditor if the file did not change', async () => {
+ Vue.set(vm, 'file', vm.file);
+ await vm.$nextTick();
- it('does not fetch file information for temp entries', (done) => {
- vm.file.tempFile = true;
-
- vm.initEditor();
- vm.$nextTick()
- .then(() => {
- expect(vm.getFileData).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('is being initialised for files without content even if shouldHideEditor is `true`', (done) => {
- vm.file.content = '';
- vm.file.raw = '';
+ expect(vm.initEditor).not.toHaveBeenCalled();
+ });
- vm.initEditor();
+ it('calls initEditor when file key is changed', async () => {
+ expect(vm.initEditor).not.toHaveBeenCalled();
- vm.$nextTick()
- .then(() => {
- expect(vm.getFileData).toHaveBeenCalled();
- expect(vm.getRawFileData).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ wrapper.setProps({
+ file: {
+ ...vm.file,
+ key: 'new',
+ },
});
+ await vm.$nextTick();
- it('does not initialize editor for files already with content', (done) => {
- vm.file.content = 'foo';
-
- vm.initEditor();
- vm.$nextTick()
- .then(() => {
- expect(vm.getFileData).not.toHaveBeenCalled();
- expect(vm.getRawFileData).not.toHaveBeenCalled();
- expect(vm.editor.createInstance).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
+ expect(vm.initEditor).toHaveBeenCalled();
+ });
+ });
+
+ describe('populates editor with the fetched content', () => {
+ const createRemoteFile = (name) => ({
+ ...file(name),
+ tmpFile: false,
});
- describe('updates on file changes', () => {
- beforeEach(() => {
- jest.spyOn(vm, 'initEditor').mockImplementation();
- });
+ beforeEach(async () => {
+ await createComponent();
+ vm.getRawFileData.mockRestore();
+ });
- it('calls removePendingTab when old file is pending', (done) => {
- jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true);
- jest.spyOn(vm, 'removePendingTab').mockImplementation();
+ it('after switching viewer from edit to diff', async () => {
+ const f = createRemoteFile('newFile');
+ Vue.set(vm.$store.state.entries, f.path, f);
- vm.file.pending = true;
+ jest.spyOn(service, 'getRawFileData').mockImplementation(async () => {
+ expect(vm.file.loading).toBe(true);
- vm.$nextTick()
- .then(() => {
- vm.file = file('testing');
- vm.file.content = 'foo'; // need to prevent full cycle of initEditor
+ // switching from edit to diff mode usually triggers editor initialization
+ vm.$store.state.viewer = viewerTypes.diff;
- return vm.$nextTick();
- })
- .then(() => {
- expect(vm.removePendingTab).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ // we delay returning the file to make sure editor doesn't initialize before we fetch file content
+ await waitUsingRealTimer(30);
+ return 'rawFileData123\n';
});
- it('does not call initEditor if the file did not change', (done) => {
- Vue.set(vm, 'file', vm.file);
-
- vm.$nextTick()
- .then(() => {
- expect(vm.initEditor).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ wrapper.setProps({
+ file: f,
});
- it('calls initEditor when file key is changed', (done) => {
- expect(vm.initEditor).not.toHaveBeenCalled();
+ await waitForEditorSetup();
+ expect(vm.model.getModel().getValue()).toBe('rawFileData123\n');
+ });
- Vue.set(vm, 'file', {
- ...vm.file,
- key: 'new',
+ it('after opening multiple files at the same time', async () => {
+ const fileA = createRemoteFile('fileA');
+ const aContent = 'fileA-rawContent\n';
+ const bContent = 'fileB-rawContent\n';
+ const fileB = createRemoteFile('fileB');
+ Vue.set(vm.$store.state.entries, fileA.path, fileA);
+ Vue.set(vm.$store.state.entries, fileB.path, fileB);
+
+ jest
+ .spyOn(service, 'getRawFileData')
+ .mockImplementation(async () => {
+ // opening fileB while the content of fileA is still being fetched
+ wrapper.setProps({
+ file: fileB,
+ });
+ return aContent;
+ })
+ .mockImplementationOnce(async () => {
+ // we delay returning fileB content to make sure the editor doesn't initialize prematurely
+ await waitUsingRealTimer(30);
+ return bContent;
});
- vm.$nextTick()
- .then(() => {
- expect(vm.initEditor).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ wrapper.setProps({
+ file: fileA,
});
- });
- describe('populates editor with the fetched content', () => {
- beforeEach(() => {
- vm.getRawFileData.mockRestore();
- });
+ await waitForEditorSetup();
+ expect(vm.model.getModel().getValue()).toBe(bContent);
+ });
+ });
- const createRemoteFile = (name) => ({
- ...file(name),
- tmpFile: false,
+ describe('onPaste', () => {
+ const setFileName = (name) =>
+ createActiveFile({
+ content: 'hello world\n',
+ name,
+ path: `foo/${name}`,
+ key: 'new',
});
- it('after switching viewer from edit to diff', async () => {
- jest.spyOn(service, 'getRawFileData').mockImplementation(async () => {
- expect(vm.file.loading).toBe(true);
-
- // switching from edit to diff mode usually triggers editor initialization
- store.state.viewer = viewerTypes.diff;
+ const pasteImage = () => {
+ window.dispatchEvent(
+ Object.assign(new Event('paste'), {
+ clipboardData: {
+ files: [new File(['foo'], 'foo.png', { type: 'image/png' })],
+ },
+ }),
+ );
+ };
- // we delay returning the file to make sure editor doesn't initialize before we fetch file content
- await waitUsingRealTimer(30);
- return 'rawFileData123\n';
+ const watchState = (watched) =>
+ new Promise((resolve) => {
+ const unwatch = vm.$store.watch(watched, () => {
+ unwatch();
+ resolve();
});
-
- const f = createRemoteFile('newFile');
- Vue.set(store.state.entries, f.path, f);
-
- vm.file = f;
-
- await waitForEditorSetup();
- expect(vm.model.getModel().getValue()).toBe('rawFileData123\n');
});
- it('after opening multiple files at the same time', async () => {
- const fileA = createRemoteFile('fileA');
- const fileB = createRemoteFile('fileB');
- Vue.set(store.state.entries, fileA.path, fileA);
- Vue.set(store.state.entries, fileB.path, fileB);
-
- jest
- .spyOn(service, 'getRawFileData')
- .mockImplementationOnce(async () => {
- // opening fileB while the content of fileA is still being fetched
- vm.file = fileB;
- return 'fileA-rawContent\n';
- })
- .mockImplementationOnce(async () => {
- // we delay returning fileB content to make sure the editor doesn't initialize prematurely
- await waitUsingRealTimer(30);
- return 'fileB-rawContent\n';
- });
+ // Pasting an image does a lot of things like using the FileReader API,
+ // so, waitForPromises isn't very reliable (and causes a flaky spec)
+ // Read more about state.watch: https://vuex.vuejs.org/api/#watch
+ const waitForFileContentChange = () => watchState((s) => s.entries['foo/bar.md'].content);
- vm.file = fileA;
-
- await waitForEditorSetup();
- expect(vm.model.getModel().getValue()).toBe('fileB-rawContent\n');
+ beforeEach(async () => {
+ await createComponent({
+ state: {
+ trees: {
+ 'gitlab-org/gitlab': { tree: [] },
+ },
+ currentProjectId: 'gitlab-org',
+ currentBranchId: 'gitlab',
+ },
+ activeFile: setFileName('bar.md'),
});
- });
-
- describe('onPaste', () => {
- const setFileName = (name) => {
- Vue.set(vm, 'file', {
- ...vm.file,
- content: 'hello world\n',
- name,
- path: `foo/${name}`,
- key: 'new',
- });
- vm.$store.state.entries[vm.file.path] = vm.file;
- };
+ vm.setupEditor();
- const pasteImage = () => {
- window.dispatchEvent(
- Object.assign(new Event('paste'), {
- clipboardData: {
- files: [new File(['foo'], 'foo.png', { type: 'image/png' })],
- },
- }),
- );
- };
-
- const watchState = (watched) =>
- new Promise((resolve) => {
- const unwatch = vm.$store.watch(watched, () => {
- unwatch();
- resolve();
- });
- });
+ await waitForPromises();
+ // set cursor to line 2, column 1
+ vm.editor.setSelection(new Range(2, 1, 2, 1));
+ vm.editor.focus();
- // Pasting an image does a lot of things like using the FileReader API,
- // so, waitForPromises isn't very reliable (and causes a flaky spec)
- // Read more about state.watch: https://vuex.vuejs.org/api/#watch
- const waitForFileContentChange = () => watchState((s) => s.entries['foo/bar.md'].content);
-
- beforeEach(() => {
- setFileName('bar.md');
-
- vm.$store.state.trees['gitlab-org/gitlab'] = { tree: [] };
- vm.$store.state.currentProjectId = 'gitlab-org';
- vm.$store.state.currentBranchId = 'gitlab';
-
- // create a new model each time, otherwise tests conflict with each other
- // because of same model being used in multiple tests
- Editor.editorInstance.modelManager.dispose();
- vm.setupEditor();
+ jest.spyOn(vm.editor, 'hasTextFocus').mockReturnValue(true);
+ });
- return waitForPromises().then(() => {
- // set cursor to line 2, column 1
- vm.editor.instance.setSelection(new Range(2, 1, 2, 1));
- vm.editor.instance.focus();
+ it('adds an image entry to the same folder for a pasted image in a markdown file', async () => {
+ pasteImage();
- jest.spyOn(vm.editor.instance, 'hasTextFocus').mockReturnValue(true);
- });
+ await waitForFileContentChange();
+ expect(vm.$store.state.entries['foo/foo.png']).toMatchObject({
+ path: 'foo/foo.png',
+ type: 'blob',
+ content: 'Zm9v',
+ rawPath: 'data:image/png;base64,Zm9v',
});
+ });
- it('adds an image entry to the same folder for a pasted image in a markdown file', () => {
- pasteImage();
-
- return waitForFileContentChange().then(() => {
- expect(vm.$store.state.entries['foo/foo.png']).toMatchObject({
- path: 'foo/foo.png',
- type: 'blob',
- content: 'Zm9v',
- rawPath: 'data:image/png;base64,Zm9v',
- });
- });
- });
+ it("adds a markdown image tag to the file's contents", async () => {
+ pasteImage();
- it("adds a markdown image tag to the file's contents", () => {
- pasteImage();
+ await waitForFileContentChange();
+ expect(vm.file.content).toBe('hello world\n![foo.png](./foo.png)');
+ });
- return waitForFileContentChange().then(() => {
- expect(vm.file.content).toBe('hello world\n![foo.png](./foo.png)');
- });
+ it("does not add file to state or set markdown image syntax if the file isn't markdown", async () => {
+ wrapper.setProps({
+ file: setFileName('myfile.txt'),
});
+ pasteImage();
- it("does not add file to state or set markdown image syntax if the file isn't markdown", () => {
- setFileName('myfile.txt');
- pasteImage();
-
- return waitForPromises().then(() => {
- expect(vm.$store.state.entries['foo/foo.png']).toBeUndefined();
- expect(vm.file.content).toBe('hello world\n');
- });
- });
+ await waitForPromises();
+ expect(vm.$store.state.entries['foo/foo.png']).toBeUndefined();
+ expect(vm.file.content).toBe('hello world\n');
});
});
describe('fetchEditorconfigRules', () => {
- beforeEach(() => {
- exampleConfigs.forEach(({ path, content }) => {
- store.state.entries[path] = { ...file(), path, content };
- });
- });
-
it.each(exampleFiles)(
'does not fetch content from remote for .editorconfig files present locally (case %#)',
- ({ path, monacoRules }) => {
- createOpenFile(path);
- createComponent();
-
- return waitForEditorSetup().then(() => {
- expect(vm.rules).toEqual(monacoRules);
- expect(vm.model.options).toMatchObject(monacoRules);
- expect(vm.getFileData).not.toHaveBeenCalled();
- expect(vm.getRawFileData).not.toHaveBeenCalled();
+ async ({ path, monacoRules }) => {
+ await createComponent({
+ state: {
+ entries: (() => {
+ const res = {};
+ exampleConfigs.forEach(({ path: configPath, content }) => {
+ res[configPath] = { ...file(), path: configPath, content };
+ });
+ return res;
+ })(),
+ },
+ activeFile: createActiveFile({
+ path,
+ key: path,
+ name: 'myfile.txt',
+ content: 'hello world',
+ }),
});
+
+ expect(vm.rules).toEqual(monacoRules);
+ expect(vm.model.options).toMatchObject(monacoRules);
+ expect(vm.getFileData).not.toHaveBeenCalled();
+ expect(vm.getRawFileData).not.toHaveBeenCalled();
},
);
- it('fetches content from remote for .editorconfig files not available locally', () => {
- exampleConfigs.forEach(({ path }) => {
- delete store.state.entries[path].content;
- delete store.state.entries[path].raw;
+ it('fetches content from remote for .editorconfig files not available locally', async () => {
+ const activeFile = createActiveFile({
+ path: 'foo/bar/baz/test/my_spec.js',
+ key: 'foo/bar/baz/test/my_spec.js',
+ name: 'myfile.txt',
+ content: 'hello world',
+ });
+
+ const expectations = [
+ 'foo/bar/baz/.editorconfig',
+ 'foo/bar/.editorconfig',
+ 'foo/.editorconfig',
+ '.editorconfig',
+ ];
+
+ await createComponent({
+ state: {
+ entries: (() => {
+ const res = {
+ [activeFile.path]: activeFile,
+ };
+ exampleConfigs.forEach(({ path: configPath }) => {
+ const f = { ...file(), path: configPath };
+ delete f.content;
+ delete f.raw;
+ res[configPath] = f;
+ });
+ return res;
+ })(),
+ },
+ activeFile,
});
- // Include a "test" directory which does not exist in store. This one should be skipped.
- createOpenFile('foo/bar/baz/test/my_spec.js');
- createComponent();
-
- return waitForEditorSetup().then(() => {
- expect(vm.getFileData.mock.calls.map(([args]) => args)).toEqual([
- { makeFileActive: false, path: 'foo/bar/baz/.editorconfig' },
- { makeFileActive: false, path: 'foo/bar/.editorconfig' },
- { makeFileActive: false, path: 'foo/.editorconfig' },
- { makeFileActive: false, path: '.editorconfig' },
- ]);
- expect(vm.getRawFileData.mock.calls.map(([args]) => args)).toEqual([
- { path: 'foo/bar/baz/.editorconfig' },
- { path: 'foo/bar/.editorconfig' },
- { path: 'foo/.editorconfig' },
- { path: '.editorconfig' },
- ]);
- });
+ expect(service.getFileData.mock.calls.map(([args]) => args)).toEqual(
+ expectations.map((expectation) => expect.stringContaining(expectation)),
+ );
+ expect(service.getRawFileData.mock.calls.map(([args]) => args)).toEqual(
+ expectations.map((expectation) => expect.objectContaining({ path: expectation })),
+ );
});
});
});
diff --git a/spec/frontend/ide/components/repo_tab_spec.js b/spec/frontend/ide/components/repo_tab_spec.js
index b39a488b034..95d52e8f7a9 100644
--- a/spec/frontend/ide/components/repo_tab_spec.js
+++ b/spec/frontend/ide/components/repo_tab_spec.js
@@ -1,5 +1,7 @@
+import { GlTab } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
+import { stubComponent } from 'helpers/stub_component';
import RepoTab from '~/ide/components/repo_tab.vue';
import { createRouter } from '~/ide/ide_router';
import { createStore } from '~/ide/stores';
@@ -8,16 +10,25 @@ import { file } from '../helpers';
const localVue = createLocalVue();
localVue.use(Vuex);
+const GlTabStub = stubComponent(GlTab, {
+ template: '<li><slot name="title" /></li>',
+});
+
describe('RepoTab', () => {
let wrapper;
let store;
let router;
+ const findTab = () => wrapper.find(GlTabStub);
+
function createComponent(propsData) {
wrapper = mount(RepoTab, {
localVue,
store,
propsData,
+ stubs: {
+ GlTab: GlTabStub,
+ },
});
}
@@ -55,7 +66,7 @@ describe('RepoTab', () => {
jest.spyOn(wrapper.vm, 'openPendingTab').mockImplementation(() => {});
- await wrapper.trigger('click');
+ await findTab().vm.$emit('click');
expect(wrapper.vm.openPendingTab).not.toHaveBeenCalled();
});
@@ -67,7 +78,7 @@ describe('RepoTab', () => {
jest.spyOn(wrapper.vm, 'clickFile').mockImplementation(() => {});
- wrapper.trigger('click');
+ findTab().vm.$emit('click');
expect(wrapper.vm.clickFile).toHaveBeenCalledWith(wrapper.vm.tab);
});
@@ -91,11 +102,11 @@ describe('RepoTab', () => {
tab,
});
- await wrapper.trigger('mouseover');
+ await findTab().vm.$emit('mouseover');
expect(wrapper.find('.file-modified').exists()).toBe(false);
- await wrapper.trigger('mouseout');
+ await findTab().vm.$emit('mouseout');
expect(wrapper.find('.file-modified').exists()).toBe(true);
});
diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js
index 678d58cba34..3503834e24b 100644
--- a/spec/frontend/ide/services/index_spec.js
+++ b/spec/frontend/ide/services/index_spec.js
@@ -1,7 +1,7 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
+import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql';
import Api from '~/api';
-import getUserPermissions from '~/ide/queries/getUserPermissions.query.graphql';
import services from '~/ide/services';
import { query } from '~/ide/services/gql';
import { escapeFileUrl } from '~/lib/utils/url_utility';
@@ -228,7 +228,7 @@ describe('IDE services', () => {
expect(response).toEqual({ data: { ...projectData, ...gqlProjectData } });
expect(Api.project).toHaveBeenCalledWith(TEST_PROJECT_ID);
expect(query).toHaveBeenCalledWith({
- query: getUserPermissions,
+ query: getIdeProject,
variables: {
projectPath: TEST_PROJECT_ID,
},
diff --git a/spec/frontend/ide/stores/getters_spec.js b/spec/frontend/ide/stores/getters_spec.js
index 450f5592026..6b66c87e205 100644
--- a/spec/frontend/ide/stores/getters_spec.js
+++ b/spec/frontend/ide/stores/getters_spec.js
@@ -1,7 +1,17 @@
import { TEST_HOST } from 'helpers/test_constants';
+import {
+ DEFAULT_PERMISSIONS,
+ PERMISSION_PUSH_CODE,
+ PUSH_RULE_REJECT_UNSIGNED_COMMITS,
+} from '~/ide/constants';
+import {
+ MSG_CANNOT_PUSH_CODE,
+ MSG_CANNOT_PUSH_CODE_SHORT,
+ MSG_CANNOT_PUSH_UNSIGNED,
+ MSG_CANNOT_PUSH_UNSIGNED_SHORT,
+} from '~/ide/messages';
import { createStore } from '~/ide/stores';
import * as getters from '~/ide/stores/getters';
-import { DEFAULT_PERMISSIONS } from '../../../../app/assets/javascripts/ide/constants';
import { file } from '../helpers';
const TEST_PROJECT_ID = 'test_project';
@@ -385,22 +395,23 @@ describe('IDE store getters', () => {
);
});
- describe('findProjectPermissions', () => {
- it('returns false if project not found', () => {
- expect(localStore.getters.findProjectPermissions(TEST_PROJECT_ID)).toEqual(
- DEFAULT_PERMISSIONS,
- );
+ describe.each`
+ getterName | projectField | defaultValue
+ ${'findProjectPermissions'} | ${'userPermissions'} | ${DEFAULT_PERMISSIONS}
+ ${'findPushRules'} | ${'pushRules'} | ${{}}
+ `('$getterName', ({ getterName, projectField, defaultValue }) => {
+ const callGetter = (...args) => localStore.getters[getterName](...args);
+
+ it('returns default if project not found', () => {
+ expect(callGetter(TEST_PROJECT_ID)).toEqual(defaultValue);
});
- it('finds permission in given project', () => {
- const userPermissions = {
- readMergeRequest: true,
- createMergeRequestsIn: false,
- };
+ it('finds field in given project', () => {
+ const obj = { test: 'foo' };
- localState.projects[TEST_PROJECT_ID] = { userPermissions };
+ localState.projects[TEST_PROJECT_ID] = { [projectField]: obj };
- expect(localStore.getters.findProjectPermissions(TEST_PROJECT_ID)).toBe(userPermissions);
+ expect(callGetter(TEST_PROJECT_ID)).toBe(obj);
});
});
@@ -408,7 +419,6 @@ describe('IDE store getters', () => {
getterName | permissionKey
${'canReadMergeRequests'} | ${'readMergeRequest'}
${'canCreateMergeRequests'} | ${'createMergeRequestIn'}
- ${'canPushCode'} | ${'pushCode'}
`('$getterName', ({ getterName, permissionKey }) => {
it.each([true, false])('finds permission for current project (%s)', (val) => {
localState.projects[TEST_PROJECT_ID] = {
@@ -422,6 +432,38 @@ describe('IDE store getters', () => {
});
});
+ describe('canPushCodeStatus', () => {
+ it.each`
+ pushCode | rejectUnsignedCommits | expected
+ ${true} | ${false} | ${{ isAllowed: true, message: '', messageShort: '' }}
+ ${false} | ${false} | ${{ isAllowed: false, message: MSG_CANNOT_PUSH_CODE, messageShort: MSG_CANNOT_PUSH_CODE_SHORT }}
+ ${false} | ${true} | ${{ isAllowed: false, message: MSG_CANNOT_PUSH_UNSIGNED, messageShort: MSG_CANNOT_PUSH_UNSIGNED_SHORT }}
+ `(
+ 'with pushCode="$pushCode" and rejectUnsignedCommits="$rejectUnsignedCommits"',
+ ({ pushCode, rejectUnsignedCommits, expected }) => {
+ localState.projects[TEST_PROJECT_ID] = {
+ pushRules: {
+ [PUSH_RULE_REJECT_UNSIGNED_COMMITS]: rejectUnsignedCommits,
+ },
+ userPermissions: {
+ [PERMISSION_PUSH_CODE]: pushCode,
+ },
+ };
+ localState.currentProjectId = TEST_PROJECT_ID;
+
+ expect(localStore.getters.canPushCodeStatus).toEqual(expected);
+ },
+ );
+ });
+
+ describe('canPushCode', () => {
+ it.each([true, false])('with canPushCodeStatus.isAllowed = $s', (isAllowed) => {
+ const canPushCodeStatus = { isAllowed };
+
+ expect(getters.canPushCode({}, { canPushCodeStatus })).toBe(isAllowed);
+ });
+ });
+
describe('entryExists', () => {
beforeEach(() => {
localState.entries = {
diff --git a/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js
index cdef4b1ee62..7a83136e785 100644
--- a/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js
+++ b/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js
@@ -1,10 +1,15 @@
-import { GlButton, GlLink, GlFormInput } from '@gitlab/ui';
+import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlFormInput } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
import { STATUSES } from '~/import_entities/constants';
import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue';
-import Select2Select from '~/vue_shared/components/select2_select.vue';
+import groupQuery from '~/import_entities/import_groups/graphql/queries/group.query.graphql';
import { availableNamespacesFixture } from '../graphql/fixtures';
+Vue.use(VueApollo);
+
const getFakeGroup = (status) => ({
web_url: 'https://fake.host/',
full_path: 'fake_group_1',
@@ -17,8 +22,12 @@ const getFakeGroup = (status) => ({
status,
});
+const EXISTING_GROUP_TARGET_NAMESPACE = 'existing-group';
+const EXISTING_GROUP_PATH = 'existing-path';
+
describe('import table row', () => {
let wrapper;
+ let apolloProvider;
let group;
const findByText = (cmp, text) => {
@@ -26,12 +35,27 @@ describe('import table row', () => {
};
const findImportButton = () => findByText(GlButton, 'Import');
const findNameInput = () => wrapper.find(GlFormInput);
- const findNamespaceDropdown = () => wrapper.find(Select2Select);
+ const findNamespaceDropdown = () => wrapper.find(GlDropdown);
const createComponent = (props) => {
+ apolloProvider = createMockApollo([
+ [
+ groupQuery,
+ ({ fullPath }) => {
+ const existingGroup =
+ fullPath === `${EXISTING_GROUP_TARGET_NAMESPACE}/${EXISTING_GROUP_PATH}`
+ ? { id: 1 }
+ : null;
+ return Promise.resolve({ data: { existingGroup } });
+ },
+ ],
+ ]);
+
wrapper = shallowMount(ImportTableRow, {
+ apolloProvider,
propsData: {
availableNamespaces: availableNamespacesFixture,
+ groupPathRegex: /.*/,
...props,
},
});
@@ -49,15 +73,24 @@ describe('import table row', () => {
});
it.each`
- selector | sourceEvent | payload | event
- ${findNamespaceDropdown} | ${'input'} | ${'demo'} | ${'update-target-namespace'}
- ${findNameInput} | ${'input'} | ${'demo'} | ${'update-new-name'}
- ${findImportButton} | ${'click'} | ${undefined} | ${'import-group'}
+ selector | sourceEvent | payload | event
+ ${findNameInput} | ${'input'} | ${'demo'} | ${'update-new-name'}
+ ${findImportButton} | ${'click'} | ${undefined} | ${'import-group'}
`('invokes $event', ({ selector, sourceEvent, payload, event }) => {
selector().vm.$emit(sourceEvent, payload);
expect(wrapper.emitted(event)).toBeDefined();
expect(wrapper.emitted(event)[0][0]).toBe(payload);
});
+
+ it('emits update-target-namespace when dropdown option is clicked', () => {
+ const dropdownItem = findNamespaceDropdown().findAllComponents(GlDropdownItem).at(2);
+ const dropdownItemText = dropdownItem.text();
+
+ dropdownItem.vm.$emit('click');
+
+ expect(wrapper.emitted('update-target-namespace')).toBeDefined();
+ expect(wrapper.emitted('update-target-namespace')[0][0]).toBe(dropdownItemText);
+ });
});
describe('when entity status is NONE', () => {
@@ -75,6 +108,34 @@ describe('import table row', () => {
});
});
+ it('renders only no parent option if available namespaces list is empty', () => {
+ createComponent({
+ group: getFakeGroup(STATUSES.NONE),
+ availableNamespaces: [],
+ });
+
+ const items = findNamespaceDropdown()
+ .findAllComponents(GlDropdownItem)
+ .wrappers.map((w) => w.text());
+
+ expect(items[0]).toBe('No parent');
+ expect(items).toHaveLength(1);
+ });
+
+ it('renders both no parent option and available namespaces list when available namespaces list is not empty', () => {
+ createComponent({
+ group: getFakeGroup(STATUSES.NONE),
+ availableNamespaces: availableNamespacesFixture,
+ });
+
+ const [firstItem, ...rest] = findNamespaceDropdown()
+ .findAllComponents(GlDropdownItem)
+ .wrappers.map((w) => w.text());
+
+ expect(firstItem).toBe('No parent');
+ expect(rest).toHaveLength(availableNamespacesFixture.length);
+ });
+
describe('when entity status is SCHEDULING', () => {
beforeEach(() => {
group = getFakeGroup(STATUSES.SCHEDULING);
@@ -109,4 +170,38 @@ describe('import table row', () => {
expect(findByText(GlLink, TARGET_LINK).exists()).toBe(true);
});
});
+
+ describe('validations', () => {
+ it('Reports invalid group name when name is not matching regex', () => {
+ createComponent({
+ group: {
+ ...getFakeGroup(STATUSES.NONE),
+ import_target: {
+ target_namespace: 'root',
+ new_name: 'very`bad`name',
+ },
+ },
+ groupPathRegex: /^[a-zA-Z]+$/,
+ });
+
+ expect(wrapper.text()).toContain('Please choose a group URL with no special characters.');
+ });
+
+ it('Reports invalid group name if group already exists', async () => {
+ createComponent({
+ group: {
+ ...getFakeGroup(STATUSES.NONE),
+ import_target: {
+ target_namespace: EXISTING_GROUP_TARGET_NAMESPACE,
+ new_name: EXISTING_GROUP_PATH,
+ },
+ },
+ });
+
+ jest.runOnlyPendingTimers();
+ await nextTick();
+
+ expect(wrapper.text()).toContain('Name already exists.');
+ });
+ });
});
diff --git a/spec/frontend/import_entities/import_groups/components/import_table_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_spec.js
index dd734782169..496c5cda7c7 100644
--- a/spec/frontend/import_entities/import_groups/components/import_table_spec.js
+++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js
@@ -1,7 +1,15 @@
-import { GlEmptyState, GlLoadingIcon, GlSearchBoxByClick, GlSprintf } from '@gitlab/ui';
+import {
+ GlEmptyState,
+ GlLoadingIcon,
+ GlSearchBoxByClick,
+ GlSprintf,
+ GlDropdown,
+ GlDropdownItem,
+} from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
+import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import { STATUSES } from '~/import_entities/constants';
import ImportTable from '~/import_entities/import_groups/components/import_table.vue';
@@ -16,13 +24,25 @@ import { availableNamespacesFixture, generateFakeEntry } from '../graphql/fixtur
const localVue = createLocalVue();
localVue.use(VueApollo);
+const GlDropdownStub = stubComponent(GlDropdown, {
+ template: '<div><h1 ref="text"><slot name="button-content"></slot></h1><slot></slot></div>',
+});
+
describe('import table', () => {
let wrapper;
let apolloProvider;
+ const SOURCE_URL = 'https://demo.host';
const FAKE_GROUP = generateFakeEntry({ id: 1, status: STATUSES.NONE });
+ const FAKE_GROUPS = [
+ generateFakeEntry({ id: 1, status: STATUSES.NONE }),
+ generateFakeEntry({ id: 2, status: STATUSES.FINISHED }),
+ ];
const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 };
+ const findPaginationDropdown = () => wrapper.findComponent(GlDropdown);
+ const findPaginationDropdownText = () => findPaginationDropdown().find({ ref: 'text' }).text();
+
const createComponent = ({ bulkImportSourceGroups }) => {
apolloProvider = createMockApollo([], {
Query: {
@@ -38,10 +58,12 @@ describe('import table', () => {
wrapper = shallowMount(ImportTable, {
propsData: {
- sourceUrl: 'https://demo.host',
+ groupPathRegex: /.*/,
+ sourceUrl: SOURCE_URL,
},
stubs: {
GlSprintf,
+ GlDropdown: GlDropdownStub,
},
localVue,
apolloProvider,
@@ -80,14 +102,10 @@ describe('import table', () => {
});
await waitForPromises();
- expect(wrapper.find(GlEmptyState).props().title).toBe('No groups available for import');
+ expect(wrapper.find(GlEmptyState).props().title).toBe('You have no groups to import');
});
it('renders import row for each group in response', async () => {
- const FAKE_GROUPS = [
- generateFakeEntry({ id: 1, status: STATUSES.NONE }),
- generateFakeEntry({ id: 2, status: STATUSES.FINISHED }),
- ];
createComponent({
bulkImportSourceGroups: () => ({
nodes: FAKE_GROUPS,
@@ -151,6 +169,20 @@ describe('import table', () => {
expect(wrapper.find(PaginationLinks).props().pageInfo).toStrictEqual(FAKE_PAGE_INFO);
});
+ it('renders pagination dropdown', () => {
+ expect(findPaginationDropdown().exists()).toBe(true);
+ });
+
+ it('updates page size when selected in Dropdown', async () => {
+ const otherOption = wrapper.findAllComponents(GlDropdownItem).at(1);
+ expect(otherOption.text()).toMatchInterpolatedText('50 items per page');
+
+ otherOption.vm.$emit('click');
+ await waitForPromises();
+
+ expect(findPaginationDropdownText()).toMatchInterpolatedText('50 items per page');
+ });
+
it('updates page when page change is requested', async () => {
const REQUESTED_PAGE = 2;
wrapper.find(PaginationLinks).props().change(REQUESTED_PAGE);
@@ -178,7 +210,7 @@ describe('import table', () => {
wrapper.find(PaginationLinks).props().change(REQUESTED_PAGE);
await waitForPromises();
- expect(wrapper.text()).toContain('Showing 21-21 of 38');
+ expect(wrapper.text()).toContain('Showing 21-21 of 38 groups from');
});
});
@@ -224,7 +256,7 @@ describe('import table', () => {
findFilterInput().vm.$emit('submit', FILTER_VALUE);
await waitForPromises();
- expect(wrapper.text()).toContain('Showing 1-1 of 40 groups matching filter "foo"');
+ expect(wrapper.text()).toContain('Showing 1-1 of 40 groups matching filter "foo" from');
});
it('properly resets filter in graphql query when search box is cleared', async () => {
diff --git a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js
index 4d3d2c41bbe..1feff861c1e 100644
--- a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js
+++ b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js
@@ -2,6 +2,7 @@ import { InMemoryCache } from 'apollo-cache-inmemory';
import MockAdapter from 'axios-mock-adapter';
import { createMockClient } from 'mock-apollo-client';
import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
import { STATUSES } from '~/import_entities/constants';
import {
clientTypenames,
@@ -18,6 +19,7 @@ import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import { statusEndpointFixture, availableNamespacesFixture } from './fixtures';
+jest.mock('~/flash');
jest.mock('~/import_entities/import_groups/graphql/services/status_poller', () => ({
StatusPoller: jest.fn().mockImplementation(function mock() {
this.startPolling = jest.fn();
@@ -35,15 +37,19 @@ describe('Bulk import resolvers', () => {
let axiosMockAdapter;
let client;
- beforeEach(() => {
- axiosMockAdapter = new MockAdapter(axios);
- client = createMockClient({
+ const createClient = (extraResolverArgs) => {
+ return createMockClient({
cache: new InMemoryCache({
fragmentMatcher: { match: () => true },
addTypename: false,
}),
- resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS }),
+ resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS, ...extraResolverArgs }),
});
+ };
+
+ beforeEach(() => {
+ axiosMockAdapter = new MockAdapter(axios);
+ client = createClient();
});
afterEach(() => {
@@ -82,6 +88,44 @@ describe('Bulk import resolvers', () => {
.reply(httpStatus.OK, availableNamespacesFixture);
});
+ it('respects cached import state when provided by group manager', async () => {
+ const FAKE_STATUS = 'DEMO_STATUS';
+ const FAKE_IMPORT_TARGET = {};
+ const TARGET_INDEX = 0;
+
+ const clientWithMockedManager = createClient({
+ GroupsManager: jest.fn().mockImplementation(() => ({
+ getImportStateFromStorageByGroupId(groupId) {
+ if (groupId === statusEndpointFixture.importable_data[TARGET_INDEX].id) {
+ return {
+ status: FAKE_STATUS,
+ importTarget: FAKE_IMPORT_TARGET,
+ };
+ }
+
+ return null;
+ },
+ })),
+ });
+
+ const clientResponse = await clientWithMockedManager.query({
+ query: bulkImportSourceGroupsQuery,
+ });
+ const clientResults = clientResponse.data.bulkImportSourceGroups.nodes;
+
+ expect(clientResults[TARGET_INDEX].import_target).toBe(FAKE_IMPORT_TARGET);
+ expect(clientResults[TARGET_INDEX].status).toBe(FAKE_STATUS);
+ });
+
+ it('populates each result instance with empty import_target when there are no available namespaces', async () => {
+ axiosMockAdapter.onGet(FAKE_ENDPOINTS.availableNamespaces).reply(httpStatus.OK, []);
+
+ const response = await client.query({ query: bulkImportSourceGroupsQuery });
+ results = response.data.bulkImportSourceGroups.nodes;
+
+ expect(results.every((r) => r.import_target.target_namespace === '')).toBe(true);
+ });
+
describe('when called', () => {
beforeEach(async () => {
const response = await client.query({ query: bulkImportSourceGroupsQuery });
@@ -220,14 +264,14 @@ describe('Bulk import resolvers', () => {
expect(intermediateResults[0].status).toBe(STATUSES.SCHEDULING);
});
- it('sets group status to STARTED when request completes', async () => {
+ it('sets import status to CREATED when request completes', async () => {
axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 });
await client.mutate({
mutation: importGroupMutation,
variables: { sourceGroupId: GROUP_ID },
});
- expect(results[0].status).toBe(STATUSES.STARTED);
+ expect(results[0].status).toBe(STATUSES.CREATED);
});
it('resets status to NONE if request fails', async () => {
@@ -245,6 +289,40 @@ describe('Bulk import resolvers', () => {
expect(results[0].status).toBe(STATUSES.NONE);
});
+
+ it('shows default error message when server error is not provided', async () => {
+ axiosMockAdapter
+ .onPost(FAKE_ENDPOINTS.createBulkImport)
+ .reply(httpStatus.INTERNAL_SERVER_ERROR);
+
+ client
+ .mutate({
+ mutation: importGroupMutation,
+ variables: { sourceGroupId: GROUP_ID },
+ })
+ .catch(() => {});
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith({ message: 'Importing the group failed' });
+ });
+
+ it('shows provided error message when error is included in backend response', async () => {
+ const CUSTOM_MESSAGE = 'custom message';
+
+ axiosMockAdapter
+ .onPost(FAKE_ENDPOINTS.createBulkImport)
+ .reply(httpStatus.INTERNAL_SERVER_ERROR, { error: CUSTOM_MESSAGE });
+
+ client
+ .mutate({
+ mutation: importGroupMutation,
+ variables: { sourceGroupId: GROUP_ID },
+ })
+ .catch(() => {});
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith({ message: CUSTOM_MESSAGE });
+ });
});
});
});
diff --git a/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js b/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js
index ca987ab3ab4..5baa201906a 100644
--- a/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js
+++ b/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js
@@ -1,11 +1,17 @@
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory';
import ImportSourceGroupFragment from '~/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql';
-import { SourceGroupsManager } from '~/import_entities/import_groups/graphql/services/source_groups_manager';
+import {
+ KEY,
+ SourceGroupsManager,
+} from '~/import_entities/import_groups/graphql/services/source_groups_manager';
+
+const FAKE_SOURCE_URL = 'http://demo.host';
describe('SourceGroupsManager', () => {
let manager;
let client;
+ let storage;
const getFakeGroup = () => ({
__typename: clientTypenames.BulkImportSourceGroup,
@@ -17,8 +23,53 @@ describe('SourceGroupsManager', () => {
readFragment: jest.fn(),
writeFragment: jest.fn(),
};
+ storage = {
+ getItem: jest.fn(),
+ setItem: jest.fn(),
+ };
+
+ manager = new SourceGroupsManager({ client, storage, sourceUrl: FAKE_SOURCE_URL });
+ });
+
+ describe('storage management', () => {
+ const IMPORT_ID = 1;
+ const IMPORT_TARGET = { destination_name: 'demo', destination_namespace: 'foo' };
+ const STATUS = 'FAKE_STATUS';
+ const FAKE_GROUP = { id: 1, import_target: IMPORT_TARGET, status: STATUS };
+
+ it('loads state from storage on creation', () => {
+ expect(storage.getItem).toHaveBeenCalledWith(KEY);
+ });
+
+ it('saves to storage when import is starting', () => {
+ manager.startImport({
+ importId: IMPORT_ID,
+ group: FAKE_GROUP,
+ });
+ const storedObject = JSON.parse(storage.setItem.mock.calls[0][1]);
+ expect(Object.values(storedObject)[0]).toStrictEqual({
+ id: FAKE_GROUP.id,
+ importTarget: IMPORT_TARGET,
+ status: STATUS,
+ });
+ });
- manager = new SourceGroupsManager({ client });
+ it('saves to storage when import status is updated', () => {
+ const CHANGED_STATUS = 'changed';
+
+ manager.startImport({
+ importId: IMPORT_ID,
+ group: FAKE_GROUP,
+ });
+
+ manager.setImportStatusByImportId(IMPORT_ID, CHANGED_STATUS);
+ const storedObject = JSON.parse(storage.setItem.mock.calls[1][1]);
+ expect(Object.values(storedObject)[0]).toStrictEqual({
+ id: FAKE_GROUP.id,
+ importTarget: IMPORT_TARGET,
+ status: CHANGED_STATUS,
+ });
+ });
});
it('finds item by group id', () => {
diff --git a/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js b/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js
index a5fc4e18a02..0d4809971ae 100644
--- a/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js
+++ b/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js
@@ -2,7 +2,6 @@ import MockAdapter from 'axios-mock-adapter';
import Visibility from 'visibilityjs';
import createFlash from '~/flash';
import { STATUSES } from '~/import_entities/constants';
-import { SourceGroupsManager } from '~/import_entities/import_groups/graphql/services/source_groups_manager';
import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller';
import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll';
@@ -18,24 +17,21 @@ jest.mock('~/import_entities/import_groups/graphql/services/source_groups_manage
}));
const FAKE_POLL_PATH = '/fake/poll/path';
-const CLIENT_MOCK = {};
describe('Bulk import status poller', () => {
let poller;
let mockAdapter;
+ let groupManager;
const getPollHistory = () => mockAdapter.history.get.filter((x) => x.url === FAKE_POLL_PATH);
beforeEach(() => {
mockAdapter = new MockAdapter(axios);
mockAdapter.onGet(FAKE_POLL_PATH).reply(200, {});
- poller = new StatusPoller({ client: CLIENT_MOCK, pollPath: FAKE_POLL_PATH });
- });
-
- it('creates source group manager with proper client', () => {
- expect(SourceGroupsManager.mock.calls).toHaveLength(1);
- const [[{ client }]] = SourceGroupsManager.mock.calls;
- expect(client).toBe(CLIENT_MOCK);
+ groupManager = {
+ setImportStatusByImportId: jest.fn(),
+ };
+ poller = new StatusPoller({ groupManager, pollPath: FAKE_POLL_PATH });
});
it('creates poller with proper config', () => {
@@ -100,14 +96,9 @@ describe('Bulk import status poller', () => {
it('when success response arrives updates relevant group status', () => {
const FAKE_ID = 5;
const [[pollConfig]] = Poll.mock.calls;
- const [managerInstance] = SourceGroupsManager.mock.instances;
- managerInstance.findByImportId.mockReturnValue({ id: FAKE_ID });
pollConfig.successCallback({ data: [{ id: FAKE_ID, status_name: STATUSES.FINISHED }] });
- expect(managerInstance.setImportStatus).toHaveBeenCalledWith(
- expect.objectContaining({ id: FAKE_ID }),
- STATUSES.FINISHED,
- );
+ expect(groupManager.setImportStatusByImportId).toHaveBeenCalledWith(FAKE_ID, STATUSES.FINISHED);
});
});
diff --git a/spec/frontend/incidents/mocks/incidents.json b/spec/frontend/incidents/mocks/incidents.json
index 07c87a5d43d..78783a0dce5 100644
--- a/spec/frontend/incidents/mocks/incidents.json
+++ b/spec/frontend/incidents/mocks/incidents.json
@@ -1,7 +1,7 @@
[
{
"iid": "15",
- "title": "New: Incident",
+ "title": "New: Alert",
"createdAt": "2020-06-03T15:46:08Z",
"assignees": {},
"state": "opened",
diff --git a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap
index 82d7f691efd..5796b3fa44e 100644
--- a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap
+++ b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap
@@ -35,7 +35,7 @@ exports[`Alert integration settings form default state should match the default
Incident template (optional)
<gl-link-stub
- href="/help/user/project/description_templates#creating-issue-templates"
+ href="/help/user/project/description_templates#create-an-issue-template"
target="_blank"
>
<gl-icon-stub
@@ -78,7 +78,7 @@ exports[`Alert integration settings form default state should match the default
>
<gl-form-checkbox-stub>
<span>
- Send a separate email notification to Developers.
+ Send a single email notification to Owners and Maintainers for new alerts.
</span>
</gl-form-checkbox-stub>
</gl-form-group-stub>
diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js
index df855674804..c015fd0b9e0 100644
--- a/spec/frontend/integrations/edit/components/integration_form_spec.js
+++ b/spec/frontend/integrations/edit/components/integration_form_spec.js
@@ -207,27 +207,6 @@ describe('IntegrationForm', () => {
expect(findJiraTriggerFields().exists()).toBe(true);
});
-
- describe('featureFlag jiraIssuesIntegration is false', () => {
- it('does not render JiraIssuesFields', () => {
- createComponent({
- customStateProps: { type: 'jira' },
- featureFlags: { jiraIssuesIntegration: false },
- });
-
- expect(findJiraIssuesFields().exists()).toBe(false);
- });
- });
-
- describe('featureFlag jiraIssuesIntegration is true', () => {
- it('renders JiraIssuesFields', () => {
- createComponent({
- customStateProps: { type: 'jira' },
- featureFlags: { jiraIssuesIntegration: true },
- });
- expect(findJiraIssuesFields().exists()).toBe(true);
- });
- });
});
describe('triggerEvents is present', () => {
diff --git a/spec/frontend/integrations/integration_settings_form_spec.js b/spec/frontend/integrations/integration_settings_form_spec.js
index 348b942703f..cbb2ef380ba 100644
--- a/spec/frontend/integrations/integration_settings_form_spec.js
+++ b/spec/frontend/integrations/integration_settings_form_spec.js
@@ -7,7 +7,6 @@ jest.mock('~/vue_shared/plugins/global_toast');
describe('IntegrationSettingsForm', () => {
const FIXTURE = 'services/edit_service.html';
- preloadFixtures(FIXTURE);
beforeEach(() => {
loadFixtures(FIXTURE);
diff --git a/spec/frontend/invite_members/components/group_select_spec.js b/spec/frontend/invite_members/components/group_select_spec.js
new file mode 100644
index 00000000000..2a6985de136
--- /dev/null
+++ b/spec/frontend/invite_members/components/group_select_spec.js
@@ -0,0 +1,90 @@
+import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import Api from '~/api';
+import GroupSelect from '~/invite_members/components/group_select.vue';
+
+const createComponent = () => {
+ return mount(GroupSelect, {});
+};
+
+const group1 = { id: 1, full_name: 'Group One' };
+const group2 = { id: 2, full_name: 'Group Two' };
+const allGroups = [group1, group2];
+
+describe('GroupSelect', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ jest.spyOn(Api, 'groups').mockResolvedValue(allGroups);
+
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType);
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdownToggle = () => findDropdown().find('button[aria-haspopup="true"]');
+ const findDropdownItemByText = (text) =>
+ wrapper
+ .findAllComponents(GlDropdownItem)
+ .wrappers.find((dropdownItemWrapper) => dropdownItemWrapper.text() === text);
+
+ it('renders GlSearchBoxByType with default attributes', () => {
+ expect(findSearchBoxByType().exists()).toBe(true);
+ expect(findSearchBoxByType().vm.$attrs).toMatchObject({
+ placeholder: 'Search groups',
+ });
+ });
+
+ describe('when user types in the search input', () => {
+ let resolveApiRequest;
+
+ beforeEach(() => {
+ jest.spyOn(Api, 'groups').mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveApiRequest = resolve;
+ }),
+ );
+
+ findSearchBoxByType().vm.$emit('input', group1.name);
+ });
+
+ it('calls the API', () => {
+ resolveApiRequest({ data: allGroups });
+
+ expect(Api.groups).toHaveBeenCalledWith(group1.name, {
+ active: true,
+ exclude_internal: true,
+ });
+ });
+
+ it('displays loading icon while waiting for API call to resolve', async () => {
+ expect(findSearchBoxByType().props('isLoading')).toBe(true);
+
+ resolveApiRequest({ data: allGroups });
+ await waitForPromises();
+
+ expect(findSearchBoxByType().props('isLoading')).toBe(false);
+ });
+ });
+
+ describe('when group is selected from the dropdown', () => {
+ beforeEach(() => {
+ findDropdownItemByText(group1.full_name).vm.$emit('click');
+ });
+
+ it('emits `input` event used by `v-model`', () => {
+ expect(wrapper.emitted('input')[0][0].id).toEqual(group1.id);
+ });
+
+ it('sets dropdown toggle text to selected item', () => {
+ expect(findDropdownToggle().text()).toBe(group1.full_name);
+ });
+ });
+});
diff --git a/spec/frontend/invite_members/components/invite_group_trigger_spec.js b/spec/frontend/invite_members/components/invite_group_trigger_spec.js
new file mode 100644
index 00000000000..cb9967ebe8c
--- /dev/null
+++ b/spec/frontend/invite_members/components/invite_group_trigger_spec.js
@@ -0,0 +1,50 @@
+import { GlButton } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import InviteGroupTrigger from '~/invite_members/components/invite_group_trigger.vue';
+import eventHub from '~/invite_members/event_hub';
+
+const displayText = 'Invite a group';
+
+const createComponent = (props = {}) => {
+ return mount(InviteGroupTrigger, {
+ propsData: {
+ displayText,
+ ...props,
+ },
+ });
+};
+
+describe('InviteGroupTrigger', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ describe('displayText', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('includes the correct displayText for the link', () => {
+ expect(findButton().text()).toBe(displayText);
+ });
+ });
+
+ describe('when button is clicked', () => {
+ beforeEach(() => {
+ eventHub.$emit = jest.fn();
+
+ wrapper = createComponent();
+
+ findButton().trigger('click');
+ });
+
+ it('emits event that triggers opening the modal', () => {
+ expect(eventHub.$emit).toHaveBeenLastCalledWith('openModal', { inviteeType: 'group' });
+ });
+ });
+});
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 e310a00133c..5ca5d855038 100644
--- a/spec/frontend/invite_members/components/invite_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -6,10 +6,11 @@ import Api from '~/api';
import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
const id = '1';
-const name = 'testgroup';
+const name = 'test name';
const isProject = false;
+const inviteeType = 'members';
const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 };
-const defaultAccessLevel = '10';
+const defaultAccessLevel = 10;
const helpLink = 'https://example.com';
const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' };
@@ -20,16 +21,19 @@ const user3 = {
username: 'one_2',
avatar_url: '',
};
+const sharedGroup = { id: '981' };
-const createComponent = (data = {}) => {
+const createComponent = (data = {}, props = {}) => {
return shallowMount(InviteMembersModal, {
propsData: {
id,
name,
isProject,
+ inviteeType,
accessLevels,
defaultAccessLevel,
helpLink,
+ ...props,
},
data() {
return data;
@@ -46,6 +50,22 @@ const createComponent = (data = {}) => {
});
};
+const createInviteMembersToProjectWrapper = () => {
+ return createComponent({ inviteeType: 'members' }, { isProject: true });
+};
+
+const createInviteMembersToGroupWrapper = () => {
+ return createComponent({ inviteeType: 'members' }, { isProject: false });
+};
+
+const createInviteGroupToProjectWrapper = () => {
+ return createComponent({ inviteeType: 'group' }, { isProject: true });
+};
+
+const createInviteGroupToGroupWrapper = () => {
+ return createComponent({ inviteeType: 'group' }, { isProject: false });
+};
+
describe('InviteMembersModal', () => {
let wrapper;
@@ -54,12 +74,13 @@ describe('InviteMembersModal', () => {
wrapper = null;
});
- const findDropdown = () => wrapper.find(GlDropdown);
- const findDropdownItems = () => findDropdown().findAll(GlDropdownItem);
- const findDatepicker = () => wrapper.find(GlDatepicker);
- const findLink = () => wrapper.find(GlLink);
- const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
- const findInviteButton = () => wrapper.find({ ref: 'inviteButton' });
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem);
+ const findDatepicker = () => wrapper.findComponent(GlDatepicker);
+ const findLink = () => wrapper.findComponent(GlLink);
+ const findIntroText = () => wrapper.find({ ref: 'introText' }).text();
+ const findCancelButton = () => wrapper.findComponent({ ref: 'cancelButton' });
+ const findInviteButton = () => wrapper.findComponent({ ref: 'inviteButton' });
const clickInviteButton = () => findInviteButton().vm.$emit('click');
describe('rendering the modal', () => {
@@ -68,7 +89,7 @@ describe('InviteMembersModal', () => {
});
it('renders the modal with the correct title', () => {
- expect(wrapper.find(GlModal).props('title')).toBe('Invite team members');
+ expect(wrapper.findComponent(GlModal).props('title')).toBe('Invite team members');
});
it('renders the Cancel button text correctly', () => {
@@ -102,21 +123,60 @@ describe('InviteMembersModal', () => {
});
});
+ describe('displaying the correct introText', () => {
+ describe('when inviting to a project', () => {
+ describe('when inviting members', () => {
+ it('includes the correct invitee, type, and formatted name', () => {
+ wrapper = createInviteMembersToProjectWrapper();
+
+ expect(findIntroText()).toBe("You're inviting members to the test name project.");
+ });
+ });
+
+ describe('when sharing with a group', () => {
+ it('includes the correct invitee, type, and formatted name', () => {
+ wrapper = createInviteGroupToProjectWrapper();
+
+ expect(findIntroText()).toBe("You're inviting a group to the test name project.");
+ });
+ });
+ });
+
+ describe('when inviting to a group', () => {
+ describe('when inviting members', () => {
+ it('includes the correct invitee, type, and formatted name', () => {
+ wrapper = createInviteMembersToGroupWrapper();
+
+ expect(findIntroText()).toBe("You're inviting members to the test name group.");
+ });
+ });
+
+ describe('when sharing with a group', () => {
+ it('includes the correct invitee, type, and formatted name', () => {
+ wrapper = createInviteGroupToGroupWrapper();
+
+ expect(findIntroText()).toBe("You're inviting a group to the test name group.");
+ });
+ });
+ });
+ });
+
describe('submitting the invite form', () => {
const apiErrorMessage = 'Member already exists';
describe('when inviting an existing user to group by user ID', () => {
const postData = {
user_id: '1',
- access_level: '10',
+ access_level: defaultAccessLevel,
expires_at: undefined,
format: 'json',
};
describe('when invites are sent successfully', () => {
beforeEach(() => {
- wrapper = createComponent({ newUsersToInvite: [user1] });
+ wrapper = createInviteMembersToGroupWrapper();
+ wrapper.setData({ newUsersToInvite: [user1] });
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
@@ -178,7 +238,7 @@ describe('InviteMembersModal', () => {
describe('when inviting a new user by email address', () => {
const postData = {
- access_level: '10',
+ access_level: defaultAccessLevel,
expires_at: undefined,
email: 'email@example.com',
format: 'json',
@@ -227,7 +287,7 @@ describe('InviteMembersModal', () => {
describe('when inviting members and non-members in same click', () => {
const postData = {
- access_level: '10',
+ access_level: defaultAccessLevel,
expires_at: undefined,
format: 'json',
};
@@ -283,5 +343,58 @@ describe('InviteMembersModal', () => {
});
});
});
+
+ describe('when inviting a group to share', () => {
+ describe('when sharing the group is successful', () => {
+ const groupPostData = {
+ group_id: sharedGroup.id,
+ group_access: defaultAccessLevel,
+ expires_at: undefined,
+ format: 'json',
+ };
+
+ beforeEach(() => {
+ wrapper = createComponent({ groupToBeSharedWith: sharedGroup });
+
+ wrapper.setData({ inviteeType: 'group' });
+ wrapper.vm.$toast = { show: jest.fn() };
+ jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData });
+ jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
+
+ clickInviteButton();
+ });
+
+ it('calls Api groupShareWithGroup with the correct params', () => {
+ expect(Api.groupShareWithGroup).toHaveBeenCalledWith(id, groupPostData);
+ });
+
+ it('displays the successful toastMessage', () => {
+ expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
+ });
+ });
+
+ describe('when sharing the group fails', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ groupToBeSharedWith: sharedGroup });
+
+ wrapper.setData({ inviteeType: 'group' });
+ wrapper.vm.$toast = { show: jest.fn() };
+
+ jest
+ .spyOn(Api, 'groupShareWithGroup')
+ .mockRejectedValue({ response: { data: { success: false } } });
+
+ jest.spyOn(wrapper.vm, 'showToastMessageError');
+
+ clickInviteButton();
+ });
+
+ it('displays the generic error toastMessage', async () => {
+ await waitForPromises();
+
+ expect(wrapper.vm.showToastMessageError).toHaveBeenCalled();
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js
index 18d6662d2d4..f362aace1df 100644
--- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js
@@ -1,9 +1,8 @@
-import { GlIcon, GlLink } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
const displayText = 'Invite team members';
-const icon = 'plus';
const createComponent = (props = {}) => {
return shallowMount(InviteMembersTrigger, {
@@ -23,36 +22,14 @@ describe('InviteMembersTrigger', () => {
});
describe('displayText', () => {
- const findLink = () => wrapper.find(GlLink);
+ const findButton = () => wrapper.findComponent(GlButton);
beforeEach(() => {
wrapper = createComponent();
});
- it('includes the correct displayText for the link', () => {
- expect(findLink().text()).toBe(displayText);
- });
- });
-
- describe('icon', () => {
- const findIcon = () => wrapper.find(GlIcon);
-
- it('includes the correct icon when an icon is sent', () => {
- wrapper = createComponent({ icon });
-
- expect(findIcon().attributes('name')).toBe(icon);
- });
-
- it('does not include an icon when icon is not sent', () => {
- wrapper = createComponent();
-
- expect(findIcon().exists()).toBe(false);
- });
-
- it('does not include an icon when empty string is sent', () => {
- wrapper = createComponent({ icon: '' });
-
- expect(findIcon().exists()).toBe(false);
+ it('includes the correct displayText for the button', () => {
+ expect(findButton().text()).toBe(displayText);
});
});
});
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 a945b99bd54..f6e79d3607f 100644
--- a/spec/frontend/invite_members/components/members_token_select_spec.js
+++ b/spec/frontend/invite_members/components/members_token_select_spec.js
@@ -37,7 +37,7 @@ describe('MembersTokenSelect', () => {
wrapper = null;
});
- const findTokenSelector = () => wrapper.find(GlTokenSelector);
+ const findTokenSelector = () => wrapper.findComponent(GlTokenSelector);
describe('rendering the token-selector component', () => {
it('renders with the correct props', () => {
diff --git a/spec/frontend/issuable/components/csv_export_modal_spec.js b/spec/frontend/issuable/components/csv_export_modal_spec.js
new file mode 100644
index 00000000000..f46b6f72f05
--- /dev/null
+++ b/spec/frontend/issuable/components/csv_export_modal_spec.js
@@ -0,0 +1,91 @@
+import { GlModal, GlIcon, GlButton } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import { stubComponent } from 'helpers/stub_component';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import CsvExportModal from '~/issuable/components/csv_export_modal.vue';
+
+describe('CsvExportModal', () => {
+ let wrapper;
+
+ function createComponent(options = {}) {
+ const { injectedProperties = {}, props = {} } = options;
+ return extendedWrapper(
+ mount(CsvExportModal, {
+ propsData: {
+ modalId: 'csv-export-modal',
+ ...props,
+ },
+ provide: {
+ issuableType: 'issues',
+ ...injectedProperties,
+ },
+ stubs: {
+ GlModal: stubComponent(GlModal, {
+ template:
+ '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>',
+ }),
+ },
+ }),
+ );
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findIcon = () => wrapper.findComponent(GlIcon);
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ describe('template', () => {
+ describe.each`
+ issuableType | modalTitle
+ ${'issues'} | ${'Export issues'}
+ ${'merge-requests'} | ${'Export merge requests'}
+ `('with the issuableType "$issuableType"', ({ issuableType, modalTitle }) => {
+ beforeEach(() => {
+ wrapper = createComponent({ injectedProperties: { issuableType } });
+ });
+
+ it('displays the modal title "$modalTitle"', () => {
+ expect(findModal().text()).toContain(modalTitle);
+ });
+
+ it('displays the button with title "$modalTitle"', () => {
+ expect(findButton().text()).toBe(modalTitle);
+ });
+ });
+
+ describe('issuable count info text', () => {
+ it('displays the info text when issuableCount is > -1', () => {
+ wrapper = createComponent({ injectedProperties: { issuableCount: 10 } });
+ expect(wrapper.findByTestId('issuable-count-note').exists()).toBe(true);
+ expect(wrapper.findByTestId('issuable-count-note').text()).toContain('10 issues selected');
+ expect(findIcon().exists()).toBe(true);
+ });
+
+ it("doesn't display the info text when issuableCount is -1", () => {
+ wrapper = createComponent({ injectedProperties: { issuableCount: -1 } });
+ expect(wrapper.findByTestId('issuable-count-note').exists()).toBe(false);
+ });
+ });
+
+ describe('email info text', () => {
+ it('displays the proper email', () => {
+ const email = 'admin@example.com';
+ wrapper = createComponent({ injectedProperties: { email } });
+ expect(findModal().text()).toContain(
+ `The CSV export will be created in the background. Once finished, it will be sent to ${email} in an attachment.`,
+ );
+ });
+ });
+
+ describe('primary button', () => {
+ it('passes the exportCsvPath to the button', () => {
+ const exportCsvPath = '/gitlab-org/gitlab-test/-/issues/export_csv';
+ wrapper = createComponent({ injectedProperties: { exportCsvPath } });
+ expect(findButton().attributes('href')).toBe(exportCsvPath);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issuable/components/csv_import_export_buttons_spec.js b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js
new file mode 100644
index 00000000000..e32bf35b13a
--- /dev/null
+++ b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js
@@ -0,0 +1,187 @@
+import { shallowMount } from '@vue/test-utils';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import CsvExportModal from '~/issuable/components/csv_export_modal.vue';
+import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
+import CsvImportModal from '~/issuable/components/csv_import_modal.vue';
+
+describe('CsvImportExportButtons', () => {
+ let wrapper;
+ let glModalDirective;
+
+ function createComponent(injectedProperties = {}) {
+ glModalDirective = jest.fn();
+ return extendedWrapper(
+ shallowMount(CsvImportExportButtons, {
+ directives: {
+ GlTooltip: createMockDirective(),
+ glModal: {
+ bind(_, { value }) {
+ glModalDirective(value);
+ },
+ },
+ },
+ provide: {
+ ...injectedProperties,
+ },
+ }),
+ );
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findExportCsvButton = () => wrapper.findByTestId('export-csv-button');
+ const findImportDropdown = () => wrapper.findByTestId('import-csv-dropdown');
+ const findImportCsvButton = () => wrapper.findByTestId('import-csv-dropdown');
+ const findImportFromJiraLink = () => wrapper.findByTestId('import-from-jira-link');
+ const findExportCsvModal = () => wrapper.findComponent(CsvExportModal);
+ const findImportCsvModal = () => wrapper.findComponent(CsvImportModal);
+
+ describe('template', () => {
+ describe('when the showExportButton=true', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ showExportButton: true });
+ });
+
+ it('displays the export button', () => {
+ expect(findExportCsvButton().exists()).toBe(true);
+ });
+
+ it('export button has a tooltip', () => {
+ const tooltip = getBinding(findExportCsvButton().element, 'gl-tooltip');
+
+ expect(tooltip).toBeDefined();
+ expect(tooltip.value).toBe('Export as CSV');
+ });
+
+ it('renders the export modal', () => {
+ expect(findExportCsvModal().exists()).toBe(true);
+ });
+
+ it('opens the export modal', () => {
+ findExportCsvButton().trigger('click');
+
+ expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.exportModalId);
+ });
+ });
+
+ describe('when the showExportButton=false', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ showExportButton: false });
+ });
+
+ it('does not display the export button', () => {
+ expect(findExportCsvButton().exists()).toBe(false);
+ });
+
+ it('does not render the export modal', () => {
+ expect(findExportCsvModal().exists()).toBe(false);
+ });
+ });
+
+ describe('when the showImportButton=true', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ showImportButton: true });
+ });
+
+ it('displays the import dropdown', () => {
+ expect(findImportDropdown().exists()).toBe(true);
+ });
+
+ it('renders the import button', () => {
+ expect(findImportCsvButton().exists()).toBe(true);
+ });
+
+ describe('when showLabel=false', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ showImportButton: true, showLabel: false });
+ });
+
+ it('does not have a button text', () => {
+ expect(findImportCsvButton().props('text')).toBe(null);
+ });
+
+ it('import button has a tooltip', () => {
+ const tooltip = getBinding(findImportDropdown().element, 'gl-tooltip');
+
+ expect(tooltip).toBeDefined();
+ expect(tooltip.value).toBe('Import issues');
+ });
+ });
+
+ describe('when showLabel=true', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ showImportButton: true, showLabel: true });
+ });
+
+ it('displays a button text', () => {
+ expect(findImportCsvButton().props('text')).toBe('Import issues');
+ });
+
+ it('import button has no tooltip', () => {
+ const tooltip = getBinding(findImportDropdown().element, 'gl-tooltip');
+
+ expect(tooltip.value).toBe(null);
+ });
+ });
+
+ it('renders the import modal', () => {
+ expect(findImportCsvModal().exists()).toBe(true);
+ });
+
+ it('opens the import modal', () => {
+ findImportCsvButton().trigger('click');
+
+ expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.importModalId);
+ });
+
+ describe('import from jira link', () => {
+ const projectImportJiraPath = 'gitlab-org/gitlab-test/-/import/jira';
+
+ beforeEach(() => {
+ wrapper = createComponent({
+ showImportButton: true,
+ canEdit: true,
+ projectImportJiraPath,
+ });
+ });
+
+ describe('when canEdit=true', () => {
+ it('renders the import dropdown item', () => {
+ expect(findImportFromJiraLink().exists()).toBe(true);
+ });
+
+ it('passes the proper path to the link', () => {
+ expect(findImportFromJiraLink().attributes('href')).toBe(projectImportJiraPath);
+ });
+ });
+
+ describe('when canEdit=false', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ showImportButton: true, canEdit: false });
+ });
+
+ it('does not render the import dropdown item', () => {
+ expect(findImportFromJiraLink().exists()).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe('when the showImportButton=false', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ showImportButton: false });
+ });
+
+ it('does not display the import dropdown', () => {
+ expect(findImportDropdown().exists()).toBe(false);
+ });
+
+ it('does not render the import modal', () => {
+ expect(findImportCsvModal().exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issuable/components/csv_import_modal_spec.js b/spec/frontend/issuable/components/csv_import_modal_spec.js
new file mode 100644
index 00000000000..ce9d738f77b
--- /dev/null
+++ b/spec/frontend/issuable/components/csv_import_modal_spec.js
@@ -0,0 +1,86 @@
+import { GlModal } from '@gitlab/ui';
+import { getByRole, getByLabelText } from '@testing-library/dom';
+import { mount } from '@vue/test-utils';
+import { stubComponent } from 'helpers/stub_component';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import CsvImportModal from '~/issuable/components/csv_import_modal.vue';
+
+jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
+
+describe('CsvImportModal', () => {
+ let wrapper;
+ let formSubmitSpy;
+
+ function createComponent(options = {}) {
+ const { injectedProperties = {}, props = {} } = options;
+ return extendedWrapper(
+ mount(CsvImportModal, {
+ propsData: {
+ modalId: 'csv-import-modal',
+ ...props,
+ },
+ provide: {
+ issuableType: 'issues',
+ ...injectedProperties,
+ },
+ stubs: {
+ GlModal: stubComponent(GlModal, {
+ template: '<div><slot></slot><slot name="modal-footer"></slot></div>',
+ }),
+ },
+ }),
+ );
+ }
+
+ beforeEach(() => {
+ formSubmitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findPrimaryButton = () => getByRole(wrapper.element, 'button', { name: 'Import issues' });
+ const findForm = () => wrapper.findByTestId('import-csv-form');
+ const findFileInput = () => getByLabelText(wrapper.element, 'Upload CSV file');
+ const findAuthenticityToken = () => new FormData(findForm().element).get('authenticity_token');
+
+ describe('template', () => {
+ it('displays modal title', () => {
+ wrapper = createComponent();
+ expect(findModal().text()).toContain('Import issues');
+ });
+
+ it('displays a note about the maximum allowed file size', () => {
+ const maxAttachmentSize = 500;
+ wrapper = createComponent({ injectedProperties: { maxAttachmentSize } });
+ expect(findModal().text()).toContain(`The maximum file size allowed is ${maxAttachmentSize}`);
+ });
+
+ describe('form', () => {
+ const importCsvIssuesPath = 'gitlab-org/gitlab-test/-/issues/import_csv';
+
+ beforeEach(() => {
+ wrapper = createComponent({ injectedProperties: { importCsvIssuesPath } });
+ });
+
+ it('displays the form with the correct action and inputs', () => {
+ expect(findForm().exists()).toBe(true);
+ expect(findForm().attributes('action')).toBe(importCsvIssuesPath);
+ expect(findAuthenticityToken()).toBe('mock-csrf-token');
+ expect(findFileInput()).toExist();
+ });
+
+ it('displays the correct primary button action text', () => {
+ expect(findPrimaryButton()).toExist();
+ });
+
+ it('submits the form when the primary action is clicked', async () => {
+ findPrimaryButton().click();
+
+ expect(formSubmitSpy).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/issuable_list/components/issuable_item_spec.js
index 987acf559e3..7281d2fde1d 100644
--- a/spec/frontend/issuable_list/components/issuable_item_spec.js
+++ b/spec/frontend/issuable_list/components/issuable_item_spec.js
@@ -202,7 +202,7 @@ describe('IssuableItem', () => {
describe('labelTarget', () => {
it('returns target string for a provided label param when `enableLabelPermalinks` is true', () => {
expect(wrapper.vm.labelTarget(mockRegularLabel)).toBe(
- '?label_name%5B%5D=Documentation%20Update',
+ '?label_name[]=Documentation%20Update',
);
});
@@ -294,7 +294,17 @@ describe('IssuableItem', () => {
expect(confidentialEl.exists()).toBe(true);
expect(confidentialEl.props('name')).toBe('eye-slash');
- expect(confidentialEl.attributes('title')).toBe('Confidential');
+ expect(confidentialEl.attributes()).toMatchObject({
+ title: 'Confidential',
+ arialabel: 'Confidential',
+ });
+ });
+
+ it('renders task status', () => {
+ const taskStatus = wrapper.find('[data-testid="task-status"]');
+ const expected = `${mockIssuable.taskCompletionStatus.completedCount} of ${mockIssuable.taskCompletionStatus.count} tasks completed`;
+
+ expect(taskStatus.text()).toBe(expected);
});
it('renders issuable reference', () => {
diff --git a/spec/frontend/issuable_list/mock_data.js b/spec/frontend/issuable_list/mock_data.js
index e19a337473a..33ffd60bf95 100644
--- a/spec/frontend/issuable_list/mock_data.js
+++ b/spec/frontend/issuable_list/mock_data.js
@@ -53,6 +53,10 @@ export const mockIssuable = {
},
assignees: [mockAuthor],
userDiscussionsCount: 2,
+ taskCompletionStatus: {
+ count: 2,
+ completedCount: 1,
+ },
};
export const mockIssuables = [
diff --git a/spec/frontend/issuable_show/components/issuable_body_spec.js b/spec/frontend/issuable_show/components/issuable_body_spec.js
index bf166bea1e5..6fa298ca3f2 100644
--- a/spec/frontend/issuable_show/components/issuable_body_spec.js
+++ b/spec/frontend/issuable_show/components/issuable_body_spec.js
@@ -6,11 +6,13 @@ import IssuableBody from '~/issuable_show/components/issuable_body.vue';
import IssuableDescription from '~/issuable_show/components/issuable_description.vue';
import IssuableEditForm from '~/issuable_show/components/issuable_edit_form.vue';
import IssuableTitle from '~/issuable_show/components/issuable_title.vue';
+import TaskList from '~/task_list';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { mockIssuableShowProps, mockIssuable } from '../mock_data';
jest.mock('~/autosave');
+jest.mock('~/flash');
const issuableBodyProps = {
...mockIssuableShowProps,
@@ -80,6 +82,75 @@ describe('IssuableBody', () => {
});
});
+ describe('watchers', () => {
+ describe('editFormVisible', () => {
+ it('calls initTaskList in nextTick', async () => {
+ jest.spyOn(wrapper.vm, 'initTaskList');
+ wrapper.setProps({
+ editFormVisible: true,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ wrapper.setProps({
+ editFormVisible: false,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.initTaskList).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('mounted', () => {
+ it('initializes TaskList instance when enabledEdit and enableTaskList props are true', () => {
+ expect(wrapper.vm.taskList instanceof TaskList).toBe(true);
+ expect(wrapper.vm.taskList).toMatchObject({
+ dataType: 'issue',
+ fieldName: 'description',
+ lockVersion: issuableBodyProps.taskListLockVersion,
+ selector: '.js-detail-page-description',
+ onSuccess: expect.any(Function),
+ onError: expect.any(Function),
+ });
+ });
+
+ it('does not initialize TaskList instance when either enabledEdit or enableTaskList prop is false', () => {
+ const wrapperNoTaskList = createComponent({
+ ...issuableBodyProps,
+ enableTaskList: false,
+ });
+
+ expect(wrapperNoTaskList.vm.taskList).not.toBeDefined();
+
+ wrapperNoTaskList.destroy();
+ });
+ });
+
+ describe('methods', () => {
+ describe('handleTaskListUpdateSuccess', () => {
+ it('emits `task-list-update-success` event on component', () => {
+ const updatedIssuable = {
+ foo: 'bar',
+ };
+
+ wrapper.vm.handleTaskListUpdateSuccess(updatedIssuable);
+
+ expect(wrapper.emitted('task-list-update-success')).toBeTruthy();
+ expect(wrapper.emitted('task-list-update-success')[0]).toEqual([updatedIssuable]);
+ });
+ });
+
+ describe('handleTaskListUpdateFailure', () => {
+ it('emits `task-list-update-failure` event on component', () => {
+ wrapper.vm.handleTaskListUpdateFailure();
+
+ expect(wrapper.emitted('task-list-update-failure')).toBeTruthy();
+ });
+ });
+ });
+
describe('template', () => {
it('renders issuable-title component', () => {
const titleEl = wrapper.find(IssuableTitle);
diff --git a/spec/frontend/issuable_show/components/issuable_description_spec.js b/spec/frontend/issuable_show/components/issuable_description_spec.js
index 29ecce1002d..1058e5decfd 100644
--- a/spec/frontend/issuable_show/components/issuable_description_spec.js
+++ b/spec/frontend/issuable_show/components/issuable_description_spec.js
@@ -5,9 +5,14 @@ import IssuableDescription from '~/issuable_show/components/issuable_description
import { mockIssuable } from '../mock_data';
-const createComponent = (issuable = mockIssuable) =>
+const createComponent = ({
+ issuable = mockIssuable,
+ enableTaskList = true,
+ canEdit = true,
+ taskListUpdatePath = `${mockIssuable.webUrl}.json`,
+} = {}) =>
shallowMount(IssuableDescription, {
- propsData: { issuable },
+ propsData: { issuable, enableTaskList, canEdit, taskListUpdatePath },
});
describe('IssuableDescription', () => {
@@ -38,4 +43,27 @@ describe('IssuableDescription', () => {
});
});
});
+
+ describe('templates', () => {
+ it('renders container element with class `js-task-list-container` when canEdit and enableTaskList props are true', () => {
+ expect(wrapper.classes()).toContain('js-task-list-container');
+ });
+
+ it('renders container element without class `js-task-list-container` when canEdit and enableTaskList props are true', () => {
+ const wrapperNoTaskList = createComponent({
+ enableTaskList: false,
+ });
+
+ expect(wrapperNoTaskList.classes()).not.toContain('js-task-list-container');
+
+ wrapperNoTaskList.destroy();
+ });
+
+ it('renders hidden textarea element when issuable.description is present and enableTaskList prop is true', () => {
+ const textareaEl = wrapper.find('textarea.gl-display-none.js-task-list-field');
+
+ expect(textareaEl.exists()).toBe(true);
+ expect(textareaEl.attributes('data-update-url')).toBe(`${mockIssuable.webUrl}.json`);
+ });
+ });
});
diff --git a/spec/frontend/issuable_show/components/issuable_header_spec.js b/spec/frontend/issuable_show/components/issuable_header_spec.js
index 2164caa40a8..b85f2dd1999 100644
--- a/spec/frontend/issuable_show/components/issuable_header_spec.js
+++ b/spec/frontend/issuable_show/components/issuable_header_spec.js
@@ -119,6 +119,27 @@ describe('IssuableHeader', () => {
expect(avatarEl.find(GlAvatarLabeled).find(GlIcon).exists()).toBe(false);
});
+ it('renders tast status text when `taskCompletionStatus` prop is defined', () => {
+ let taskStatusEl = wrapper.findByTestId('task-status');
+
+ expect(taskStatusEl.exists()).toBe(true);
+ expect(taskStatusEl.text()).toContain('0 of 5 tasks completed');
+
+ const wrapperSingleTask = createComponent({
+ ...issuableHeaderProps,
+ taskCompletionStatus: {
+ completedCount: 0,
+ count: 1,
+ },
+ });
+
+ taskStatusEl = wrapperSingleTask.findByTestId('task-status');
+
+ expect(taskStatusEl.text()).toContain('0 of 1 task completed');
+
+ wrapperSingleTask.destroy();
+ });
+
it('renders sidebar toggle button', () => {
const toggleButtonEl = wrapper.findByTestId('sidebar-toggle');
diff --git a/spec/frontend/issuable_show/components/issuable_show_root_spec.js b/spec/frontend/issuable_show/components/issuable_show_root_spec.js
index 3e3778492d2..b4c125f4910 100644
--- a/spec/frontend/issuable_show/components/issuable_show_root_spec.js
+++ b/spec/frontend/issuable_show/components/issuable_show_root_spec.js
@@ -54,6 +54,7 @@ describe('IssuableShowRoot', () => {
editFormVisible,
descriptionPreviewPath,
descriptionHelpPath,
+ taskCompletionStatus,
} = mockIssuableShowProps;
const { blocked, confidential, createdAt, author } = mockIssuable;
@@ -72,6 +73,7 @@ describe('IssuableShowRoot', () => {
confidential,
createdAt,
author,
+ taskCompletionStatus,
});
expect(issuableHeader.find('.issuable-status-box').text()).toContain('Open');
expect(issuableHeader.find('.detail-page-header-actions button.js-close').exists()).toBe(
@@ -111,6 +113,26 @@ describe('IssuableShowRoot', () => {
expect(wrapper.emitted('edit-issuable')).toBeTruthy();
});
+ it('component emits `task-list-update-success` event bubbled via issuable-body', () => {
+ const issuableBody = wrapper.find(IssuableBody);
+ const eventParam = {
+ foo: 'bar',
+ };
+
+ issuableBody.vm.$emit('task-list-update-success', eventParam);
+
+ expect(wrapper.emitted('task-list-update-success')).toBeTruthy();
+ expect(wrapper.emitted('task-list-update-success')[0]).toEqual([eventParam]);
+ });
+
+ it('component emits `task-list-update-failure` event bubbled via issuable-body', () => {
+ const issuableBody = wrapper.find(IssuableBody);
+
+ issuableBody.vm.$emit('task-list-update-failure');
+
+ expect(wrapper.emitted('task-list-update-failure')).toBeTruthy();
+ });
+
it('component emits `sidebar-toggle` event bubbled via issuable-sidebar', () => {
const issuableSidebar = wrapper.find(IssuableSidebar);
diff --git a/spec/frontend/issuable_show/mock_data.js b/spec/frontend/issuable_show/mock_data.js
index af854f420bc..9ecff705617 100644
--- a/spec/frontend/issuable_show/mock_data.js
+++ b/spec/frontend/issuable_show/mock_data.js
@@ -12,6 +12,7 @@ export const mockIssuable = {
blocked: false,
confidential: false,
updatedBy: issuable.author,
+ type: 'ISSUE',
currentUserTodos: {
nodes: [
{
@@ -26,11 +27,18 @@ export const mockIssuableShowProps = {
issuable: mockIssuable,
descriptionHelpPath: '/help/user/markdown',
descriptionPreviewPath: '/gitlab-org/gitlab-shell/preview_markdown',
+ taskListUpdatePath: `${mockIssuable.webUrl}.json`,
+ taskListLockVersion: 1,
editFormVisible: false,
enableAutocomplete: true,
enableAutosave: true,
+ enableTaskList: true,
enableEdit: true,
showFieldTitle: false,
statusBadgeClass: 'status-box-open',
statusIcon: 'issue-open-m',
+ taskCompletionStatus: {
+ completedCount: 0,
+ count: 5,
+ },
};
diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js
index 9e1bc8242fe..b8860e93a22 100644
--- a/spec/frontend/issue_show/components/app_spec.js
+++ b/spec/frontend/issue_show/components/app_spec.js
@@ -166,40 +166,6 @@ describe('Issuable output', () => {
});
});
- it('opens reCAPTCHA modal if update rejected as spam', () => {
- let modal;
-
- jest.spyOn(wrapper.vm.service, 'updateIssuable').mockResolvedValue({
- data: {
- recaptcha_html: '<div class="g-recaptcha">recaptcha_html</div>',
- },
- });
-
- wrapper.vm.canUpdate = true;
- wrapper.vm.showForm = true;
-
- return wrapper.vm
- .$nextTick()
- .then(() => {
- wrapper.vm.$refs.recaptchaModal.scriptSrc = '//scriptsrc';
- return wrapper.vm.updateIssuable();
- })
- .then(() => {
- modal = wrapper.find('.js-recaptcha-modal');
- expect(modal.isVisible()).toBe(true);
- expect(modal.find('.g-recaptcha').text()).toEqual('recaptcha_html');
- expect(document.body.querySelector('.js-recaptcha-script').src).toMatch('//scriptsrc');
- })
- .then(() => {
- modal.find('.close').trigger('click');
- return wrapper.vm.$nextTick();
- })
- .then(() => {
- expect(modal.isVisible()).toBe(false);
- expect(document.body.querySelector('.js-recaptcha-script')).toBeNull();
- });
- });
-
describe('Pinned links propagated', () => {
it.each`
prop | value
@@ -422,7 +388,18 @@ describe('Issuable output', () => {
formSpy = jest.spyOn(wrapper.vm, 'updateAndShowForm');
});
- it('shows the form if template names request is successful', () => {
+ it('shows the form if template names as hash request is successful', () => {
+ const mockData = {
+ test: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }],
+ };
+ mock.onGet('/issuable-templates-path').reply(() => Promise.resolve([200, mockData]));
+
+ return wrapper.vm.requestTemplatesAndShowForm().then(() => {
+ expect(formSpy).toHaveBeenCalledWith(mockData);
+ });
+ });
+
+ it('shows the form if template names as array request is successful', () => {
const mockData = [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }];
mock.onGet('/issuable-templates-path').reply(() => Promise.resolve([200, mockData]));
diff --git a/spec/frontend/issue_show/components/description_spec.js b/spec/frontend/issue_show/components/description_spec.js
index d59a257a2be..70c04280675 100644
--- a/spec/frontend/issue_show/components/description_spec.js
+++ b/spec/frontend/issue_show/components/description_spec.js
@@ -70,36 +70,6 @@ describe('Description component', () => {
});
});
- it('opens reCAPTCHA dialog if update rejected as spam', () => {
- let modal;
- const recaptchaChild = vm.$children.find(
- // eslint-disable-next-line no-underscore-dangle
- (child) => child.$options._componentTag === 'recaptcha-modal',
- );
-
- recaptchaChild.scriptSrc = '//scriptsrc';
-
- vm.taskListUpdateSuccess({
- recaptcha_html: '<div class="g-recaptcha">recaptcha_html</div>',
- });
-
- return vm
- .$nextTick()
- .then(() => {
- modal = vm.$el.querySelector('.js-recaptcha-modal');
-
- expect(modal.style.display).not.toEqual('none');
- expect(modal.querySelector('.g-recaptcha').textContent).toEqual('recaptcha_html');
- expect(document.body.querySelector('.js-recaptcha-script').src).toMatch('//scriptsrc');
- })
- .then(() => modal.querySelector('.close').click())
- .then(() => vm.$nextTick())
- .then(() => {
- expect(modal.style.display).toEqual('none');
- expect(document.body.querySelector('.js-recaptcha-script')).toBeNull();
- });
- });
-
it('applies syntax highlighting and math when description changed', () => {
const vmSpy = jest.spyOn(vm, 'renderGFM');
const prototypeSpy = jest.spyOn($.prototype, 'renderGFM');
@@ -144,7 +114,6 @@ describe('Description component', () => {
dataType: 'issuableType',
fieldName: 'description',
selector: '.detail-page-description',
- onSuccess: expect.any(Function),
onError: expect.any(Function),
lockVersion: 0,
});
diff --git a/spec/frontend/issue_show/components/fields/description_template_spec.js b/spec/frontend/issue_show/components/fields/description_template_spec.js
index 1193d4f8add..dc126c53f5e 100644
--- a/spec/frontend/issue_show/components/fields/description_template_spec.js
+++ b/spec/frontend/issue_show/components/fields/description_template_spec.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import descriptionTemplate from '~/issue_show/components/fields/description_template.vue';
-describe('Issue description template component', () => {
+describe('Issue description template component with templates as hash', () => {
let vm;
let formState;
@@ -14,7 +14,9 @@ describe('Issue description template component', () => {
vm = new Component({
propsData: {
formState,
- issuableTemplates: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }],
+ issuableTemplates: {
+ test: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }],
+ },
projectId: 1,
projectPath: '/',
namespacePath: '/',
@@ -23,9 +25,9 @@ describe('Issue description template component', () => {
}).$mount();
});
- it('renders templates as JSON array in data attribute', () => {
+ it('renders templates as JSON hash in data attribute', () => {
expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe(
- '[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]',
+ '{"test":[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]}',
);
});
@@ -41,3 +43,32 @@ describe('Issue description template component', () => {
expect(vm.issuableTemplate.editor.getValue()).toBe('testing new template');
});
});
+
+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":"/"}]',
+ );
+ });
+});
diff --git a/spec/frontend/issue_show/components/form_spec.js b/spec/frontend/issue_show/components/form_spec.js
index 4a8ec3cf66a..fc2e224ad92 100644
--- a/spec/frontend/issue_show/components/form_spec.js
+++ b/spec/frontend/issue_show/components/form_spec.js
@@ -42,7 +42,7 @@ describe('Inline edit form component', () => {
expect(vm.$el.querySelector('.js-issuable-selector-wrap')).toBeNull();
});
- it('renders template selector when templates exists', () => {
+ it('renders template selector when templates as array exists', () => {
createComponent({
issuableTemplates: [
{ name: 'test', id: 'test', project_path: 'test', namespace_path: 'test' },
@@ -52,6 +52,16 @@ describe('Inline edit form component', () => {
expect(vm.$el.querySelector('.js-issuable-selector-wrap')).not.toBeNull();
});
+ it('renders template selector when templates as hash exists', () => {
+ createComponent({
+ issuableTemplates: {
+ test: [{ name: 'test', id: 'test', project_path: 'test', namespace_path: 'test' }],
+ },
+ });
+
+ expect(vm.$el.querySelector('.js-issuable-selector-wrap')).not.toBeNull();
+ });
+
it('hides locked warning by default', () => {
createComponent();
diff --git a/spec/frontend/issue_spec.js b/spec/frontend/issue_spec.js
index fb6caef41e2..952ef54d286 100644
--- a/spec/frontend/issue_spec.js
+++ b/spec/frontend/issue_spec.js
@@ -1,91 +1,90 @@
+import { getByText } from '@testing-library/dom';
import MockAdapter from 'axios-mock-adapter';
-import $ from 'jquery';
+import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
import Issue from '~/issue';
import axios from '~/lib/utils/axios_utils';
-import '~/lib/utils/text_utility';
describe('Issue', () => {
- let $boxClosed;
- let $boxOpen;
let testContext;
+ let mock;
beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet(/(.*)\/related_branches$/).reply(200, {});
+
testContext = {};
+ testContext.issue = new Issue();
});
- preloadFixtures('issues/closed-issue.html');
- preloadFixtures('issues/open-issue.html');
-
- function expectVisibility($element, shouldBeVisible) {
- if (shouldBeVisible) {
- expect($element).not.toHaveClass('hidden');
- } else {
- expect($element).toHaveClass('hidden');
- }
- }
-
- function expectIssueState(isIssueOpen) {
- expectVisibility($boxClosed, !isIssueOpen);
- expectVisibility($boxOpen, isIssueOpen);
- }
-
- function findElements() {
- $boxClosed = $('div.status-box-issue-closed');
-
- expect($boxClosed).toExist();
- expect($boxClosed).toHaveText('Closed');
+ afterEach(() => {
+ mock.restore();
+ testContext.issue.dispose();
+ });
- $boxOpen = $('div.status-box-open');
+ const getIssueCounter = () => document.querySelector('.issue_counter');
+ const getOpenStatusBox = () =>
+ getByText(document, (_, el) => el.textContent.match(/Open/), {
+ selector: '.status-box-open',
+ });
+ const getClosedStatusBox = () =>
+ getByText(document, (_, el) => el.textContent.match(/Closed/), {
+ selector: '.status-box-issue-closed',
+ });
- expect($boxOpen).toExist();
- expect($boxOpen).toHaveText('Open');
- }
+ describe.each`
+ desc | isIssueInitiallyOpen | expectedCounterText
+ ${'with an initially open issue'} | ${true} | ${'1,000'}
+ ${'with an initially closed issue'} | ${false} | ${'1,002'}
+ `('$desc', ({ isIssueInitiallyOpen, expectedCounterText }) => {
+ beforeEach(() => {
+ if (isIssueInitiallyOpen) {
+ loadFixtures('issues/open-issue.html');
+ } else {
+ loadFixtures('issues/closed-issue.html');
+ }
- [true, false].forEach((isIssueInitiallyOpen) => {
- describe(`with ${isIssueInitiallyOpen ? 'open' : 'closed'} issue`, () => {
- const action = isIssueInitiallyOpen ? 'close' : 'reopen';
- let mock;
+ testContext.issueCounter = getIssueCounter();
+ testContext.statusBoxClosed = getClosedStatusBox();
+ testContext.statusBoxOpen = getOpenStatusBox();
- function setup() {
- testContext.issue = new Issue();
- expectIssueState(isIssueInitiallyOpen);
+ testContext.issueCounter.textContent = '1,001';
+ });
- testContext.$projectIssuesCounter = $('.issue_counter').first();
- testContext.$projectIssuesCounter.text('1,001');
+ it(`has the proper visible status box when ${isIssueInitiallyOpen ? 'open' : 'closed'}`, () => {
+ if (isIssueInitiallyOpen) {
+ expect(testContext.statusBoxClosed).toHaveClass('hidden');
+ expect(testContext.statusBoxOpen).not.toHaveClass('hidden');
+ } else {
+ expect(testContext.statusBoxClosed).not.toHaveClass('hidden');
+ expect(testContext.statusBoxOpen).toHaveClass('hidden');
}
+ });
+ describe('when vue app triggers change', () => {
beforeEach(() => {
- if (isIssueInitiallyOpen) {
- loadFixtures('issues/open-issue.html');
- } else {
- loadFixtures('issues/closed-issue.html');
- }
-
- mock = new MockAdapter(axios);
- mock.onGet(/(.*)\/related_branches$/).reply(200, {});
- jest.spyOn(axios, 'get');
-
- findElements(isIssueInitiallyOpen);
- });
-
- afterEach(() => {
- mock.restore();
- $('div.flash-alert').remove();
- });
-
- it(`${action}s the issue on dispatch of issuable_vue_app:change event`, () => {
- setup();
-
document.dispatchEvent(
- new CustomEvent('issuable_vue_app:change', {
+ new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, {
detail: {
data: { id: 1 },
isClosed: isIssueInitiallyOpen,
},
}),
);
+ });
+
+ it('displays correct status box', () => {
+ if (isIssueInitiallyOpen) {
+ expect(testContext.statusBoxClosed).not.toHaveClass('hidden');
+ expect(testContext.statusBoxOpen).toHaveClass('hidden');
+ } else {
+ expect(testContext.statusBoxClosed).toHaveClass('hidden');
+ expect(testContext.statusBoxOpen).not.toHaveClass('hidden');
+ }
+ });
- expectIssueState(!isIssueInitiallyOpen);
+ it('updates issueCounter text', () => {
+ expect(testContext.issueCounter).toBeVisible();
+ expect(testContext.issueCounter).toHaveText(expectedCounterText);
});
});
});
diff --git a/spec/frontend/issues_list/components/issuable_spec.js b/spec/frontend/issues_list/components/issuable_spec.js
index a8bf124373b..97d841c861d 100644
--- a/spec/frontend/issues_list/components/issuable_spec.js
+++ b/spec/frontend/issues_list/components/issuable_spec.js
@@ -1,4 +1,4 @@
-import { GlSprintf, GlLabel, GlIcon } from '@gitlab/ui';
+import { GlSprintf, GlLabel, GlIcon, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import { trimText } from 'helpers/text_helper';
@@ -31,6 +31,7 @@ const TEST_MILESTONE = {
};
const TEXT_CLOSED = 'CLOSED';
const TEST_META_COUNT = 100;
+const MOCK_GITLAB_URL = 'http://0.0.0.0:3000';
describe('Issuable component', () => {
let issuable;
@@ -54,6 +55,7 @@ describe('Issuable component', () => {
beforeEach(() => {
issuable = { ...simpleIssue };
+ gon.gitlab_url = MOCK_GITLAB_URL;
});
afterEach(() => {
@@ -190,15 +192,42 @@ describe('Issuable component', () => {
expect(wrapper.classes('closed')).toBe(false);
});
- it('renders fuzzy opened date and author', () => {
+ it('renders fuzzy created date and author', () => {
expect(trimText(findOpenedAgoContainer().text())).toContain(
- `opened 1 month ago by ${TEST_USER_NAME}`,
+ `created 1 month ago by ${TEST_USER_NAME}`,
);
});
it('renders no comments', () => {
expect(findNotes().classes('no-comments')).toBe(true);
});
+
+ it.each`
+ gitlabWebUrl | webUrl | expectedHref | expectedTarget | isExternal
+ ${undefined} | ${`${MOCK_GITLAB_URL}/issue`} | ${`${MOCK_GITLAB_URL}/issue`} | ${undefined} | ${false}
+ ${undefined} | ${'https://jira.com/issue'} | ${'https://jira.com/issue'} | ${'_blank'} | ${true}
+ ${'/gitlab-org/issue'} | ${'https://jira.com/issue'} | ${'/gitlab-org/issue'} | ${undefined} | ${false}
+ `(
+ 'renders issuable title correctly when `gitlabWebUrl` is `$gitlabWebUrl` and webUrl is `$webUrl`',
+ async ({ webUrl, gitlabWebUrl, expectedHref, expectedTarget, isExternal }) => {
+ factory({
+ issuable: {
+ ...issuable,
+ web_url: webUrl,
+ gitlab_web_url: gitlabWebUrl,
+ },
+ });
+
+ const titleEl = findIssuableTitle();
+
+ expect(titleEl.exists()).toBe(true);
+ expect(titleEl.find(GlLink).attributes('href')).toBe(expectedHref);
+ expect(titleEl.find(GlLink).attributes('target')).toBe(expectedTarget);
+ expect(titleEl.find(GlLink).text()).toBe(issuable.title);
+
+ expect(titleEl.find(GlIcon).exists()).toBe(isExternal);
+ },
+ );
});
describe('with confidential issuable', () => {
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
new file mode 100644
index 00000000000..614ad586ec9
--- /dev/null
+++ b/spec/frontend/issues_list/components/issue_card_time_info_spec.js
@@ -0,0 +1,109 @@
+import { GlIcon, GlLink } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { useFakeDate } from 'helpers/fake_date';
+import IssueCardTimeInfo from '~/issues_list/components/issue_card_time_info.vue';
+
+describe('IssuesListApp component', () => {
+ useFakeDate(2020, 11, 11);
+
+ let wrapper;
+
+ const issue = {
+ milestone: {
+ dueDate: '2020-12-17',
+ startDate: '2020-12-10',
+ title: 'My milestone',
+ webUrl: '/milestone/webUrl',
+ },
+ dueDate: '2020-12-12',
+ timeStats: {
+ humanTimeEstimate: '1w',
+ },
+ };
+
+ const findMilestone = () => wrapper.find('[data-testid="issuable-milestone"]');
+ const findMilestoneTitle = () => findMilestone().find(GlLink).attributes('title');
+ const findDueDate = () => wrapper.find('[data-testid="issuable-due-date"]');
+
+ const mountComponent = ({
+ dueDate = issue.dueDate,
+ milestoneDueDate = issue.milestone.dueDate,
+ milestoneStartDate = issue.milestone.startDate,
+ } = {}) =>
+ shallowMount(IssueCardTimeInfo, {
+ propsData: {
+ issue: {
+ ...issue,
+ milestone: {
+ ...issue.milestone,
+ dueDate: milestoneDueDate,
+ startDate: milestoneStartDate,
+ },
+ dueDate,
+ },
+ },
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('milestone', () => {
+ it('renders', () => {
+ wrapper = mountComponent();
+
+ const milestone = findMilestone();
+
+ expect(milestone.text()).toBe(issue.milestone.title);
+ expect(milestone.find(GlIcon).props('name')).toBe('clock');
+ expect(milestone.find(GlLink).attributes('href')).toBe(issue.milestone.webUrl);
+ });
+
+ describe.each`
+ time | text | milestoneDueDate | milestoneStartDate | expected
+ ${'due date is in past'} | ${'Past due'} | ${'2020-09-09'} | ${null} | ${'Sep 9, 2020 (Past due)'}
+ ${'due date is today'} | ${'Today'} | ${'2020-12-11'} | ${null} | ${'Dec 11, 2020 (Today)'}
+ ${'start date is in future'} | ${'Upcoming'} | ${'2021-03-01'} | ${'2021-02-01'} | ${'Mar 1, 2021 (Upcoming)'}
+ ${'due date is in future'} | ${'2 weeks remaining'} | ${'2020-12-25'} | ${null} | ${'Dec 25, 2020 (2 weeks remaining)'}
+ `('when $description', ({ text, milestoneDueDate, milestoneStartDate, expected }) => {
+ it(`renders with "${text}"`, () => {
+ wrapper = mountComponent({ milestoneDueDate, milestoneStartDate });
+
+ expect(findMilestoneTitle()).toBe(expected);
+ });
+ });
+ });
+
+ describe('due date', () => {
+ describe('when upcoming', () => {
+ it('renders', () => {
+ wrapper = mountComponent();
+
+ const dueDate = findDueDate();
+
+ expect(dueDate.text()).toBe('Dec 12, 2020');
+ expect(dueDate.attributes('title')).toBe('Due date');
+ expect(dueDate.find(GlIcon).props('name')).toBe('calendar');
+ expect(dueDate.classes()).not.toContain('gl-text-red-500');
+ });
+ });
+
+ describe('when in the past', () => {
+ it('renders in red', () => {
+ wrapper = mountComponent({ dueDate: new Date('2020-10-10') });
+
+ expect(findDueDate().classes()).toContain('gl-text-red-500');
+ });
+ });
+ });
+
+ it('renders time estimate', () => {
+ wrapper = mountComponent();
+
+ const timeEstimate = wrapper.find('[data-testid="time-estimate"]');
+
+ expect(timeEstimate.text()).toBe(issue.timeStats.humanTimeEstimate);
+ expect(timeEstimate.attributes('title')).toBe('Estimate');
+ expect(timeEstimate.find(GlIcon).props('name')).toBe('timer');
+ });
+});
diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js
new file mode 100644
index 00000000000..1053e8934c9
--- /dev/null
+++ b/spec/frontend/issues_list/components/issues_list_app_spec.js
@@ -0,0 +1,98 @@
+import { shallowMount } from '@vue/test-utils';
+import AxiosMockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
+import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
+import axios from '~/lib/utils/axios_utils';
+
+describe('IssuesListApp component', () => {
+ let axiosMock;
+ let wrapper;
+
+ const fullPath = 'path/to/project';
+ const endpoint = 'api/endpoint';
+ const state = 'opened';
+ const xPage = 1;
+ const xTotal = 25;
+ const fetchIssuesResponse = {
+ data: [],
+ headers: {
+ 'x-page': xPage,
+ 'x-total': xTotal,
+ },
+ };
+
+ const findIssuableList = () => wrapper.findComponent(IssuableList);
+
+ const mountComponent = () =>
+ shallowMount(IssuesListApp, {
+ provide: {
+ endpoint,
+ fullPath,
+ },
+ });
+
+ beforeEach(async () => {
+ axiosMock = new AxiosMockAdapter(axios);
+ axiosMock.onGet(endpoint).reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers);
+ wrapper = mountComponent();
+ await waitForPromises();
+ });
+
+ afterEach(() => {
+ axiosMock.reset();
+ wrapper.destroy();
+ });
+
+ it('renders IssuableList', () => {
+ expect(findIssuableList().props()).toMatchObject({
+ namespace: fullPath,
+ recentSearchesStorageKey: 'issues',
+ searchInputPlaceholder: 'Search or filter results…',
+ showPaginationControls: true,
+ issuables: [],
+ totalItems: xTotal,
+ currentPage: xPage,
+ previousPage: xPage - 1,
+ nextPage: xPage + 1,
+ urlParams: { page: xPage, state },
+ });
+ });
+
+ describe('when "page-change" event is emitted', () => {
+ const data = [{ id: 10, title: 'title', state }];
+ const page = 2;
+ const totalItems = 21;
+
+ beforeEach(async () => {
+ axiosMock.onGet(endpoint).reply(200, data, {
+ 'x-page': page,
+ 'x-total': totalItems,
+ });
+
+ findIssuableList().vm.$emit('page-change', page);
+
+ await waitForPromises();
+ });
+
+ it('fetches issues with expected params', async () => {
+ expect(axiosMock.history.get[1].params).toEqual({
+ page,
+ per_page: 20,
+ state,
+ with_labels_details: true,
+ });
+ });
+
+ it('updates IssuableList with response data', () => {
+ expect(findIssuableList().props()).toMatchObject({
+ issuables: data,
+ totalItems,
+ currentPage: page,
+ previousPage: page - 1,
+ nextPage: page + 1,
+ urlParams: { page, state },
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issues_list/components/jira_issues_list_root_spec.js b/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js
index eecb092a330..0c96b95a61f 100644
--- a/spec/frontend/issues_list/components/jira_issues_list_root_spec.js
+++ b/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js
@@ -1,9 +1,9 @@
import { GlAlert, GlLabel } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
-import JiraIssuesListRoot from '~/issues_list/components/jira_issues_list_root.vue';
+import JiraIssuesImportStatus from '~/issues_list/components/jira_issues_import_status_app.vue';
-describe('JiraIssuesListRoot', () => {
+describe('JiraIssuesImportStatus', () => {
const issuesPath = 'gitlab-org/gitlab-test/-/issues';
const label = {
color: '#333',
@@ -19,7 +19,7 @@ describe('JiraIssuesListRoot', () => {
shouldShowFinishedAlert = false,
shouldShowInProgressAlert = false,
} = {}) =>
- shallowMount(JiraIssuesListRoot, {
+ shallowMount(JiraIssuesImportStatus, {
propsData: {
canEdit: true,
isJiraConfigured: true,
diff --git a/spec/frontend/jira_connect/components/app_spec.js b/spec/frontend/jira_connect/components/app_spec.js
index d11b66b2089..e2a5cd1be9d 100644
--- a/spec/frontend/jira_connect/components/app_spec.js
+++ b/spec/frontend/jira_connect/components/app_spec.js
@@ -1,10 +1,12 @@
-import { GlAlert, GlButton, GlModal } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { GlAlert, GlButton, GlModal, GlLink } from '@gitlab/ui';
+import { mount, shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import JiraConnectApp from '~/jira_connect/components/app.vue';
import createStore from '~/jira_connect/store';
-import { SET_ERROR_MESSAGE } from '~/jira_connect/store/mutation_types';
+import { SET_ALERT } from '~/jira_connect/store/mutation_types';
+import { persistAlert } from '~/jira_connect/utils';
+import { __ } from '~/locale';
jest.mock('~/jira_connect/api');
@@ -13,21 +15,19 @@ describe('JiraConnectApp', () => {
let store;
const findAlert = () => wrapper.findComponent(GlAlert);
+ const findAlertLink = () => findAlert().find(GlLink);
const findGlButton = () => wrapper.findComponent(GlButton);
const findGlModal = () => wrapper.findComponent(GlModal);
const findHeader = () => wrapper.findByTestId('new-jira-connect-ui-heading');
const findHeaderText = () => findHeader().text();
- const createComponent = (options = {}) => {
+ const createComponent = ({ provide, mountFn = shallowMount } = {}) => {
store = createStore();
wrapper = extendedWrapper(
- shallowMount(JiraConnectApp, {
+ mountFn(JiraConnectApp, {
store,
- provide: {
- glFeatures: { newJiraConnectUi: true },
- },
- ...options,
+ provide,
}),
);
};
@@ -49,7 +49,6 @@ describe('JiraConnectApp', () => {
beforeEach(() => {
createComponent({
provide: {
- glFeatures: { newJiraConnectUi: true },
usersPath: '/users',
},
});
@@ -72,37 +71,72 @@ describe('JiraConnectApp', () => {
});
});
- describe('newJiraConnectUi is false', () => {
- it('does not render new UI', () => {
- createComponent({
- provide: {
- glFeatures: { newJiraConnectUi: false },
- },
- });
+ describe('alert', () => {
+ it.each`
+ message | variant | alertShouldRender
+ ${'Test error'} | ${'danger'} | ${true}
+ ${'Test notice'} | ${'info'} | ${true}
+ ${''} | ${undefined} | ${false}
+ ${undefined} | ${undefined} | ${false}
+ `(
+ 'renders correct alert when message is `$message` and variant is `$variant`',
+ async ({ message, alertShouldRender, variant }) => {
+ createComponent();
+
+ store.commit(SET_ALERT, { message, variant });
+ await wrapper.vm.$nextTick();
+
+ const alert = findAlert();
+
+ expect(alert.exists()).toBe(alertShouldRender);
+ if (alertShouldRender) {
+ expect(alert.isVisible()).toBe(alertShouldRender);
+ expect(alert.html()).toContain(message);
+ expect(alert.props('variant')).toBe(variant);
+ expect(findAlertLink().exists()).toBe(false);
+ }
+ },
+ );
+
+ it('hides alert on @dismiss event', async () => {
+ createComponent();
+
+ store.commit(SET_ALERT, { message: 'test message' });
+ await wrapper.vm.$nextTick();
+
+ findAlert().vm.$emit('dismiss');
+ await wrapper.vm.$nextTick();
- expect(findHeader().exists()).toBe(false);
+ expect(findAlert().exists()).toBe(false);
});
- });
- it.each`
- errorMessage | errorShouldRender
- ${'Test error'} | ${true}
- ${''} | ${false}
- ${undefined} | ${false}
- `(
- 'renders correct alert when errorMessage is `$errorMessage`',
- async ({ errorMessage, errorShouldRender }) => {
- createComponent();
+ it('renders link when `linkUrl` is set', async () => {
+ createComponent({ mountFn: mount });
- store.commit(SET_ERROR_MESSAGE, errorMessage);
+ store.commit(SET_ALERT, {
+ message: __('test message %{linkStart}test link%{linkEnd}'),
+ linkUrl: 'https://gitlab.com',
+ });
await wrapper.vm.$nextTick();
- expect(findAlert().exists()).toBe(errorShouldRender);
- if (errorShouldRender) {
- expect(findAlert().isVisible()).toBe(errorShouldRender);
- expect(findAlert().html()).toContain(errorMessage);
- }
- },
- );
+ const alertLink = findAlertLink();
+
+ expect(alertLink.exists()).toBe(true);
+ expect(alertLink.text()).toContain('test link');
+ expect(alertLink.attributes('href')).toBe('https://gitlab.com');
+ });
+
+ describe('when alert is set in localStoage', () => {
+ it('renders alert on mount', () => {
+ persistAlert({ message: 'error message' });
+ createComponent();
+
+ const alert = findAlert();
+
+ expect(alert.exists()).toBe(true);
+ expect(alert.html()).toContain('error message');
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/jira_connect/components/groups_list_item_spec.js b/spec/frontend/jira_connect/components/groups_list_item_spec.js
index bb247534aca..da16223255c 100644
--- a/spec/frontend/jira_connect/components/groups_list_item_spec.js
+++ b/spec/frontend/jira_connect/components/groups_list_item_spec.js
@@ -5,8 +5,11 @@ import waitForPromises from 'helpers/wait_for_promises';
import * as JiraConnectApi from '~/jira_connect/api';
import GroupsListItem from '~/jira_connect/components/groups_list_item.vue';
+import { persistAlert } from '~/jira_connect/utils';
import { mockGroup1 } from '../mock_data';
+jest.mock('~/jira_connect/utils');
+
describe('GroupsListItem', () => {
let wrapper;
const mockSubscriptionPath = 'subscriptionPath';
@@ -85,7 +88,16 @@ describe('GroupsListItem', () => {
expect(findLinkButton().props('loading')).toBe(true);
+ await waitForPromises();
+
expect(addSubscriptionSpy).toHaveBeenCalledWith(mockSubscriptionPath, mockGroup1.full_path);
+ expect(persistAlert).toHaveBeenCalledWith({
+ linkUrl: '/help/integration/jira_development_panel.html#usage',
+ message:
+ 'You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}',
+ title: 'Namespace successfully linked',
+ variant: 'success',
+ });
});
describe('when request is successful', () => {
diff --git a/spec/frontend/jira_connect/store/mutations_spec.js b/spec/frontend/jira_connect/store/mutations_spec.js
index d1f9d22b3de..584b17b36f7 100644
--- a/spec/frontend/jira_connect/store/mutations_spec.js
+++ b/spec/frontend/jira_connect/store/mutations_spec.js
@@ -8,11 +8,21 @@ describe('JiraConnect store mutations', () => {
localState = state();
});
- describe('SET_ERROR_MESSAGE', () => {
- it('sets error message', () => {
- mutations.SET_ERROR_MESSAGE(localState, 'test error');
+ describe('SET_ALERT', () => {
+ it('sets alert state', () => {
+ mutations.SET_ALERT(localState, {
+ message: 'test error',
+ variant: 'danger',
+ title: 'test title',
+ linkUrl: 'linkUrl',
+ });
- expect(localState.errorMessage).toBe('test error');
+ expect(localState.alert).toMatchObject({
+ message: 'test error',
+ variant: 'danger',
+ title: 'test title',
+ linkUrl: 'linkUrl',
+ });
});
});
});
diff --git a/spec/frontend/jira_connect/utils_spec.js b/spec/frontend/jira_connect/utils_spec.js
new file mode 100644
index 00000000000..5310bce384b
--- /dev/null
+++ b/spec/frontend/jira_connect/utils_spec.js
@@ -0,0 +1,32 @@
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+import { ALERT_LOCALSTORAGE_KEY } from '~/jira_connect/constants';
+import { persistAlert, retrieveAlert } from '~/jira_connect/utils';
+
+useLocalStorageSpy();
+
+describe('JiraConnect utils', () => {
+ describe('alert utils', () => {
+ it.each`
+ arg | expectedRetrievedValue
+ ${{ title: 'error' }} | ${{ title: 'error' }}
+ ${{ title: 'error', randomKey: 'test' }} | ${{ title: 'error' }}
+ ${{ title: 'error', message: 'error message', linkUrl: 'link', variant: 'danger' }} | ${{ title: 'error', message: 'error message', linkUrl: 'link', variant: 'danger' }}
+ ${undefined} | ${{}}
+ `(
+ 'persists and retrieves alert data from localStorage when arg is $arg',
+ ({ arg, expectedRetrievedValue }) => {
+ persistAlert(arg);
+
+ expect(localStorage.setItem).toHaveBeenCalledWith(
+ ALERT_LOCALSTORAGE_KEY,
+ JSON.stringify(expectedRetrievedValue),
+ );
+
+ const retrievedValue = retrieveAlert();
+
+ expect(localStorage.getItem).toHaveBeenCalledWith(ALERT_LOCALSTORAGE_KEY);
+ expect(retrievedValue).toEqual(expectedRetrievedValue);
+ },
+ );
+ });
+});
diff --git a/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js
index 8fc5b071e54..6914b8d4fa1 100644
--- a/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js
+++ b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js
@@ -54,7 +54,7 @@ describe('Job Sidebar Retry Button', () => {
expect(findRetryButton().attributes()).toMatchObject({
category: 'primary',
- variant: 'info',
+ variant: 'confirm',
});
});
});
diff --git a/spec/frontend/jobs/components/jobs_container_spec.js b/spec/frontend/jobs/components/jobs_container_spec.js
index 9a336489101..1cde72682a2 100644
--- a/spec/frontend/jobs/components/jobs_container_spec.js
+++ b/spec/frontend/jobs/components/jobs_container_spec.js
@@ -1,10 +1,10 @@
-import Vue from 'vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import component from '~/jobs/components/jobs_container.vue';
+import { GlLink } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import JobsContainer from '~/jobs/components/jobs_container.vue';
describe('Jobs List block', () => {
- const Component = Vue.extend(component);
- let vm;
+ let wrapper;
const retried = {
status: {
@@ -52,80 +52,96 @@ describe('Jobs List block', () => {
tooltip: 'build - passed',
};
+ const findAllJobs = () => wrapper.findAllComponents(GlLink);
+ const findJob = () => findAllJobs().at(0);
+
+ const findArrowIcon = () => wrapper.findByTestId('arrow-right-icon');
+ const findRetryIcon = () => wrapper.findByTestId('retry-icon');
+
+ const createComponent = (props) => {
+ wrapper = extendedWrapper(
+ mount(JobsContainer, {
+ propsData: {
+ ...props,
+ },
+ }),
+ );
+ };
+
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
- it('renders list of jobs', () => {
- vm = mountComponent(Component, {
+ it('renders a list of jobs', () => {
+ createComponent({
jobs: [job, retried, active],
jobId: 12313,
});
- expect(vm.$el.querySelectorAll('a').length).toEqual(3);
+ expect(findAllJobs()).toHaveLength(3);
});
- it('renders arrow right when job id matches `jobId`', () => {
- vm = mountComponent(Component, {
+ it('renders the arrow right icon when job id matches `jobId`', () => {
+ createComponent({
jobs: [active],
jobId: active.id,
});
- expect(vm.$el.querySelector('a .js-arrow-right')).not.toBeNull();
+ expect(findArrowIcon().exists()).toBe(true);
});
- it('does not render arrow right when job is not active', () => {
- vm = mountComponent(Component, {
+ it('does not render the arrow right icon when the job is not active', () => {
+ createComponent({
jobs: [job],
jobId: active.id,
});
- expect(vm.$el.querySelector('a .js-arrow-right')).toBeNull();
+ expect(findArrowIcon().exists()).toBe(false);
});
- it('renders job name when present', () => {
- vm = mountComponent(Component, {
+ it('renders the job name when present', () => {
+ createComponent({
jobs: [job],
jobId: active.id,
});
- expect(vm.$el.querySelector('a').textContent.trim()).toContain(job.name);
- expect(vm.$el.querySelector('a').textContent.trim()).not.toContain(job.id);
+ expect(findJob().text()).toBe(job.name);
+ expect(findJob().text()).not.toContain(job.id);
});
it('renders job id when job name is not available', () => {
- vm = mountComponent(Component, {
+ createComponent({
jobs: [retried],
jobId: active.id,
});
- expect(vm.$el.querySelector('a').textContent.trim()).toContain(retried.id);
+ expect(findJob().text()).toBe(retried.id.toString());
});
it('links to the job page', () => {
- vm = mountComponent(Component, {
+ createComponent({
jobs: [job],
jobId: active.id,
});
- expect(vm.$el.querySelector('a').getAttribute('href')).toEqual(job.status.details_path);
+ expect(findJob().attributes('href')).toBe(job.status.details_path);
});
it('renders retry icon when job was retried', () => {
- vm = mountComponent(Component, {
+ createComponent({
jobs: [retried],
jobId: active.id,
});
- expect(vm.$el.querySelector('.js-retry-icon')).not.toBeNull();
+ expect(findRetryIcon().exists()).toBe(true);
});
it('does not render retry icon when job was not retried', () => {
- vm = mountComponent(Component, {
+ createComponent({
jobs: [job],
jobId: active.id,
});
- expect(vm.$el.querySelector('.js-retry-icon')).toBeNull();
+ expect(findRetryIcon().exists()).toBe(false);
});
});
diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js
index 32a24227cbd..2df0cb00f9a 100644
--- a/spec/frontend/lib/utils/datetime_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime_utility_spec.js
@@ -764,6 +764,21 @@ describe('date addition/subtraction methods', () => {
);
});
+ describe('nYearsAfter', () => {
+ it.each`
+ date | numberOfYears | expected
+ ${'2020-07-06'} | ${1} | ${'2021-07-06'}
+ ${'2020-07-06'} | ${15} | ${'2035-07-06'}
+ `(
+ 'returns $expected for "$numberOfYears year(s) after $date"',
+ ({ date, numberOfYears, expected }) => {
+ expect(datetimeUtility.nYearsAfter(new Date(date), numberOfYears)).toEqual(
+ new Date(expected),
+ );
+ },
+ );
+ });
+
describe('nMonthsBefore', () => {
// The previous month (February) has 28 days
const march2019 = '2019-03-15T00:00:00.000Z';
@@ -1018,6 +1033,81 @@ describe('isToday', () => {
});
});
+describe('isInPast', () => {
+ it.each`
+ date | expected
+ ${new Date('2024-12-15')} | ${false}
+ ${new Date('2020-07-06T00:00')} | ${false}
+ ${new Date('2020-07-05T23:59:59.999')} | ${true}
+ ${new Date('2020-07-05')} | ${true}
+ ${new Date('1999-03-21')} | ${true}
+ `('returns $expected for $date', ({ date, expected }) => {
+ expect(datetimeUtility.isInPast(date)).toBe(expected);
+ });
+});
+
+describe('isInFuture', () => {
+ it.each`
+ date | expected
+ ${new Date('2024-12-15')} | ${true}
+ ${new Date('2020-07-07T00:00')} | ${true}
+ ${new Date('2020-07-06T23:59:59.999')} | ${false}
+ ${new Date('2020-07-06')} | ${false}
+ ${new Date('1999-03-21')} | ${false}
+ `('returns $expected for $date', ({ date, expected }) => {
+ expect(datetimeUtility.isInFuture(date)).toBe(expected);
+ });
+});
+
+describe('fallsBefore', () => {
+ it.each`
+ dateA | dateB | expected
+ ${new Date('2020-07-06T23:59:59.999')} | ${new Date('2020-07-07T00:00')} | ${true}
+ ${new Date('2020-07-07T00:00')} | ${new Date('2020-07-06T23:59:59.999')} | ${false}
+ ${new Date('2020-04-04')} | ${new Date('2021-10-10')} | ${true}
+ ${new Date('2021-10-10')} | ${new Date('2020-04-04')} | ${false}
+ `('returns $expected for "$dateA falls before $dateB"', ({ dateA, dateB, expected }) => {
+ expect(datetimeUtility.fallsBefore(dateA, dateB)).toBe(expected);
+ });
+});
+
+describe('removeTime', () => {
+ it.each`
+ date | expected
+ ${new Date('2020-07-07')} | ${new Date('2020-07-07T00:00:00.000')}
+ ${new Date('2020-07-07T00:00:00.001')} | ${new Date('2020-07-07T00:00:00.000')}
+ ${new Date('2020-07-07T23:59:59.999')} | ${new Date('2020-07-07T00:00:00.000')}
+ ${new Date('2020-07-07T12:34:56.789')} | ${new Date('2020-07-07T00:00:00.000')}
+ `('returns $expected for $date', ({ date, expected }) => {
+ expect(datetimeUtility.removeTime(date)).toEqual(expected);
+ });
+});
+
+describe('getTimeRemainingInWords', () => {
+ it.each`
+ date | expected
+ ${new Date('2020-07-06T12:34:56.789')} | ${'0 days remaining'}
+ ${new Date('2020-07-07T12:34:56.789')} | ${'1 day remaining'}
+ ${new Date('2020-07-08T12:34:56.789')} | ${'2 days remaining'}
+ ${new Date('2020-07-12T12:34:56.789')} | ${'6 days remaining'}
+ ${new Date('2020-07-13T12:34:56.789')} | ${'1 week remaining'}
+ ${new Date('2020-07-19T12:34:56.789')} | ${'1 week remaining'}
+ ${new Date('2020-07-20T12:34:56.789')} | ${'2 weeks remaining'}
+ ${new Date('2020-07-27T12:34:56.789')} | ${'3 weeks remaining'}
+ ${new Date('2020-08-03T12:34:56.789')} | ${'4 weeks remaining'}
+ ${new Date('2020-08-05T12:34:56.789')} | ${'4 weeks remaining'}
+ ${new Date('2020-08-06T12:34:56.789')} | ${'1 month remaining'}
+ ${new Date('2020-09-06T12:34:56.789')} | ${'2 months remaining'}
+ ${new Date('2021-06-06T12:34:56.789')} | ${'11 months remaining'}
+ ${new Date('2021-07-06T12:34:56.789')} | ${'1 year remaining'}
+ ${new Date('2022-07-06T12:34:56.789')} | ${'2 years remaining'}
+ ${new Date('2030-07-06T12:34:56.789')} | ${'10 years remaining'}
+ ${new Date('2119-07-06T12:34:56.789')} | ${'99 years remaining'}
+ `('returns $expected for $date', ({ date, expected }) => {
+ expect(datetimeUtility.getTimeRemainingInWords(date)).toEqual(expected);
+ });
+});
+
describe('getStartOfDay', () => {
beforeEach(() => {
timezoneMock.register('US/Eastern');
@@ -1046,3 +1136,32 @@ describe('getStartOfDay', () => {
},
);
});
+
+describe('getStartOfWeek', () => {
+ beforeEach(() => {
+ timezoneMock.register('US/Eastern');
+ });
+
+ afterEach(() => {
+ timezoneMock.unregister();
+ });
+
+ it.each`
+ inputAsString | options | expectedAsString
+ ${'2021-01-29T18:08:23.014Z'} | ${undefined} | ${'2021-01-25T05:00:00.000Z'}
+ ${'2021-01-29T13:08:23.014-05:00'} | ${undefined} | ${'2021-01-25T05:00:00.000Z'}
+ ${'2021-01-30T03:08:23.014+09:00'} | ${undefined} | ${'2021-01-25T05:00:00.000Z'}
+ ${'2021-01-28T18:08:23.014-10:00'} | ${undefined} | ${'2021-01-25T05:00:00.000Z'}
+ ${'2021-01-28T18:08:23.014-10:00'} | ${{}} | ${'2021-01-25T05:00:00.000Z'}
+ ${'2021-01-28T18:08:23.014-10:00'} | ${{ utc: false }} | ${'2021-01-25T05:00:00.000Z'}
+ ${'2021-01-28T18:08:23.014-10:00'} | ${{ utc: true }} | ${'2021-01-26T00:00:00.000Z'}
+ `(
+ 'when the provided date is $inputAsString and the options parameter is $options, returns $expectedAsString',
+ ({ inputAsString, options, expectedAsString }) => {
+ const inputDate = new Date(inputAsString);
+ const actual = datetimeUtility.getStartOfWeek(inputDate, options);
+
+ expect(actual.toISOString()).toEqual(expectedAsString);
+ },
+ );
+});
diff --git a/spec/frontend/lib/utils/experimentation_spec.js b/spec/frontend/lib/utils/experimentation_spec.js
deleted file mode 100644
index 2c5d2f89297..00000000000
--- a/spec/frontend/lib/utils/experimentation_spec.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import * as experimentUtils from '~/lib/utils/experimentation';
-
-const TEST_KEY = 'abc';
-
-describe('experiment Utilities', () => {
- describe('isExperimentEnabled', () => {
- it.each`
- experiments | value
- ${{ [TEST_KEY]: true }} | ${true}
- ${{ [TEST_KEY]: false }} | ${false}
- ${{ def: true }} | ${false}
- ${{}} | ${false}
- ${null} | ${false}
- `('returns correct value of $value for experiments=$experiments', ({ experiments, value }) => {
- window.gon = { experiments };
-
- expect(experimentUtils.isExperimentEnabled(TEST_KEY)).toEqual(value);
- });
- });
-});
diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js
index 2f8f1092612..4dcd9211697 100644
--- a/spec/frontend/lib/utils/number_utility_spec.js
+++ b/spec/frontend/lib/utils/number_utility_spec.js
@@ -9,6 +9,7 @@ import {
median,
changeInPercent,
formattedChangeInPercent,
+ isNumeric,
} from '~/lib/utils/number_utils';
describe('Number Utils', () => {
@@ -162,4 +163,25 @@ describe('Number Utils', () => {
expect(formattedChangeInPercent(0, 1, { nonFiniteResult: '*' })).toBe('*');
});
});
+
+ describe('isNumeric', () => {
+ it.each`
+ value | outcome
+ ${0} | ${true}
+ ${12345} | ${true}
+ ${'0'} | ${true}
+ ${'12345'} | ${true}
+ ${1.0} | ${true}
+ ${'1.0'} | ${true}
+ ${'abcd'} | ${false}
+ ${'abcd100'} | ${false}
+ ${''} | ${false}
+ ${false} | ${false}
+ ${true} | ${false}
+ ${undefined} | ${false}
+ ${null} | ${false}
+ `('when called with $value it returns $outcome', ({ value, outcome }) => {
+ expect(isNumeric(value)).toBe(outcome);
+ });
+ });
});
diff --git a/spec/frontend/lib/utils/select2_utils_spec.js b/spec/frontend/lib/utils/select2_utils_spec.js
new file mode 100644
index 00000000000..6d601dd5ad1
--- /dev/null
+++ b/spec/frontend/lib/utils/select2_utils_spec.js
@@ -0,0 +1,100 @@
+import MockAdapter from 'axios-mock-adapter';
+import $ from 'jquery';
+import { setHTMLFixture } from 'helpers/fixtures';
+import waitForPromises from 'helpers/wait_for_promises';
+import axios from '~/lib/utils/axios_utils';
+import { select2AxiosTransport } from '~/lib/utils/select2_utils';
+
+import 'select2/select2';
+
+const TEST_URL = '/test/api/url';
+const TEST_SEARCH_DATA = { extraSearch: 'test' };
+const TEST_DATA = [{ id: 1 }];
+const TEST_SEARCH = 'FOO';
+
+describe('lib/utils/select2_utils', () => {
+ let mock;
+ let resultsSpy;
+
+ beforeEach(() => {
+ setHTMLFixture('<div><input id="root" /></div>');
+
+ mock = new MockAdapter(axios);
+
+ resultsSpy = jest.fn().mockReturnValue({ results: [] });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ const setupSelect2 = (input) => {
+ input.select2({
+ ajax: {
+ url: TEST_URL,
+ quietMillis: 250,
+ transport: select2AxiosTransport,
+ data(search, page) {
+ return {
+ search,
+ page,
+ ...TEST_SEARCH_DATA,
+ };
+ },
+ results: resultsSpy,
+ },
+ });
+ };
+
+ const setupSelect2AndSearch = async () => {
+ const $input = $('#root');
+
+ setupSelect2($input);
+
+ $input.select2('search', TEST_SEARCH);
+
+ jest.runOnlyPendingTimers();
+ await waitForPromises();
+ };
+
+ describe('select2AxiosTransport', () => {
+ it('uses axios to make request', async () => {
+ // setup mock response
+ const replySpy = jest.fn();
+ mock.onGet(TEST_URL).reply((...args) => replySpy(...args));
+
+ await setupSelect2AndSearch();
+
+ expect(replySpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: TEST_URL,
+ method: 'get',
+ params: {
+ page: 1,
+ search: TEST_SEARCH,
+ ...TEST_SEARCH_DATA,
+ },
+ }),
+ );
+ });
+
+ it.each`
+ headers | pagination
+ ${{}} | ${{ more: false }}
+ ${{ 'X-PAGE': '1', 'x-next-page': 2 }} | ${{ more: true }}
+ `(
+ 'passes results and pagination to results callback, with headers=$headers',
+ async ({ headers, pagination }) => {
+ mock.onGet(TEST_URL).reply(200, TEST_DATA, headers);
+
+ await setupSelect2AndSearch();
+
+ expect(resultsSpy).toHaveBeenCalledWith(
+ { results: TEST_DATA, pagination },
+ 1,
+ expect.anything(),
+ );
+ },
+ );
+ });
+});
diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js
index 43de195c702..b538257fac0 100644
--- a/spec/frontend/lib/utils/text_markdown_spec.js
+++ b/spec/frontend/lib/utils/text_markdown_spec.js
@@ -171,27 +171,40 @@ describe('init markdown', () => {
expect(textArea.value).toEqual(text.replace(selected, `[${selected}](url)`));
});
- it.each`
- key | expected
- ${'['} | ${`[${selected}]`}
- ${'*'} | ${`**${selected}**`}
- ${"'"} | ${`'${selected}'`}
- ${'_'} | ${`_${selected}_`}
- ${'`'} | ${`\`${selected}\``}
- ${'"'} | ${`"${selected}"`}
- ${'{'} | ${`{${selected}}`}
- ${'('} | ${`(${selected})`}
- ${'<'} | ${`<${selected}>`}
- `('generates $expected when $key is pressed', ({ key, expected }) => {
- const event = new KeyboardEvent('keydown', { key });
-
- textArea.addEventListener('keydown', keypressNoteText);
- textArea.dispatchEvent(event);
-
- expect(textArea.value).toEqual(text.replace(selected, expected));
+ describe('surrounds selected text with matching character', () => {
+ it.each`
+ key | expected
+ ${'['} | ${`[${selected}]`}
+ ${'*'} | ${`**${selected}**`}
+ ${"'"} | ${`'${selected}'`}
+ ${'_'} | ${`_${selected}_`}
+ ${'`'} | ${`\`${selected}\``}
+ ${'"'} | ${`"${selected}"`}
+ ${'{'} | ${`{${selected}}`}
+ ${'('} | ${`(${selected})`}
+ ${'<'} | ${`<${selected}>`}
+ `('generates $expected when $key is pressed', ({ key, expected }) => {
+ const event = new KeyboardEvent('keydown', { key });
+ gon.markdown_surround_selection = true;
+
+ textArea.addEventListener('keydown', keypressNoteText);
+ textArea.dispatchEvent(event);
+
+ expect(textArea.value).toEqual(text.replace(selected, expected));
+
+ // cursor placement should be after selection + 2 tag lengths
+ expect(textArea.selectionStart).toBe(selectedIndex + expected.length);
+ });
- // cursor placement should be after selection + 2 tag lengths
- expect(textArea.selectionStart).toBe(selectedIndex + expected.length);
+ it('does nothing if user preference disabled', () => {
+ const event = new KeyboardEvent('keydown', { key: '[' });
+ gon.markdown_surround_selection = false;
+
+ textArea.addEventListener('keydown', keypressNoteText);
+ textArea.dispatchEvent(event);
+
+ expect(textArea.value).toEqual(text);
+ });
});
describe('and text to be selected', () => {
diff --git a/spec/frontend/lib/utils/unit_format/index_spec.js b/spec/frontend/lib/utils/unit_format/index_spec.js
index 5b2fdf1f02b..7fd273f1b58 100644
--- a/spec/frontend/lib/utils/unit_format/index_spec.js
+++ b/spec/frontend/lib/utils/unit_format/index_spec.js
@@ -1,157 +1,213 @@
-import { getFormatter, SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
+import {
+ number,
+ percent,
+ percentHundred,
+ seconds,
+ milliseconds,
+ decimalBytes,
+ kilobytes,
+ megabytes,
+ gigabytes,
+ terabytes,
+ petabytes,
+ bytes,
+ kibibytes,
+ mebibytes,
+ gibibytes,
+ tebibytes,
+ pebibytes,
+ engineering,
+ getFormatter,
+ SUPPORTED_FORMATS,
+} from '~/lib/utils/unit_format';
describe('unit_format', () => {
- describe('when a supported format is provided, the returned function formats', () => {
- it('numbers, by default', () => {
- expect(getFormatter()(1)).toBe('1');
- });
-
- it('numbers', () => {
- const formatNumber = getFormatter(SUPPORTED_FORMATS.number);
-
- expect(formatNumber(1)).toBe('1');
- expect(formatNumber(100)).toBe('100');
- expect(formatNumber(1000)).toBe('1,000');
- expect(formatNumber(10000)).toBe('10,000');
- expect(formatNumber(1000000)).toBe('1,000,000');
- });
-
- it('percent', () => {
- const formatPercent = getFormatter(SUPPORTED_FORMATS.percent);
+ it('engineering', () => {
+ expect(engineering(1)).toBe('1');
+ expect(engineering(100)).toBe('100');
+ expect(engineering(1000)).toBe('1k');
+ expect(engineering(10_000)).toBe('10k');
+ expect(engineering(1_000_000)).toBe('1M');
+
+ expect(engineering(10 ** 9)).toBe('1G');
+ });
- expect(formatPercent(1)).toBe('100%');
- expect(formatPercent(1, 2)).toBe('100.00%');
+ it('number', () => {
+ expect(number(1)).toBe('1');
+ expect(number(100)).toBe('100');
+ expect(number(1000)).toBe('1,000');
+ expect(number(10_000)).toBe('10,000');
+ expect(number(1_000_000)).toBe('1,000,000');
- expect(formatPercent(0.1)).toBe('10%');
- expect(formatPercent(0.5)).toBe('50%');
+ expect(number(10 ** 9)).toBe('1,000,000,000');
+ });
- expect(formatPercent(0.888888)).toBe('89%');
- expect(formatPercent(0.888888, 2)).toBe('88.89%');
- expect(formatPercent(0.888888, 5)).toBe('88.88880%');
+ it('percent', () => {
+ expect(percent(1)).toBe('100%');
+ expect(percent(1, 2)).toBe('100.00%');
- expect(formatPercent(2)).toBe('200%');
- expect(formatPercent(10)).toBe('1,000%');
- });
+ expect(percent(0.1)).toBe('10%');
+ expect(percent(0.5)).toBe('50%');
- it('percentunit', () => {
- const formatPercentHundred = getFormatter(SUPPORTED_FORMATS.percentHundred);
+ expect(percent(0.888888)).toBe('89%');
+ expect(percent(0.888888, 2)).toBe('88.89%');
+ expect(percent(0.888888, 5)).toBe('88.88880%');
- expect(formatPercentHundred(1)).toBe('1%');
- expect(formatPercentHundred(1, 2)).toBe('1.00%');
-
- expect(formatPercentHundred(88.8888)).toBe('89%');
- expect(formatPercentHundred(88.8888, 2)).toBe('88.89%');
- expect(formatPercentHundred(88.8888, 5)).toBe('88.88880%');
+ expect(percent(2)).toBe('200%');
+ expect(percent(10)).toBe('1,000%');
+ });
- expect(formatPercentHundred(100)).toBe('100%');
- expect(formatPercentHundred(100, 2)).toBe('100.00%');
+ it('percentHundred', () => {
+ expect(percentHundred(1)).toBe('1%');
+ expect(percentHundred(1, 2)).toBe('1.00%');
- expect(formatPercentHundred(200)).toBe('200%');
- expect(formatPercentHundred(1000)).toBe('1,000%');
- });
+ expect(percentHundred(88.8888)).toBe('89%');
+ expect(percentHundred(88.8888, 2)).toBe('88.89%');
+ expect(percentHundred(88.8888, 5)).toBe('88.88880%');
- it('seconds', () => {
- expect(getFormatter(SUPPORTED_FORMATS.seconds)(1)).toBe('1s');
- });
+ expect(percentHundred(100)).toBe('100%');
+ expect(percentHundred(100, 2)).toBe('100.00%');
- it('milliseconds', () => {
- const formatMilliseconds = getFormatter(SUPPORTED_FORMATS.milliseconds);
+ expect(percentHundred(200)).toBe('200%');
+ expect(percentHundred(1000)).toBe('1,000%');
+ });
- expect(formatMilliseconds(1)).toBe('1ms');
- expect(formatMilliseconds(100)).toBe('100ms');
- expect(formatMilliseconds(1000)).toBe('1,000ms');
- expect(formatMilliseconds(10000)).toBe('10,000ms');
- expect(formatMilliseconds(1000000)).toBe('1,000,000ms');
- });
+ it('seconds', () => {
+ expect(seconds(1)).toBe('1s');
+ });
- it('decimalBytes', () => {
- const formatDecimalBytes = getFormatter(SUPPORTED_FORMATS.decimalBytes);
-
- expect(formatDecimalBytes(1)).toBe('1B');
- expect(formatDecimalBytes(1, 1)).toBe('1.0B');
-
- expect(formatDecimalBytes(10)).toBe('10B');
- expect(formatDecimalBytes(10 ** 2)).toBe('100B');
- expect(formatDecimalBytes(10 ** 3)).toBe('1kB');
- expect(formatDecimalBytes(10 ** 4)).toBe('10kB');
- expect(formatDecimalBytes(10 ** 5)).toBe('100kB');
- expect(formatDecimalBytes(10 ** 6)).toBe('1MB');
- expect(formatDecimalBytes(10 ** 7)).toBe('10MB');
- expect(formatDecimalBytes(10 ** 8)).toBe('100MB');
- expect(formatDecimalBytes(10 ** 9)).toBe('1GB');
- expect(formatDecimalBytes(10 ** 10)).toBe('10GB');
- expect(formatDecimalBytes(10 ** 11)).toBe('100GB');
- });
+ it('milliseconds', () => {
+ expect(milliseconds(1)).toBe('1ms');
+ expect(milliseconds(100)).toBe('100ms');
+ expect(milliseconds(1000)).toBe('1,000ms');
+ expect(milliseconds(10_000)).toBe('10,000ms');
+ expect(milliseconds(1_000_000)).toBe('1,000,000ms');
+ });
- it('kilobytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.kilobytes)(1)).toBe('1kB');
- expect(getFormatter(SUPPORTED_FORMATS.kilobytes)(1, 1)).toBe('1.0kB');
- });
+ it('decimalBytes', () => {
+ expect(decimalBytes(1)).toBe('1B');
+ expect(decimalBytes(1, 1)).toBe('1.0B');
+
+ expect(decimalBytes(10)).toBe('10B');
+ expect(decimalBytes(10 ** 2)).toBe('100B');
+ expect(decimalBytes(10 ** 3)).toBe('1kB');
+ expect(decimalBytes(10 ** 4)).toBe('10kB');
+ expect(decimalBytes(10 ** 5)).toBe('100kB');
+ expect(decimalBytes(10 ** 6)).toBe('1MB');
+ expect(decimalBytes(10 ** 7)).toBe('10MB');
+ expect(decimalBytes(10 ** 8)).toBe('100MB');
+ expect(decimalBytes(10 ** 9)).toBe('1GB');
+ expect(decimalBytes(10 ** 10)).toBe('10GB');
+ expect(decimalBytes(10 ** 11)).toBe('100GB');
+ });
- it('megabytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.megabytes)(1)).toBe('1MB');
- expect(getFormatter(SUPPORTED_FORMATS.megabytes)(1, 1)).toBe('1.0MB');
- });
+ it('kilobytes', () => {
+ expect(kilobytes(1)).toBe('1kB');
+ expect(kilobytes(1, 1)).toBe('1.0kB');
+ });
- it('gigabytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.gigabytes)(1)).toBe('1GB');
- expect(getFormatter(SUPPORTED_FORMATS.gigabytes)(1, 1)).toBe('1.0GB');
- });
+ it('megabytes', () => {
+ expect(megabytes(1)).toBe('1MB');
+ expect(megabytes(1, 1)).toBe('1.0MB');
+ });
- it('terabytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.terabytes)(1)).toBe('1TB');
- expect(getFormatter(SUPPORTED_FORMATS.terabytes)(1, 1)).toBe('1.0TB');
- });
+ it('gigabytes', () => {
+ expect(gigabytes(1)).toBe('1GB');
+ expect(gigabytes(1, 1)).toBe('1.0GB');
+ });
- it('petabytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.petabytes)(1)).toBe('1PB');
- expect(getFormatter(SUPPORTED_FORMATS.petabytes)(1, 1)).toBe('1.0PB');
- });
+ it('terabytes', () => {
+ expect(terabytes(1)).toBe('1TB');
+ expect(terabytes(1, 1)).toBe('1.0TB');
+ });
- it('bytes', () => {
- const formatBytes = getFormatter(SUPPORTED_FORMATS.bytes);
+ it('petabytes', () => {
+ expect(petabytes(1)).toBe('1PB');
+ expect(petabytes(1, 1)).toBe('1.0PB');
+ });
- expect(formatBytes(1)).toBe('1B');
- expect(formatBytes(1, 1)).toBe('1.0B');
+ it('bytes', () => {
+ expect(bytes(1)).toBe('1B');
+ expect(bytes(1, 1)).toBe('1.0B');
- expect(formatBytes(10)).toBe('10B');
- expect(formatBytes(100)).toBe('100B');
- expect(formatBytes(1000)).toBe('1,000B');
+ expect(bytes(10)).toBe('10B');
+ expect(bytes(100)).toBe('100B');
+ expect(bytes(1000)).toBe('1,000B');
- expect(formatBytes(1 * 1024)).toBe('1KiB');
- expect(formatBytes(1 * 1024 ** 2)).toBe('1MiB');
- expect(formatBytes(1 * 1024 ** 3)).toBe('1GiB');
- });
+ expect(bytes(1 * 1024)).toBe('1KiB');
+ expect(bytes(1 * 1024 ** 2)).toBe('1MiB');
+ expect(bytes(1 * 1024 ** 3)).toBe('1GiB');
+ });
- it('kibibytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.kibibytes)(1)).toBe('1KiB');
- expect(getFormatter(SUPPORTED_FORMATS.kibibytes)(1, 1)).toBe('1.0KiB');
- });
+ it('kibibytes', () => {
+ expect(kibibytes(1)).toBe('1KiB');
+ expect(kibibytes(1, 1)).toBe('1.0KiB');
+ });
- it('mebibytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.mebibytes)(1)).toBe('1MiB');
- expect(getFormatter(SUPPORTED_FORMATS.mebibytes)(1, 1)).toBe('1.0MiB');
- });
+ it('mebibytes', () => {
+ expect(mebibytes(1)).toBe('1MiB');
+ expect(mebibytes(1, 1)).toBe('1.0MiB');
+ });
- it('gibibytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.gibibytes)(1)).toBe('1GiB');
- expect(getFormatter(SUPPORTED_FORMATS.gibibytes)(1, 1)).toBe('1.0GiB');
- });
+ it('gibibytes', () => {
+ expect(gibibytes(1)).toBe('1GiB');
+ expect(gibibytes(1, 1)).toBe('1.0GiB');
+ });
- it('tebibytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.tebibytes)(1)).toBe('1TiB');
- expect(getFormatter(SUPPORTED_FORMATS.tebibytes)(1, 1)).toBe('1.0TiB');
- });
+ it('tebibytes', () => {
+ expect(tebibytes(1)).toBe('1TiB');
+ expect(tebibytes(1, 1)).toBe('1.0TiB');
+ });
- it('pebibytes', () => {
- expect(getFormatter(SUPPORTED_FORMATS.pebibytes)(1)).toBe('1PiB');
- expect(getFormatter(SUPPORTED_FORMATS.pebibytes)(1, 1)).toBe('1.0PiB');
- });
+ it('pebibytes', () => {
+ expect(pebibytes(1)).toBe('1PiB');
+ expect(pebibytes(1, 1)).toBe('1.0PiB');
});
- describe('when get formatter format is incorrect', () => {
- it('formatter fails', () => {
- expect(() => getFormatter('not-supported')(1)).toThrow();
+ describe('getFormatter', () => {
+ it.each([
+ [1],
+ [10],
+ [200],
+ [100],
+ [1000],
+ [10_000],
+ [100_000],
+ [1_000_000],
+ [10 ** 6],
+ [10 ** 9],
+ [0.1],
+ [0.5],
+ [0.888888],
+ ])('formatting functions yield the same result as getFormatter for %d', (value) => {
+ expect(number(value)).toBe(getFormatter(SUPPORTED_FORMATS.number)(value));
+ expect(percent(value)).toBe(getFormatter(SUPPORTED_FORMATS.percent)(value));
+ expect(percentHundred(value)).toBe(getFormatter(SUPPORTED_FORMATS.percentHundred)(value));
+
+ expect(seconds(value)).toBe(getFormatter(SUPPORTED_FORMATS.seconds)(value));
+ expect(milliseconds(value)).toBe(getFormatter(SUPPORTED_FORMATS.milliseconds)(value));
+
+ expect(decimalBytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.decimalBytes)(value));
+ expect(kilobytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.kilobytes)(value));
+ expect(megabytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.megabytes)(value));
+ expect(gigabytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.gigabytes)(value));
+ expect(terabytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.terabytes)(value));
+ expect(petabytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.petabytes)(value));
+
+ expect(bytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.bytes)(value));
+ expect(kibibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.kibibytes)(value));
+ expect(mebibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.mebibytes)(value));
+ expect(gibibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.gibibytes)(value));
+ expect(tebibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.tebibytes)(value));
+ expect(pebibytes(value)).toBe(getFormatter(SUPPORTED_FORMATS.pebibytes)(value));
+
+ expect(engineering(value)).toBe(getFormatter(SUPPORTED_FORMATS.engineering)(value));
+ });
+
+ describe('when get formatter format is incorrect', () => {
+ it('formatter fails', () => {
+ expect(() => getFormatter('not-supported')(1)).toThrow();
+ });
});
});
});
diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js
index b60ddea81ee..e12cd8b0e37 100644
--- a/spec/frontend/lib/utils/url_utility_spec.js
+++ b/spec/frontend/lib/utils/url_utility_spec.js
@@ -814,6 +814,14 @@ describe('URL utility', () => {
);
});
+ it('decodes URI when decodeURI=true', () => {
+ const url = 'https://gitlab.com/test';
+
+ expect(urlUtils.setUrlParams({ labels: ['foo', 'bar'] }, url, false, true, true)).toEqual(
+ 'https://gitlab.com/test?labels[]=foo&labels[]=bar',
+ );
+ });
+
it('removes all existing URL params and sets a new param when cleanParams=true', () => {
const url = 'https://gitlab.com/test?group_id=gitlab-org&project_id=my-project';
diff --git a/spec/frontend/line_highlighter_spec.js b/spec/frontend/line_highlighter_spec.js
index 8318f63ab3e..b5a0adc9d49 100644
--- a/spec/frontend/line_highlighter_spec.js
+++ b/spec/frontend/line_highlighter_spec.js
@@ -7,7 +7,6 @@ import LineHighlighter from '~/line_highlighter';
describe('LineHighlighter', () => {
const testContext = {};
- preloadFixtures('static/line_highlighter.html');
const clickLine = (number, eventData = {}) => {
if ($.isEmptyObject(eventData)) {
return $(`#L${number}`).click();
diff --git a/spec/frontend/locale/index_spec.js b/spec/frontend/locale/index_spec.js
index d65d7c195b2..a08be502735 100644
--- a/spec/frontend/locale/index_spec.js
+++ b/spec/frontend/locale/index_spec.js
@@ -1,5 +1,5 @@
import { setLanguage } from 'helpers/locale_helper';
-import { createDateTimeFormat, languageCode } from '~/locale';
+import { createDateTimeFormat, formatNumber, languageCode } from '~/locale';
describe('locale', () => {
afterEach(() => setLanguage(null));
@@ -27,4 +27,68 @@ describe('locale', () => {
expect(dateFormat.format(new Date(2015, 6, 3))).toBe('July 3, 2015');
});
});
+
+ describe('formatNumber', () => {
+ it('formats numbers', () => {
+ expect(formatNumber(1)).toBe('1');
+ expect(formatNumber(12345)).toBe('12,345');
+ });
+
+ it('formats bigint numbers', () => {
+ expect(formatNumber(123456789123456789n)).toBe('123,456,789,123,456,789');
+ });
+
+ it('formats numbers with options', () => {
+ expect(formatNumber(1, { style: 'percent' })).toBe('100%');
+ expect(formatNumber(1, { style: 'currency', currency: 'USD' })).toBe('$1.00');
+ });
+
+ it('formats localized numbers', () => {
+ expect(formatNumber(12345, {}, 'es')).toBe('12.345');
+ });
+
+ it('formats NaN', () => {
+ expect(formatNumber(NaN)).toBe('NaN');
+ });
+
+ it('formats infinity', () => {
+ expect(formatNumber(Number.POSITIVE_INFINITY)).toBe('∞');
+ });
+
+ it('formats negative infinity', () => {
+ expect(formatNumber(Number.NEGATIVE_INFINITY)).toBe('-∞');
+ });
+
+ it('formats EPSILON', () => {
+ expect(formatNumber(Number.EPSILON)).toBe('0');
+ });
+
+ describe('non-number values should pass through', () => {
+ it('undefined', () => {
+ expect(formatNumber(undefined)).toBe(undefined);
+ });
+
+ it('null', () => {
+ expect(formatNumber(null)).toBe(null);
+ });
+
+ it('arrays', () => {
+ expect(formatNumber([])).toEqual([]);
+ });
+
+ it('objects', () => {
+ expect(formatNumber({ a: 'b' })).toEqual({ a: 'b' });
+ });
+ });
+
+ describe('when in a different locale', () => {
+ beforeEach(() => {
+ setLanguage('es');
+ });
+
+ it('formats localized numbers', () => {
+ expect(formatNumber(12345)).toBe('12.345');
+ });
+ });
+ });
});
diff --git a/spec/frontend/members/components/avatars/user_avatar_spec.js b/spec/frontend/members/components/avatars/user_avatar_spec.js
index 303c82582a3..3f4d9155c5d 100644
--- a/spec/frontend/members/components/avatars/user_avatar_spec.js
+++ b/spec/frontend/members/components/avatars/user_avatar_spec.js
@@ -1,21 +1,31 @@
import { GlAvatarLink, GlBadge } from '@gitlab/ui';
import { within } from '@testing-library/dom';
import { mount, createWrapper } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
import UserAvatar from '~/members/components/avatars/user_avatar.vue';
-import { member as memberMock, orphanedMember } from '../../mock_data';
+import { member as memberMock, member2faEnabled, orphanedMember } from '../../mock_data';
+
+Vue.use(Vuex);
describe('UserAvatar', () => {
let wrapper;
const { user } = memberMock;
- const createComponent = (propsData = {}) => {
+ const createComponent = (propsData = {}, state = {}) => {
wrapper = mount(UserAvatar, {
propsData: {
member: memberMock,
isCurrentUser: false,
...propsData,
},
+ store: new Vuex.Store({
+ state: {
+ canManageMembers: true,
+ ...state,
+ },
+ }),
});
};
@@ -69,9 +79,9 @@ describe('UserAvatar', () => {
describe('badges', () => {
it.each`
- member | badgeText
- ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${'Blocked'}
- ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${'2FA'}
+ member | badgeText
+ ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${'Blocked'}
+ ${member2faEnabled} | ${'2FA'}
`('renders the "$badgeText" badge', ({ member, badgeText }) => {
createComponent({ member });
@@ -83,6 +93,12 @@ describe('UserAvatar', () => {
expect(getByText("It's you").exists()).toBe(true);
});
+
+ it('does not render 2FA badge when `canManageMembers` is `false`', () => {
+ createComponent({ member: member2faEnabled }, { canManageMembers: false });
+
+ expect(within(wrapper.element).queryByText('2FA')).toBe(null);
+ });
});
describe('user status', () => {
diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js
index fa324ce1cf9..6a73b2fcf8c 100644
--- a/spec/frontend/members/mock_data.js
+++ b/spec/frontend/members/mock_data.js
@@ -75,3 +75,5 @@ export const membersJsonString = JSON.stringify(members);
export const directMember = { ...member, isDirectMember: true };
export const inheritedMember = { ...member, isDirectMember: false };
+
+export const member2faEnabled = { ...member, user: { ...member.user, twoFactorEnabled: true } };
diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js
index f447a4c4ee9..bfb5a4bc7d3 100644
--- a/spec/frontend/members/utils_spec.js
+++ b/spec/frontend/members/utils_spec.js
@@ -17,6 +17,7 @@ import {
member as memberMock,
directMember,
inheritedMember,
+ member2faEnabled,
group,
invite,
membersJsonString,
@@ -30,7 +31,11 @@ const URL_HOST = 'https://localhost/';
describe('Members Utils', () => {
describe('generateBadges', () => {
it('has correct properties for each badge', () => {
- const badges = generateBadges(memberMock, true);
+ const badges = generateBadges({
+ member: memberMock,
+ isCurrentUser: true,
+ canManageMembers: true,
+ });
badges.forEach((badge) => {
expect(badge).toEqual(
@@ -44,12 +49,32 @@ describe('Members Utils', () => {
});
it.each`
- member | expected
- ${memberMock} | ${{ show: true, text: "It's you", variant: 'success' }}
- ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${{ show: true, text: 'Blocked', variant: 'danger' }}
- ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${{ show: true, text: '2FA', variant: 'info' }}
+ member | expected
+ ${memberMock} | ${{ show: true, text: "It's you", variant: 'success' }}
+ ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${{ show: true, text: 'Blocked', variant: 'danger' }}
+ ${member2faEnabled} | ${{ show: true, text: '2FA', variant: 'info' }}
`('returns expected output for "$expected.text" badge', ({ member, expected }) => {
- expect(generateBadges(member, true)).toContainEqual(expect.objectContaining(expected));
+ expect(
+ generateBadges({ member, isCurrentUser: true, canManageMembers: true }),
+ ).toContainEqual(expect.objectContaining(expected));
+ });
+
+ describe('when `canManageMembers` argument is `false`', () => {
+ describe.each`
+ description | memberIsCurrentUser | expectedBadgeToBeShown
+ ${'is not the current user'} | ${false} | ${false}
+ ${'is the current user'} | ${true} | ${true}
+ `('when member is $description', ({ memberIsCurrentUser, expectedBadgeToBeShown }) => {
+ it(`sets 'show' to '${expectedBadgeToBeShown}' for 2FA badge`, () => {
+ const badges = generateBadges({
+ member: member2faEnabled,
+ isCurrentUser: memberIsCurrentUser,
+ canManageMembers: false,
+ });
+
+ expect(badges.find((badge) => badge.text === '2FA').show).toBe(expectedBadgeToBeShown);
+ });
+ });
});
});
diff --git a/spec/frontend/merge_conflicts/store/actions_spec.js b/spec/frontend/merge_conflicts/store/actions_spec.js
new file mode 100644
index 00000000000..352f1783b87
--- /dev/null
+++ b/spec/frontend/merge_conflicts/store/actions_spec.js
@@ -0,0 +1,257 @@
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
+import testAction from 'helpers/vuex_action_helper';
+import createFlash from '~/flash';
+import { INTERACTIVE_RESOLVE_MODE, EDIT_RESOLVE_MODE } from '~/merge_conflicts/constants';
+import * as actions from '~/merge_conflicts/store/actions';
+import * as types from '~/merge_conflicts/store/mutation_types';
+import { restoreFileLinesState, markLine, decorateFiles } from '~/merge_conflicts/utils';
+
+jest.mock('~/flash.js');
+jest.mock('~/merge_conflicts/utils');
+
+describe('merge conflicts actions', () => {
+ let mock;
+
+ const files = [
+ {
+ blobPath: 'a',
+ },
+ { blobPath: 'b' },
+ ];
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('fetchConflictsData', () => {
+ const conflictsPath = 'conflicts/path/mock';
+
+ it('on success dispatches setConflictsData', (done) => {
+ mock.onGet(conflictsPath).reply(200, {});
+ testAction(
+ actions.fetchConflictsData,
+ conflictsPath,
+ {},
+ [
+ { type: types.SET_LOADING_STATE, payload: true },
+ { type: types.SET_LOADING_STATE, payload: false },
+ ],
+ [{ type: 'setConflictsData', payload: {} }],
+ done,
+ );
+ });
+
+ it('when data has type equal to error ', (done) => {
+ mock.onGet(conflictsPath).reply(200, { type: 'error', message: 'error message' });
+ testAction(
+ actions.fetchConflictsData,
+ conflictsPath,
+ {},
+ [
+ { type: types.SET_LOADING_STATE, payload: true },
+ { type: types.SET_FAILED_REQUEST, payload: 'error message' },
+ { type: types.SET_LOADING_STATE, payload: false },
+ ],
+ [],
+ done,
+ );
+ });
+
+ it('when request fails ', (done) => {
+ mock.onGet(conflictsPath).reply(400);
+ testAction(
+ actions.fetchConflictsData,
+ conflictsPath,
+ {},
+ [
+ { type: types.SET_LOADING_STATE, payload: true },
+ { type: types.SET_FAILED_REQUEST },
+ { type: types.SET_LOADING_STATE, payload: false },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('submitResolvedConflicts', () => {
+ useMockLocationHelper();
+ const resolveConflictsPath = 'resolve/conflicts/path/mock';
+
+ it('on success reloads the page', (done) => {
+ mock.onPost(resolveConflictsPath).reply(200, { redirect_to: 'hrefPath' });
+ testAction(
+ actions.submitResolvedConflicts,
+ resolveConflictsPath,
+ {},
+ [{ type: types.SET_SUBMIT_STATE, payload: true }],
+ [],
+ () => {
+ expect(window.location.assign).toHaveBeenCalledWith('hrefPath');
+ done();
+ },
+ );
+ });
+
+ it('on errors shows flash', (done) => {
+ mock.onPost(resolveConflictsPath).reply(400);
+ testAction(
+ actions.submitResolvedConflicts,
+ resolveConflictsPath,
+ {},
+ [
+ { type: types.SET_SUBMIT_STATE, payload: true },
+ { type: types.SET_SUBMIT_STATE, payload: false },
+ ],
+ [],
+ () => {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'Failed to save merge conflicts resolutions. Please try again!',
+ });
+ done();
+ },
+ );
+ });
+ });
+
+ describe('setConflictsData', () => {
+ it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => {
+ decorateFiles.mockReturnValue([{ bar: 'baz' }]);
+ testAction(
+ actions.setConflictsData,
+ { files, foo: 'bar' },
+ {},
+ [
+ {
+ type: types.SET_CONFLICTS_DATA,
+ payload: { foo: 'bar', files: [{ bar: 'baz' }] },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setFileResolveMode', () => {
+ it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => {
+ testAction(
+ actions.setFileResolveMode,
+ { file: files[0], mode: INTERACTIVE_RESOLVE_MODE },
+ { conflictsData: { files }, getFileIndex: () => 0 },
+ [
+ {
+ type: types.UPDATE_FILE,
+ payload: {
+ file: { ...files[0], showEditor: false, resolveMode: INTERACTIVE_RESOLVE_MODE },
+ index: 0,
+ },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+
+ it('EDIT_RESOLVE_MODE updates the correct file ', (done) => {
+ restoreFileLinesState.mockReturnValue([]);
+ const file = {
+ ...files[0],
+ showEditor: true,
+ loadEditor: true,
+ resolutionData: {},
+ resolveMode: EDIT_RESOLVE_MODE,
+ };
+ testAction(
+ actions.setFileResolveMode,
+ { file: files[0], mode: EDIT_RESOLVE_MODE },
+ { conflictsData: { files }, getFileIndex: () => 0 },
+ [
+ {
+ type: types.UPDATE_FILE,
+ payload: {
+ file,
+ index: 0,
+ },
+ },
+ ],
+ [],
+ () => {
+ expect(restoreFileLinesState).toHaveBeenCalledWith(file);
+ done();
+ },
+ );
+ });
+ });
+
+ describe('setPromptConfirmationState', () => {
+ it('updates the correct file ', (done) => {
+ testAction(
+ actions.setPromptConfirmationState,
+ { file: files[0], promptDiscardConfirmation: true },
+ { conflictsData: { files }, getFileIndex: () => 0 },
+ [
+ {
+ type: types.UPDATE_FILE,
+ payload: {
+ file: { ...files[0], promptDiscardConfirmation: true },
+ index: 0,
+ },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('handleSelected', () => {
+ const file = {
+ ...files[0],
+ inlineLines: [{ id: 1, hasConflict: true }, { id: 2 }],
+ parallelLines: [
+ [{ id: 1, hasConflict: true }, { id: 1 }],
+ [{ id: 2 }, { id: 3 }],
+ ],
+ };
+
+ it('updates the correct file ', (done) => {
+ const marLikeMockReturn = { foo: 'bar' };
+ markLine.mockReturnValue(marLikeMockReturn);
+
+ testAction(
+ actions.handleSelected,
+ { file, line: { id: 1, section: 'baz' } },
+ { conflictsData: { files }, getFileIndex: () => 0 },
+ [
+ {
+ type: types.UPDATE_FILE,
+ payload: {
+ file: {
+ ...file,
+ resolutionData: { 1: 'baz' },
+ inlineLines: [marLikeMockReturn, { id: 2 }],
+ parallelLines: [
+ [marLikeMockReturn, marLikeMockReturn],
+ [{ id: 2 }, { id: 3 }],
+ ],
+ },
+ index: 0,
+ },
+ },
+ ],
+ [],
+ () => {
+ expect(markLine).toHaveBeenCalledTimes(3);
+ done();
+ },
+ );
+ });
+ });
+});
diff --git a/spec/frontend/merge_request_spec.js b/spec/frontend/merge_request_spec.js
index 84647a108b2..0b7ed349507 100644
--- a/spec/frontend/merge_request_spec.js
+++ b/spec/frontend/merge_request_spec.js
@@ -9,7 +9,6 @@ describe('MergeRequest', () => {
describe('task lists', () => {
let mock;
- preloadFixtures('merge_requests/merge_request_with_task_list.html');
beforeEach(() => {
loadFixtures('merge_requests/merge_request_with_task_list.html');
diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js
index fd2c240aff3..23e9bf8b447 100644
--- a/spec/frontend/merge_request_tabs_spec.js
+++ b/spec/frontend/merge_request_tabs_spec.js
@@ -21,11 +21,6 @@ describe('MergeRequestTabs', () => {
$.extend(stubLocation, defaults, stubs || {});
};
- preloadFixtures(
- 'merge_requests/merge_request_with_task_list.html',
- 'merge_requests/diff_comment.html',
- );
-
beforeEach(() => {
initMrPage();
diff --git a/spec/frontend/mini_pipeline_graph_dropdown_spec.js b/spec/frontend/mini_pipeline_graph_dropdown_spec.js
index 3ff34c967e4..ccd5a4ea142 100644
--- a/spec/frontend/mini_pipeline_graph_dropdown_spec.js
+++ b/spec/frontend/mini_pipeline_graph_dropdown_spec.js
@@ -5,8 +5,6 @@ import axios from '~/lib/utils/axios_utils';
import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
describe('Mini Pipeline Graph Dropdown', () => {
- preloadFixtures('static/mini_dropdown_graph.html');
-
beforeEach(() => {
loadFixtures('static/mini_dropdown_graph.html');
});
diff --git a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js
index b794d0c571e..400ac2e8f85 100644
--- a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js
@@ -188,7 +188,7 @@ describe('dashboard invalid url parameters', () => {
});
describe('when there is an error', () => {
- const mockError = 'an error ocurred!';
+ const mockError = 'an error occurred!';
beforeEach(() => {
store.commit(`monitoringDashboard/${types.RECEIVE_PANEL_PREVIEW_FAILURE}`, mockError);
diff --git a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js
index 9672f6a315a..51b4106d4b1 100644
--- a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js
+++ b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js
@@ -23,7 +23,7 @@ describe('DuplicateDashboardForm', () => {
findByRef(ref).setValue(val);
};
const setChecked = (value) => {
- const input = wrapper.find(`.form-check-input[value="${value}"]`);
+ const input = wrapper.find(`.custom-control-input[value="${value}"]`);
input.element.checked = true;
input.trigger('click');
input.trigger('change');
diff --git a/spec/frontend/monitoring/requests/index_spec.js b/spec/frontend/monitoring/requests/index_spec.js
index b30b1e60575..03bf5d70153 100644
--- a/spec/frontend/monitoring/requests/index_spec.js
+++ b/spec/frontend/monitoring/requests/index_spec.js
@@ -94,7 +94,7 @@ describe('monitoring metrics_requests', () => {
it('rejects after getting an HTTP 500 error', () => {
mock.onGet(prometheusEndpoint).reply(500, {
status: 'error',
- error: 'An error ocurred',
+ error: 'An error occurred',
});
return getPrometheusQueryData(prometheusEndpoint, params).catch((error) => {
@@ -106,7 +106,7 @@ describe('monitoring metrics_requests', () => {
// Mock multiple attempts while the cache is filling up and fails
mock.onGet(prometheusEndpoint).reply(statusCodes.UNAUTHORIZED, {
status: 'error',
- error: 'An error ocurred',
+ error: 'An error occurred',
});
return getPrometheusQueryData(prometheusEndpoint, params).catch((error) => {
@@ -120,7 +120,7 @@ describe('monitoring metrics_requests', () => {
mock.onGet(prometheusEndpoint).replyOnce(statusCodes.NO_CONTENT);
mock.onGet(prometheusEndpoint).reply(500, {
status: 'error',
- error: 'An error ocurred',
+ error: 'An error occurred',
}); // 3rd attempt
return getPrometheusQueryData(prometheusEndpoint, params).catch((error) => {
diff --git a/spec/frontend/new_branch_spec.js b/spec/frontend/new_branch_spec.js
index 7e6b8a78d4f..66b28a8c0dc 100644
--- a/spec/frontend/new_branch_spec.js
+++ b/spec/frontend/new_branch_spec.js
@@ -9,8 +9,6 @@ describe('Branch', () => {
});
describe('create a new branch', () => {
- preloadFixtures('branches/new_branch.html');
-
function fillNameWith(value) {
$('.js-branch-name').val(value).trigger('blur');
}
diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js
index 2f58f75ab70..bab90723578 100644
--- a/spec/frontend/notes/components/comment_form_spec.js
+++ b/spec/frontend/notes/components/comment_form_spec.js
@@ -1,7 +1,9 @@
+import { GlDropdown, GlAlert } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import Autosize from 'autosize';
import MockAdapter from 'axios-mock-adapter';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
+import Vuex from 'vuex';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { deprecatedCreateFlash as flash } from '~/flash';
@@ -9,7 +11,8 @@ import axios from '~/lib/utils/axios_utils';
import CommentForm from '~/notes/components/comment_form.vue';
import * as constants from '~/notes/constants';
import eventHub from '~/notes/event_hub';
-import createStore from '~/notes/stores';
+import { COMMENT_FORM } from '~/notes/i18n';
+import notesModule from '~/notes/stores/modules';
import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data';
jest.mock('autosize');
@@ -17,15 +20,45 @@ jest.mock('~/commons/nav/user_merge_requests');
jest.mock('~/flash');
jest.mock('~/gl_form');
+Vue.use(Vuex);
+
describe('issue_comment_form component', () => {
let store;
let wrapper;
let axiosMock;
const findCloseReopenButton = () => wrapper.findByTestId('close-reopen-button');
- const findCommentButton = () => wrapper.findByTestId('comment-button');
const findTextArea = () => wrapper.findByTestId('comment-field');
const findConfidentialNoteCheckbox = () => wrapper.findByTestId('confidential-note-checkbox');
+ const findCommentGlDropdown = () => wrapper.find(GlDropdown);
+ const findCommentButton = () => findCommentGlDropdown().find('button');
+ const findErrorAlerts = () => wrapper.findAllComponents(GlAlert).wrappers;
+
+ async function clickCommentButton({ waitForComponent = true, waitForNetwork = true } = {}) {
+ findCommentButton().trigger('click');
+
+ if (waitForComponent || waitForNetwork) {
+ // Wait for the click to bubble out and trigger the handler
+ await nextTick();
+
+ if (waitForNetwork) {
+ // Wait for the network request promise to resolve
+ await nextTick();
+ }
+ }
+ }
+
+ function createStore({ actions = {} } = {}) {
+ const baseModule = notesModule();
+
+ return new Vuex.Store({
+ ...baseModule,
+ actions: {
+ ...baseModule.actions,
+ ...actions,
+ },
+ });
+ }
const createNotableDataMock = (data = {}) => {
return {
@@ -101,6 +134,83 @@ describe('issue_comment_form component', () => {
expect(wrapper.vm.resizeTextarea).toHaveBeenCalled();
});
+ it('does not report errors in the UI when the save succeeds', async () => {
+ mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' } });
+
+ jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue();
+
+ await clickCommentButton();
+
+ // findErrorAlerts().exists returns false if *any* wrapper is empty,
+ // not necessarily that there aren't any at all.
+ // We want to check here that there are none found, so we use the
+ // raw wrapper array length instead.
+ expect(findErrorAlerts().length).toBe(0);
+ });
+
+ it.each`
+ httpStatus | errors
+ ${400} | ${[COMMENT_FORM.GENERIC_UNSUBMITTABLE_NETWORK]}
+ ${422} | ${['error 1']}
+ ${422} | ${['error 1', 'error 2']}
+ ${422} | ${['error 1', 'error 2', 'error 3']}
+ `(
+ 'displays the correct errors ($errors) for a $httpStatus network response',
+ async ({ errors, httpStatus }) => {
+ store = createStore({
+ actions: {
+ saveNote: jest.fn().mockRejectedValue({
+ response: { status: httpStatus, data: { errors: { commands_only: errors } } },
+ }),
+ },
+ });
+
+ mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' } });
+
+ await clickCommentButton();
+
+ const errorAlerts = findErrorAlerts();
+
+ expect(errorAlerts.length).toBe(errors.length);
+ errors.forEach((msg, index) => {
+ const alert = errorAlerts[index];
+
+ expect(alert.text()).toBe(msg);
+ });
+ },
+ );
+
+ it('should remove the correct error from the list when it is dismissed', async () => {
+ const commandErrors = ['1', '2', '3'];
+ store = createStore({
+ actions: {
+ saveNote: jest.fn().mockRejectedValue({
+ response: { status: 422, data: { errors: { commands_only: [...commandErrors] } } },
+ }),
+ },
+ });
+
+ mountComponent({ mountFunction: mount, initialData: { note: '/label ~sdfghj' } });
+
+ await clickCommentButton();
+
+ let errorAlerts = findErrorAlerts();
+
+ expect(errorAlerts.length).toBe(commandErrors.length);
+
+ // dismiss the second error
+ extendedWrapper(errorAlerts[1]).findByTestId('close-icon').trigger('click');
+ // Wait for the dismissal to bubble out of the Alert component and be handled in this component
+ await nextTick();
+ // Refresh the list of alerts
+ errorAlerts = findErrorAlerts();
+
+ expect(errorAlerts.length).toBe(commandErrors.length - 1);
+ // We want to know that the *correct* error was dismissed, not just that any one is gone
+ expect(errorAlerts[0].text()).toBe(commandErrors[0]);
+ expect(errorAlerts[1].text()).toBe(commandErrors[2]);
+ });
+
it('should toggle issue state when no note', () => {
mountComponent({ mountFunction: mount });
@@ -243,7 +353,7 @@ describe('issue_comment_form component', () => {
it('should render comment button as disabled', () => {
mountComponent();
- expect(findCommentButton().props('disabled')).toBe(true);
+ expect(findCommentGlDropdown().props('disabled')).toBe(true);
});
it('should enable comment button if it has note', async () => {
@@ -251,7 +361,7 @@ describe('issue_comment_form component', () => {
await wrapper.setData({ note: 'Foo' });
- expect(findCommentButton().props('disabled')).toBe(false);
+ expect(findCommentGlDropdown().props('disabled')).toBe(false);
});
it('should update buttons texts when it has note', () => {
@@ -437,7 +547,7 @@ describe('issue_comment_form component', () => {
await wrapper.vm.$nextTick();
// submit comment
- wrapper.findByTestId('comment-button').trigger('click');
+ findCommentButton().trigger('click');
const [providedData] = wrapper.vm.saveNote.mock.calls[0];
expect(providedData.data.note.confidential).toBe(shouldCheckboxBeChecked);
@@ -472,16 +582,4 @@ describe('issue_comment_form component', () => {
expect(findTextArea().exists()).toBe(false);
});
});
-
- describe('close/reopen button variants', () => {
- it.each([
- [constants.OPENED, 'warning'],
- [constants.REOPENED, 'warning'],
- [constants.CLOSED, 'default'],
- ])('when %s, the variant of the btn is %s', (state, expected) => {
- mountComponent({ noteableData: { ...noteableDataMock, state } });
-
- expect(findCloseReopenButton().props('variant')).toBe(expected);
- });
- });
});
diff --git a/spec/frontend/notes/components/diff_discussion_header_spec.js b/spec/frontend/notes/components/diff_discussion_header_spec.js
index fdc89522901..fa34a5e8d39 100644
--- a/spec/frontend/notes/components/diff_discussion_header_spec.js
+++ b/spec/frontend/notes/components/diff_discussion_header_spec.js
@@ -6,14 +6,10 @@ import createStore from '~/notes/stores';
import mockDiffFile from '../../diffs/mock_data/diff_discussions';
import { discussionMock } from '../mock_data';
-const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json';
-
describe('diff_discussion_header component', () => {
let store;
let wrapper;
- preloadFixtures(discussionWithTwoUnresolvedNotes);
-
beforeEach(() => {
window.mrTabs = {};
store = createStore();
diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js
index 03e5842bb0f..c6a7d7ead98 100644
--- a/spec/frontend/notes/components/discussion_actions_spec.js
+++ b/spec/frontend/notes/components/discussion_actions_spec.js
@@ -96,7 +96,7 @@ describe('DiscussionActions', () => {
it('emits showReplyForm event when clicking on reply placeholder', () => {
jest.spyOn(wrapper.vm, '$emit');
- wrapper.find(ReplyPlaceholder).find('button').trigger('click');
+ wrapper.find(ReplyPlaceholder).find('textarea').trigger('focus');
expect(wrapper.vm.$emit).toHaveBeenCalledWith('showReplyForm');
});
diff --git a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js
index b7b7ec08867..2a4cd0df0c7 100644
--- a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js
+++ b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js
@@ -1,17 +1,17 @@
import { shallowMount } from '@vue/test-utils';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
-const buttonText = 'Test Button Text';
+const placeholderText = 'Test Button Text';
describe('ReplyPlaceholder', () => {
let wrapper;
- const findButton = () => wrapper.find({ ref: 'button' });
+ const findTextarea = () => wrapper.find({ ref: 'textarea' });
beforeEach(() => {
wrapper = shallowMount(ReplyPlaceholder, {
propsData: {
- buttonText,
+ placeholderText,
},
});
});
@@ -20,17 +20,17 @@ describe('ReplyPlaceholder', () => {
wrapper.destroy();
});
- it('emits onClick event on button click', () => {
- findButton().trigger('click');
+ it('emits focus event on button click', () => {
+ findTextarea().trigger('focus');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted()).toEqual({
- onClick: [[]],
+ focus: [[]],
});
});
});
it('should render reply button', () => {
- expect(findButton().text()).toEqual(buttonText);
+ expect(findTextarea().attributes('placeholder')).toEqual(placeholderText);
});
});
diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js
index 17717ebd09a..cc41088e21e 100644
--- a/spec/frontend/notes/components/note_actions_spec.js
+++ b/spec/frontend/notes/components/note_actions_spec.js
@@ -6,6 +6,7 @@ import axios from '~/lib/utils/axios_utils';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import noteActions from '~/notes/components/note_actions.vue';
import createStore from '~/notes/stores';
+import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import { userDataMock } from '../mock_data';
describe('noteActions', () => {
@@ -15,6 +16,9 @@ describe('noteActions', () => {
let actions;
let axiosMock;
+ const findUserAccessRoleBadge = (idx) => wrapper.findAll(UserAccessRoleBadge).at(idx);
+ const findUserAccessRoleBadgeText = (idx) => findUserAccessRoleBadge(idx).text().trim();
+
const mountNoteActions = (propsData, computed) => {
const localVue = createLocalVue();
return mount(localVue.extend(noteActions), {
@@ -44,6 +48,7 @@ describe('noteActions', () => {
projectName: 'project',
reportAbusePath: `${TEST_HOST}/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26`,
showReply: false,
+ awardPath: `${TEST_HOST}/award_emoji`,
};
actions = {
@@ -66,11 +71,11 @@ describe('noteActions', () => {
});
it('should render noteable author badge', () => {
- expect(wrapper.findAll('.note-role').at(0).text().trim()).toEqual('Author');
+ expect(findUserAccessRoleBadgeText(0)).toBe('Author');
});
it('should render access level badge', () => {
- expect(wrapper.findAll('.note-role').at(1).text().trim()).toEqual(props.accessLevel);
+ expect(findUserAccessRoleBadgeText(1)).toBe(props.accessLevel);
});
it('should render contributor badge', () => {
@@ -80,7 +85,7 @@ describe('noteActions', () => {
});
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.findAll('.note-role').at(1).text().trim()).toBe('Contributor');
+ expect(findUserAccessRoleBadgeText(1)).toBe('Contributor');
});
});
diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js
index 7615f3b70f1..92137d3190f 100644
--- a/spec/frontend/notes/components/note_form_spec.js
+++ b/spec/frontend/notes/components/note_form_spec.js
@@ -83,7 +83,7 @@ describe('issue_note_form component', () => {
});
const message =
- 'This comment has changed since you started editing, please review the updated comment to ensure information is not lost.';
+ 'This comment changed after you started editing it. Review the updated comment to ensure information is not lost.';
await nextTick();
diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js
index 87538279c3d..dd65351ef88 100644
--- a/spec/frontend/notes/components/noteable_discussion_spec.js
+++ b/spec/frontend/notes/components/noteable_discussion_spec.js
@@ -24,8 +24,6 @@ describe('noteable_discussion component', () => {
let wrapper;
let originalGon;
- preloadFixtures(discussionWithTwoUnresolvedNotes);
-
beforeEach(() => {
window.mrTabs = {};
store = createStore();
@@ -65,7 +63,7 @@ describe('noteable_discussion component', () => {
expect(wrapper.vm.isReplying).toEqual(false);
const replyPlaceholder = wrapper.find(ReplyPlaceholder);
- replyPlaceholder.vm.$emit('onClick');
+ replyPlaceholder.vm.$emit('focus');
await nextTick();
expect(wrapper.vm.isReplying).toEqual(true);
diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js
index fe78e086403..112983f3ac2 100644
--- a/spec/frontend/notes/components/noteable_note_spec.js
+++ b/spec/frontend/notes/components/noteable_note_spec.js
@@ -1,5 +1,6 @@
import { mount, createLocalVue } from '@vue/test-utils';
import { escape } from 'lodash';
+import waitForPromises from 'helpers/wait_for_promises';
import NoteActions from '~/notes/components/note_actions.vue';
import NoteBody from '~/notes/components/note_body.vue';
import NoteHeader from '~/notes/components/note_header.vue';
@@ -13,7 +14,7 @@ describe('issue_note', () => {
let wrapper;
const findMultilineComment = () => wrapper.find('[data-testid="multiline-comment"]');
- beforeEach(() => {
+ const createWrapper = (props = {}) => {
store = createStore();
store.dispatch('setNoteableData', noteableDataMock);
store.dispatch('setNotesData', notesDataMock);
@@ -23,6 +24,7 @@ describe('issue_note', () => {
store,
propsData: {
note,
+ ...props,
},
localVue,
stubs: [
@@ -33,14 +35,18 @@ describe('issue_note', () => {
'multiline-comment-form',
],
});
- });
+ };
afterEach(() => {
wrapper.destroy();
});
describe('mutiline comments', () => {
- it('should render if has multiline comment', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('should render if has multiline comment', async () => {
const position = {
line_range: {
start: {
@@ -69,9 +75,8 @@ describe('issue_note', () => {
line,
});
- return wrapper.vm.$nextTick().then(() => {
- expect(findMultilineComment().text()).toEqual('Comment on lines 1 to 2');
- });
+ await wrapper.vm.$nextTick();
+ expect(findMultilineComment().text()).toBe('Comment on lines 1 to 2');
});
it('should only render if it has everything it needs', () => {
@@ -147,108 +152,151 @@ describe('issue_note', () => {
});
});
- it('should render user information', () => {
- const { author } = note;
- const avatar = wrapper.find(UserAvatarLink);
- const avatarProps = avatar.props();
+ describe('rendering', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
- expect(avatarProps.linkHref).toBe(author.path);
- expect(avatarProps.imgSrc).toBe(author.avatar_url);
- expect(avatarProps.imgAlt).toBe(author.name);
- expect(avatarProps.imgSize).toBe(40);
- });
+ it('should render user information', () => {
+ const { author } = note;
+ const avatar = wrapper.findComponent(UserAvatarLink);
+ const avatarProps = avatar.props();
- it('should render note header content', () => {
- const noteHeader = wrapper.find(NoteHeader);
- const noteHeaderProps = noteHeader.props();
+ expect(avatarProps.linkHref).toBe(author.path);
+ expect(avatarProps.imgSrc).toBe(author.avatar_url);
+ expect(avatarProps.imgAlt).toBe(author.name);
+ expect(avatarProps.imgSize).toBe(40);
+ });
- expect(noteHeaderProps.author).toEqual(note.author);
- expect(noteHeaderProps.createdAt).toEqual(note.created_at);
- expect(noteHeaderProps.noteId).toEqual(note.id);
- });
+ it('should render note header content', () => {
+ const noteHeader = wrapper.findComponent(NoteHeader);
+ const noteHeaderProps = noteHeader.props();
- it('should render note actions', () => {
- const { author } = note;
- const noteActions = wrapper.find(NoteActions);
- const noteActionsProps = noteActions.props();
-
- expect(noteActionsProps.authorId).toBe(author.id);
- expect(noteActionsProps.noteId).toBe(note.id);
- expect(noteActionsProps.noteUrl).toBe(note.noteable_note_url);
- expect(noteActionsProps.accessLevel).toBe(note.human_access);
- expect(noteActionsProps.canEdit).toBe(note.current_user.can_edit);
- expect(noteActionsProps.canAwardEmoji).toBe(note.current_user.can_award_emoji);
- expect(noteActionsProps.canDelete).toBe(note.current_user.can_edit);
- expect(noteActionsProps.canReportAsAbuse).toBe(true);
- expect(noteActionsProps.canResolve).toBe(false);
- expect(noteActionsProps.reportAbusePath).toBe(note.report_abuse_path);
- expect(noteActionsProps.resolvable).toBe(false);
- expect(noteActionsProps.isResolved).toBe(false);
- expect(noteActionsProps.isResolving).toBe(false);
- expect(noteActionsProps.resolvedBy).toEqual({});
- });
+ expect(noteHeaderProps.author).toBe(note.author);
+ expect(noteHeaderProps.createdAt).toBe(note.created_at);
+ expect(noteHeaderProps.noteId).toBe(note.id);
+ });
- it('should render issue body', () => {
- const noteBody = wrapper.find(NoteBody);
- const noteBodyProps = noteBody.props();
+ it('should render note actions', () => {
+ const { author } = note;
+ const noteActions = wrapper.findComponent(NoteActions);
+ const noteActionsProps = noteActions.props();
- expect(noteBodyProps.note).toEqual(note);
- expect(noteBodyProps.line).toBe(null);
- expect(noteBodyProps.canEdit).toBe(note.current_user.can_edit);
- expect(noteBodyProps.isEditing).toBe(false);
- expect(noteBodyProps.helpPagePath).toBe('');
- });
+ expect(noteActionsProps.authorId).toBe(author.id);
+ expect(noteActionsProps.noteId).toBe(note.id);
+ expect(noteActionsProps.noteUrl).toBe(note.noteable_note_url);
+ expect(noteActionsProps.accessLevel).toBe(note.human_access);
+ expect(noteActionsProps.canEdit).toBe(note.current_user.can_edit);
+ expect(noteActionsProps.canAwardEmoji).toBe(note.current_user.can_award_emoji);
+ expect(noteActionsProps.canDelete).toBe(note.current_user.can_edit);
+ expect(noteActionsProps.canReportAsAbuse).toBe(true);
+ expect(noteActionsProps.canResolve).toBe(false);
+ expect(noteActionsProps.reportAbusePath).toBe(note.report_abuse_path);
+ expect(noteActionsProps.resolvable).toBe(false);
+ expect(noteActionsProps.isResolved).toBe(false);
+ expect(noteActionsProps.isResolving).toBe(false);
+ expect(noteActionsProps.resolvedBy).toEqual({});
+ });
- it('prevents note preview xss', (done) => {
- const imgSrc = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
- const noteBody = `<img src="${imgSrc}" onload="alert(1)" />`;
- const alertSpy = jest.spyOn(window, 'alert');
- store.hotUpdate({
- actions: {
- updateNote() {},
- setSelectedCommentPositionHover() {},
- },
+ it('should render issue body', () => {
+ const noteBody = wrapper.findComponent(NoteBody);
+ const noteBodyProps = noteBody.props();
+
+ expect(noteBodyProps.note).toBe(note);
+ expect(noteBodyProps.line).toBe(null);
+ expect(noteBodyProps.canEdit).toBe(note.current_user.can_edit);
+ expect(noteBodyProps.isEditing).toBe(false);
+ expect(noteBodyProps.helpPagePath).toBe('');
});
- const noteBodyComponent = wrapper.find(NoteBody);
- noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {});
+ it('prevents note preview xss', async () => {
+ const noteBody =
+ '<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" onload="alert(1)" />';
+ const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
+ const noteBodyComponent = wrapper.findComponent(NoteBody);
+
+ store.hotUpdate({
+ actions: {
+ updateNote() {},
+ setSelectedCommentPositionHover() {},
+ },
+ });
+
+ noteBodyComponent.vm.$emit('handleFormUpdate', noteBody, null, () => {});
- setImmediate(() => {
+ await waitForPromises();
expect(alertSpy).not.toHaveBeenCalled();
- expect(wrapper.vm.note.note_html).toEqual(escape(noteBody));
- done();
+ expect(wrapper.vm.note.note_html).toBe(escape(noteBody));
});
});
describe('cancel edit', () => {
- it('restores content of updated note', (done) => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('restores content of updated note', async () => {
const updatedText = 'updated note text';
store.hotUpdate({
actions: {
updateNote() {},
},
});
- const noteBody = wrapper.find(NoteBody);
+ const noteBody = wrapper.findComponent(NoteBody);
noteBody.vm.resetAutoSave = () => {};
noteBody.vm.$emit('handleFormUpdate', updatedText, null, () => {});
- wrapper.vm
- .$nextTick()
- .then(() => {
- const noteBodyProps = noteBody.props();
-
- expect(noteBodyProps.note.note_html).toBe(updatedText);
- noteBody.vm.$emit('cancelForm');
- })
- .then(() => wrapper.vm.$nextTick())
- .then(() => {
- const noteBodyProps = noteBody.props();
-
- expect(noteBodyProps.note.note_html).toBe(note.note_html);
- })
- .then(done)
- .catch(done.fail);
+ await wrapper.vm.$nextTick();
+ let noteBodyProps = noteBody.props();
+
+ expect(noteBodyProps.note.note_html).toBe(updatedText);
+
+ noteBody.vm.$emit('cancelForm');
+ await wrapper.vm.$nextTick();
+
+ noteBodyProps = noteBody.props();
+
+ expect(noteBodyProps.note.note_html).toBe(note.note_html);
+ });
+ });
+
+ describe('formUpdateHandler', () => {
+ const updateNote = jest.fn();
+ const params = ['', null, jest.fn(), ''];
+
+ const updateActions = () => {
+ store.hotUpdate({
+ actions: {
+ updateNote,
+ setSelectedCommentPositionHover() {},
+ },
+ });
+ };
+
+ afterEach(() => updateNote.mockReset());
+
+ it('responds to handleFormUpdate', () => {
+ createWrapper();
+ updateActions();
+ wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params);
+ expect(wrapper.emitted('handleUpdateNote')).toBeTruthy();
+ });
+
+ it('does not stringify empty position', () => {
+ createWrapper();
+ updateActions();
+ wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params);
+ expect(updateNote.mock.calls[0][1].note.note.position).toBeUndefined();
+ });
+
+ it('stringifies populated position', () => {
+ const position = { test: true };
+ const expectation = JSON.stringify(position);
+ createWrapper({ note: { ...note, position } });
+ updateActions();
+ wrapper.findComponent(NoteBody).vm.$emit('handleFormUpdate', ...params);
+ expect(updateNote.mock.calls[0][1].note.note.position).toBe(expectation);
});
});
});
diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js
index efee72dea96..163501d5ce8 100644
--- a/spec/frontend/notes/components/notes_app_spec.js
+++ b/spec/frontend/notes/components/notes_app_spec.js
@@ -33,6 +33,8 @@ describe('note_app', () => {
let wrapper;
let store;
+ const findCommentButton = () => wrapper.find('[data-testid="comment-button"]');
+
const getComponentOrder = () => {
return wrapper
.findAll('#notes-list,.js-comment-form')
@@ -144,7 +146,7 @@ describe('note_app', () => {
});
it('should render form comment button as disabled', () => {
- expect(wrapper.find('.js-note-new-discussion').attributes('disabled')).toEqual('disabled');
+ expect(findCommentButton().props('disabled')).toEqual(true);
});
it('updates discussions badge', () => {
diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js
index 1852108b39f..f972ff0d2e4 100644
--- a/spec/frontend/notes/stores/actions_spec.js
+++ b/spec/frontend/notes/stores/actions_spec.js
@@ -3,6 +3,7 @@ import testAction from 'helpers/vuex_action_helper';
import { TEST_HOST } from 'spec/test_constants';
import Api from '~/api';
import { deprecatedCreateFlash as Flash } from '~/flash';
+import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
import axios from '~/lib/utils/axios_utils';
import * as notesConstants from '~/notes/constants';
import createStore from '~/notes/stores';
@@ -10,7 +11,6 @@ import * as actions from '~/notes/stores/actions';
import * as mutationTypes from '~/notes/stores/mutation_types';
import mutations from '~/notes/stores/mutations';
import * as utils from '~/notes/stores/utils';
-import updateIssueConfidentialMutation from '~/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql';
import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql';
import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql';
import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub';
@@ -203,7 +203,7 @@ describe('Actions Notes Store', () => {
describe('emitStateChangedEvent', () => {
it('emits an event on the document', () => {
- document.addEventListener('issuable_vue_app:change', (event) => {
+ document.addEventListener(EVENT_ISSUABLE_VUE_APP_CHANGE, (event) => {
expect(event.detail.data).toEqual({ id: '1', state: 'closed' });
expect(event.detail.isClosed).toEqual(false);
});
@@ -1276,68 +1276,6 @@ describe('Actions Notes Store', () => {
});
});
- describe('updateConfidentialityOnIssuable', () => {
- state = { noteableData: { confidential: false } };
- const iid = '1';
- const projectPath = 'full/path';
- const getters = { getNoteableData: { iid } };
- const actionArgs = { fullPath: projectPath, confidential: true };
- const confidential = true;
-
- beforeEach(() => {
- jest
- .spyOn(utils.gqClient, 'mutate')
- .mockResolvedValue({ data: { issueSetConfidential: { issue: { confidential } } } });
- });
-
- it('calls gqClient mutation one time', () => {
- actions.updateConfidentialityOnIssuable({ commit: () => {}, state, getters }, actionArgs);
-
- expect(utils.gqClient.mutate).toHaveBeenCalledTimes(1);
- });
-
- it('calls gqClient mutation with the correct values', () => {
- actions.updateConfidentialityOnIssuable({ commit: () => {}, state, getters }, actionArgs);
-
- expect(utils.gqClient.mutate).toHaveBeenCalledWith({
- mutation: updateIssueConfidentialMutation,
- variables: { input: { iid, projectPath, confidential } },
- });
- });
-
- describe('on success of mutation', () => {
- it('calls commit with the correct values', () => {
- const commitSpy = jest.fn();
-
- return actions
- .updateConfidentialityOnIssuable({ commit: commitSpy, state, getters }, actionArgs)
- .then(() => {
- expect(Flash).not.toHaveBeenCalled();
- expect(commitSpy).toHaveBeenCalledWith(
- mutationTypes.SET_ISSUE_CONFIDENTIAL,
- confidential,
- );
- });
- });
- });
-
- describe('on user recoverable error', () => {
- it('sends the error to Flash', () => {
- const error = 'error';
-
- jest
- .spyOn(utils.gqClient, 'mutate')
- .mockResolvedValue({ data: { issueSetConfidential: { errors: [error] } } });
-
- return actions
- .updateConfidentialityOnIssuable({ commit: () => {}, state, getters }, actionArgs)
- .then(() => {
- expect(Flash).toHaveBeenCalledWith(error, 'alert');
- });
- });
- });
- });
-
describe.each`
issuableType
${'issue'} | ${'merge_request'}
diff --git a/spec/frontend/notes/stores/getters_spec.js b/spec/frontend/notes/stores/getters_spec.js
index 4ebfc679310..4d2f86a1ecf 100644
--- a/spec/frontend/notes/stores/getters_spec.js
+++ b/spec/frontend/notes/stores/getters_spec.js
@@ -26,8 +26,6 @@ const createDiscussionNeighborParams = (discussionId, diffOrder, step) => ({
describe('Getters Notes Store', () => {
let state;
- preloadFixtures(discussionWithTwoUnresolvedNotes);
-
beforeEach(() => {
state = {
discussions: [individualNote],
diff --git a/spec/frontend/notifications/components/custom_notifications_modal_spec.js b/spec/frontend/notifications/components/custom_notifications_modal_spec.js
index 3e87f3107bd..5e4114d91f5 100644
--- a/spec/frontend/notifications/components/custom_notifications_modal_spec.js
+++ b/spec/frontend/notifications/components/custom_notifications_modal_spec.js
@@ -180,7 +180,7 @@ describe('CustomNotificationsModal', () => {
expect(
mockToastShow,
).toHaveBeenCalledWith(
- 'An error occured while loading the notification settings. Please try again.',
+ 'An error occurred while loading the notification settings. Please try again.',
{ type: 'error' },
);
});
@@ -258,7 +258,7 @@ describe('CustomNotificationsModal', () => {
expect(
mockToastShow,
).toHaveBeenCalledWith(
- 'An error occured while updating the notification settings. Please try again.',
+ 'An error occurred while updating the notification settings. Please try again.',
{ type: 'error' },
);
});
diff --git a/spec/frontend/notifications/components/notifications_dropdown_spec.js b/spec/frontend/notifications/components/notifications_dropdown_spec.js
index 0673fb51a91..e90bd68d067 100644
--- a/spec/frontend/notifications/components/notifications_dropdown_spec.js
+++ b/spec/frontend/notifications/components/notifications_dropdown_spec.js
@@ -1,4 +1,4 @@
-import { GlButtonGroup, GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
@@ -15,14 +15,10 @@ const mockToastShow = jest.fn();
describe('NotificationsDropdown', () => {
let wrapper;
let mockAxios;
- let glModalDirective;
function createComponent(injectedProperties = {}) {
- glModalDirective = jest.fn();
-
return shallowMount(NotificationsDropdown, {
stubs: {
- GlButtonGroup,
GlDropdown,
GlDropdownItem,
NotificationsDropdownItem,
@@ -30,11 +26,6 @@ describe('NotificationsDropdown', () => {
},
directives: {
GlTooltip: createMockDirective(),
- glModal: {
- bind(_, { value }) {
- glModalDirective(value);
- },
- },
},
provide: {
dropdownItems: mockDropdownItems,
@@ -49,13 +40,12 @@ describe('NotificationsDropdown', () => {
});
}
- const findButtonGroup = () => wrapper.find(GlButtonGroup);
- const findButton = () => wrapper.find(GlButton);
const findDropdown = () => wrapper.find(GlDropdown);
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
const findAllNotificationsDropdownItems = () => wrapper.findAll(NotificationsDropdownItem);
const findDropdownItemAt = (index) =>
findAllNotificationsDropdownItems().at(index).find(GlDropdownItem);
+ const findNotificationsModal = () => wrapper.find(CustomNotificationsModal);
const clickDropdownItemAt = async (index) => {
const dropdownItem = findDropdownItemAt(index);
@@ -83,8 +73,8 @@ describe('NotificationsDropdown', () => {
});
});
- it('renders a button group', () => {
- expect(findButtonGroup().exists()).toBe(true);
+ it('renders split dropdown', () => {
+ expect(findDropdown().props().split).toBe(true);
});
it('shows the button text when showLabel is true', () => {
@@ -93,7 +83,7 @@ describe('NotificationsDropdown', () => {
showLabel: true,
});
- expect(findButton().text()).toBe('Custom');
+ expect(findDropdown().props().text).toBe('Custom');
});
it("doesn't show the button text when showLabel is false", () => {
@@ -102,7 +92,7 @@ describe('NotificationsDropdown', () => {
showLabel: false,
});
- expect(findButton().text()).toBe('');
+ expect(findDropdown().props().text).toBe(null);
});
it('opens the modal when the user clicks the button', async () => {
@@ -113,9 +103,9 @@ describe('NotificationsDropdown', () => {
initialNotificationLevel: 'custom',
});
- findButton().vm.$emit('click');
+ await findDropdown().vm.$emit('click');
- expect(glModalDirective).toHaveBeenCalled();
+ expect(findNotificationsModal().props().visible).toBe(true);
});
});
@@ -126,8 +116,8 @@ describe('NotificationsDropdown', () => {
});
});
- it('does not render a button group', () => {
- expect(findButtonGroup().exists()).toBe(false);
+ it('renders unified dropdown', () => {
+ expect(findDropdown().props().split).toBe(false);
});
it('shows the button text when showLabel is true', () => {
@@ -162,7 +152,7 @@ describe('NotificationsDropdown', () => {
initialNotificationLevel: level,
});
- const tooltipElement = findByTestId('notificationButton');
+ const tooltipElement = findByTestId('notification-dropdown');
const tooltip = getBinding(tooltipElement.element, 'gl-tooltip');
expect(tooltip.value.title).toBe(`${tooltipTitlePrefix} - ${title}`);
@@ -255,7 +245,7 @@ describe('NotificationsDropdown', () => {
expect(
mockToastShow,
).toHaveBeenCalledWith(
- 'An error occured while updating the notification settings. Please try again.',
+ 'An error occurred while updating the notification settings. Please try again.',
{ type: 'error' },
);
});
@@ -264,11 +254,9 @@ describe('NotificationsDropdown', () => {
mockAxios.onPut('/api/v4/notification_settings').reply(httpStatus.OK, {});
wrapper = createComponent();
- const mockModalShow = jest.spyOn(wrapper.vm.$refs.customNotificationsModal, 'open');
-
await clickDropdownItemAt(5);
- expect(mockModalShow).toHaveBeenCalled();
+ expect(findNotificationsModal().props().visible).toBe(true);
});
});
});
diff --git a/spec/frontend/oauth_remember_me_spec.js b/spec/frontend/oauth_remember_me_spec.js
index 910676a97ed..70bda1d9f9e 100644
--- a/spec/frontend/oauth_remember_me_spec.js
+++ b/spec/frontend/oauth_remember_me_spec.js
@@ -6,8 +6,6 @@ describe('OAuthRememberMe', () => {
return $(`#oauth-container .oauth-login${selector}`).parent('form').attr('action');
};
- preloadFixtures('static/oauth_remember_me.html');
-
beforeEach(() => {
loadFixtures('static/oauth_remember_me.html');
diff --git a/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap
index a1d08f032bc..a3423e3f4d7 100644
--- a/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap
+++ b/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap
@@ -2,11 +2,10 @@
exports[`ConanInstallation renders all the messages 1`] = `
<div>
- <h3
- class="gl-font-lg"
- >
- Installation
- </h3>
+ <installation-title-stub
+ options="[object Object]"
+ packagetype="conan"
+ />
<code-instruction-stub
copytext="Copy Conan Command"
diff --git a/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap
index 6d22b372d41..a6bb9e868ee 100644
--- a/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap
+++ b/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap
@@ -1,12 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`MavenInstallation renders all the messages 1`] = `
+exports[`MavenInstallation gradle renders all the messages 1`] = `
<div>
- <h3
- class="gl-font-lg"
- >
- Installation
- </h3>
+ <installation-title-stub
+ options="[object Object],[object Object]"
+ packagetype="maven"
+ />
+
+ <code-instruction-stub
+ class="gl-mb-5"
+ copytext="Copy Gradle Groovy DSL install command"
+ instruction="foo/gradle/install"
+ label="Gradle Groovy DSL install command"
+ trackingaction="copy_gradle_install_command"
+ trackinglabel="code_instruction"
+ />
+
+ <code-instruction-stub
+ copytext="Copy add Gradle Groovy DSL repository command"
+ instruction="foo/gradle/add/source"
+ label="Add Gradle Groovy DSL repository command"
+ multiline="true"
+ trackingaction="copy_gradle_add_to_source_command"
+ trackinglabel="code_instruction"
+ />
+</div>
+`;
+
+exports[`MavenInstallation maven renders all the messages 1`] = `
+<div>
+ <installation-title-stub
+ options="[object Object],[object Object]"
+ packagetype="maven"
+ />
<p>
<gl-sprintf-stub
@@ -17,7 +43,7 @@ exports[`MavenInstallation renders all the messages 1`] = `
<code-instruction-stub
copytext="Copy Maven XML"
instruction="foo/xml"
- label="Maven XML"
+ label=""
multiline="true"
trackingaction="copy_maven_xml"
trackinglabel="code_instruction"
diff --git a/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap
index b616751f75f..6903d342d6a 100644
--- a/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap
+++ b/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap
@@ -2,11 +2,10 @@
exports[`NpmInstallation renders all the messages 1`] = `
<div>
- <h3
- class="gl-font-lg"
- >
- Installation
- </h3>
+ <installation-title-stub
+ options="[object Object]"
+ packagetype="npm"
+ />
<code-instruction-stub
copytext="Copy npm command"
diff --git a/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap
index 84cf5e4db49..04532743952 100644
--- a/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap
+++ b/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap
@@ -2,11 +2,10 @@
exports[`NugetInstallation renders all the messages 1`] = `
<div>
- <h3
- class="gl-font-lg"
- >
- Installation
- </h3>
+ <installation-title-stub
+ options="[object Object]"
+ packagetype="nuget"
+ />
<code-instruction-stub
copytext="Copy NuGet Command"
diff --git a/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap
index 2a588f99c1d..d5bb825d8d1 100644
--- a/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap
+++ b/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap
@@ -2,11 +2,10 @@
exports[`PypiInstallation renders all the messages 1`] = `
<div>
- <h3
- class="gl-font-lg"
- >
- Installation
- </h3>
+ <installation-title-stub
+ options="[object Object]"
+ packagetype="pypi"
+ />
<code-instruction-stub
copytext="Copy Pip command"
diff --git a/spec/frontend/packages/details/components/composer_installation_spec.js b/spec/frontend/packages/details/components/composer_installation_spec.js
index a1d30d0ed22..18d11c7dd57 100644
--- a/spec/frontend/packages/details/components/composer_installation_spec.js
+++ b/spec/frontend/packages/details/components/composer_installation_spec.js
@@ -4,6 +4,7 @@ import Vuex from 'vuex';
import { registryUrl as composerHelpPath } from 'jest/packages/details/mock_data';
import { composerPackage as packageEntity } from 'jest/packages/mock_data';
import ComposerInstallation from '~/packages/details/components/composer_installation.vue';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import { TrackingActions } from '~/packages/details/constants';
@@ -33,6 +34,7 @@ describe('ComposerInstallation', () => {
const findPackageInclude = () => wrapper.find('[data-testid="package-include"]');
const findHelpText = () => wrapper.find('[data-testid="help-text"]');
const findHelpLink = () => wrapper.find(GlLink);
+ const findInstallationTitle = () => wrapper.findComponent(InstallationTitle);
function createComponent() {
wrapper = shallowMount(ComposerInstallation, {
@@ -48,6 +50,19 @@ describe('ComposerInstallation', () => {
wrapper.destroy();
});
+ describe('install command switch', () => {
+ it('has the installation title component', () => {
+ createStore();
+ createComponent();
+
+ expect(findInstallationTitle().exists()).toBe(true);
+ expect(findInstallationTitle().props()).toMatchObject({
+ packageType: 'composer',
+ options: [{ value: 'composer', label: 'Show Composer commands' }],
+ });
+ });
+ });
+
describe('registry include command', () => {
beforeEach(() => {
createStore();
diff --git a/spec/frontend/packages/details/components/conan_installation_spec.js b/spec/frontend/packages/details/components/conan_installation_spec.js
index bf8a92a6350..78a7d265a21 100644
--- a/spec/frontend/packages/details/components/conan_installation_spec.js
+++ b/spec/frontend/packages/details/components/conan_installation_spec.js
@@ -1,6 +1,7 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import ConanInstallation from '~/packages/details/components/conan_installation.vue';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue';
import { conanPackage as packageEntity } from '../../mock_data';
import { registryUrl as conanPath } from '../mock_data';
@@ -26,6 +27,7 @@ describe('ConanInstallation', () => {
});
const findCodeInstructions = () => wrapper.findAll(CodeInstructions);
+ const findInstallationTitle = () => wrapper.findComponent(InstallationTitle);
function createComponent() {
wrapper = shallowMount(ConanInstallation, {
@@ -39,13 +41,23 @@ describe('ConanInstallation', () => {
});
afterEach(() => {
- if (wrapper) wrapper.destroy();
+ wrapper.destroy();
});
it('renders all the messages', () => {
expect(wrapper.element).toMatchSnapshot();
});
+ describe('install command switch', () => {
+ it('has the installation title component', () => {
+ expect(findInstallationTitle().exists()).toBe(true);
+ expect(findInstallationTitle().props()).toMatchObject({
+ packageType: 'conan',
+ options: [{ value: 'conan', label: 'Show Conan commands' }],
+ });
+ });
+ });
+
describe('installation commands', () => {
it('renders the correct command', () => {
expect(findCodeInstructions().at(0).props('instruction')).toBe(conanInstallationCommandStr);
diff --git a/spec/frontend/packages/details/components/installation_title_spec.js b/spec/frontend/packages/details/components/installation_title_spec.js
new file mode 100644
index 00000000000..14e990d3011
--- /dev/null
+++ b/spec/frontend/packages/details/components/installation_title_spec.js
@@ -0,0 +1,58 @@
+import { shallowMount } from '@vue/test-utils';
+
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
+import PersistedDropdownSelection from '~/vue_shared/components/registry/persisted_dropdown_selection.vue';
+
+describe('InstallationTitle', () => {
+ let wrapper;
+
+ const defaultProps = { packageType: 'foo', options: [{ value: 'foo', label: 'bar' }] };
+
+ const findPersistedDropdownSelection = () => wrapper.findComponent(PersistedDropdownSelection);
+ const findTitle = () => wrapper.find('h3');
+
+ function createComponent({ props = {} } = {}) {
+ wrapper = shallowMount(InstallationTitle, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('has a title', () => {
+ createComponent();
+
+ expect(findTitle().exists()).toBe(true);
+ expect(findTitle().text()).toBe('Installation');
+ });
+
+ describe('persisted dropdown selection', () => {
+ it('exists', () => {
+ createComponent();
+
+ expect(findPersistedDropdownSelection().exists()).toBe(true);
+ });
+
+ it('has the correct props', () => {
+ createComponent();
+
+ expect(findPersistedDropdownSelection().props()).toMatchObject({
+ storageKey: 'package_foo_installation_instructions',
+ options: defaultProps.options,
+ });
+ });
+
+ it('on change event emits a change event', () => {
+ createComponent();
+
+ findPersistedDropdownSelection().vm.$emit('change', 'baz');
+
+ expect(wrapper.emitted('change')).toEqual([['baz']]);
+ });
+ });
+});
diff --git a/spec/frontend/packages/details/components/maven_installation_spec.js b/spec/frontend/packages/details/components/maven_installation_spec.js
index dfeb6002186..d49a7c0b561 100644
--- a/spec/frontend/packages/details/components/maven_installation_spec.js
+++ b/spec/frontend/packages/details/components/maven_installation_spec.js
@@ -1,7 +1,9 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { nextTick } from 'vue';
import Vuex from 'vuex';
import { registryUrl as mavenPath } from 'jest/packages/details/mock_data';
import { mavenPackage as packageEntity } from 'jest/packages/mock_data';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import MavenInstallation from '~/packages/details/components/maven_installation.vue';
import { TrackingActions } from '~/packages/details/constants';
import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue';
@@ -15,6 +17,8 @@ describe('MavenInstallation', () => {
const xmlCodeBlock = 'foo/xml';
const mavenCommandStr = 'foo/command';
const mavenSetupXml = 'foo/setup';
+ const gradleGroovyInstallCommandText = 'foo/gradle/install';
+ const gradleGroovyAddSourceCommandText = 'foo/gradle/add/source';
const store = new Vuex.Store({
state: {
@@ -25,54 +29,120 @@ describe('MavenInstallation', () => {
mavenInstallationXml: () => xmlCodeBlock,
mavenInstallationCommand: () => mavenCommandStr,
mavenSetupXml: () => mavenSetupXml,
+ gradleGroovyInstalCommand: () => gradleGroovyInstallCommandText,
+ gradleGroovyAddSourceCommand: () => gradleGroovyAddSourceCommandText,
},
});
const findCodeInstructions = () => wrapper.findAll(CodeInstructions);
+ const findInstallationTitle = () => wrapper.findComponent(InstallationTitle);
- function createComponent() {
+ function createComponent({ data = {} } = {}) {
wrapper = shallowMount(MavenInstallation, {
localVue,
store,
+ data() {
+ return data;
+ },
});
}
- beforeEach(() => {
- createComponent();
- });
-
afterEach(() => {
- if (wrapper) wrapper.destroy();
+ wrapper.destroy();
});
- it('renders all the messages', () => {
- expect(wrapper.element).toMatchSnapshot();
+ describe('install command switch', () => {
+ it('has the installation title component', () => {
+ createComponent();
+
+ expect(findInstallationTitle().exists()).toBe(true);
+ expect(findInstallationTitle().props()).toMatchObject({
+ packageType: 'maven',
+ options: [
+ { value: 'maven', label: 'Show Maven commands' },
+ { value: 'groovy', label: 'Show Gradle Groovy DSL commands' },
+ ],
+ });
+ });
+
+ it('on change event updates the instructions to show', async () => {
+ createComponent();
+
+ expect(findCodeInstructions().at(0).props('instruction')).toBe(xmlCodeBlock);
+ findInstallationTitle().vm.$emit('change', 'groovy');
+
+ await nextTick();
+
+ expect(findCodeInstructions().at(0).props('instruction')).toBe(
+ gradleGroovyInstallCommandText,
+ );
+ });
});
- describe('installation commands', () => {
- it('renders the correct xml block', () => {
- expect(findCodeInstructions().at(0).props()).toMatchObject({
- instruction: xmlCodeBlock,
- multiline: true,
- trackingAction: TrackingActions.COPY_MAVEN_XML,
+ describe('maven', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders all the messages', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('installation commands', () => {
+ it('renders the correct xml block', () => {
+ expect(findCodeInstructions().at(0).props()).toMatchObject({
+ instruction: xmlCodeBlock,
+ multiline: true,
+ trackingAction: TrackingActions.COPY_MAVEN_XML,
+ });
+ });
+
+ it('renders the correct maven command', () => {
+ expect(findCodeInstructions().at(1).props()).toMatchObject({
+ instruction: mavenCommandStr,
+ multiline: false,
+ trackingAction: TrackingActions.COPY_MAVEN_COMMAND,
+ });
});
});
- it('renders the correct maven command', () => {
- expect(findCodeInstructions().at(1).props()).toMatchObject({
- instruction: mavenCommandStr,
- multiline: false,
- trackingAction: TrackingActions.COPY_MAVEN_COMMAND,
+ describe('setup commands', () => {
+ it('renders the correct xml block', () => {
+ expect(findCodeInstructions().at(2).props()).toMatchObject({
+ instruction: mavenSetupXml,
+ multiline: true,
+ trackingAction: TrackingActions.COPY_MAVEN_SETUP,
+ });
});
});
});
- describe('setup commands', () => {
- it('renders the correct xml block', () => {
- expect(findCodeInstructions().at(2).props()).toMatchObject({
- instruction: mavenSetupXml,
- multiline: true,
- trackingAction: TrackingActions.COPY_MAVEN_SETUP,
+ describe('gradle', () => {
+ beforeEach(() => {
+ createComponent({ data: { instructionType: 'gradle' } });
+ });
+
+ it('renders all the messages', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('installation commands', () => {
+ it('renders the gradle install command', () => {
+ expect(findCodeInstructions().at(0).props()).toMatchObject({
+ instruction: gradleGroovyInstallCommandText,
+ multiline: false,
+ trackingAction: TrackingActions.COPY_GRADLE_INSTALL_COMMAND,
+ });
+ });
+ });
+
+ describe('setup commands', () => {
+ it('renders the correct gradle command', () => {
+ expect(findCodeInstructions().at(1).props()).toMatchObject({
+ instruction: gradleGroovyAddSourceCommandText,
+ multiline: true,
+ trackingAction: TrackingActions.COPY_GRADLE_ADD_TO_SOURCE_COMMAND,
+ });
});
});
});
diff --git a/spec/frontend/packages/details/components/npm_installation_spec.js b/spec/frontend/packages/details/components/npm_installation_spec.js
index df820e7e948..09afcd4fd0a 100644
--- a/spec/frontend/packages/details/components/npm_installation_spec.js
+++ b/spec/frontend/packages/details/components/npm_installation_spec.js
@@ -2,6 +2,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { registryUrl as nugetPath } from 'jest/packages/details/mock_data';
import { npmPackage as packageEntity } from 'jest/packages/mock_data';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import NpmInstallation from '~/packages/details/components/npm_installation.vue';
import { TrackingActions } from '~/packages/details/constants';
import { npmInstallationCommand, npmSetupCommand } from '~/packages/details/store/getters';
@@ -14,6 +15,7 @@ describe('NpmInstallation', () => {
let wrapper;
const findCodeInstructions = () => wrapper.findAll(CodeInstructions);
+ const findInstallationTitle = () => wrapper.findComponent(InstallationTitle);
function createComponent() {
const store = new Vuex.Store({
@@ -38,13 +40,23 @@ describe('NpmInstallation', () => {
});
afterEach(() => {
- if (wrapper) wrapper.destroy();
+ wrapper.destroy();
});
it('renders all the messages', () => {
expect(wrapper.element).toMatchSnapshot();
});
+ describe('install command switch', () => {
+ it('has the installation title component', () => {
+ expect(findInstallationTitle().exists()).toBe(true);
+ expect(findInstallationTitle().props()).toMatchObject({
+ packageType: 'npm',
+ options: [{ value: 'npm', label: 'Show NPM commands' }],
+ });
+ });
+ });
+
describe('installation commands', () => {
it('renders the correct npm command', () => {
expect(findCodeInstructions().at(0).props()).toMatchObject({
diff --git a/spec/frontend/packages/details/components/nuget_installation_spec.js b/spec/frontend/packages/details/components/nuget_installation_spec.js
index 100e369751c..8839a8f1108 100644
--- a/spec/frontend/packages/details/components/nuget_installation_spec.js
+++ b/spec/frontend/packages/details/components/nuget_installation_spec.js
@@ -2,6 +2,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { registryUrl as nugetPath } from 'jest/packages/details/mock_data';
import { nugetPackage as packageEntity } from 'jest/packages/mock_data';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import NugetInstallation from '~/packages/details/components/nuget_installation.vue';
import { TrackingActions } from '~/packages/details/constants';
import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue';
@@ -27,6 +28,7 @@ describe('NugetInstallation', () => {
});
const findCodeInstructions = () => wrapper.findAll(CodeInstructions);
+ const findInstallationTitle = () => wrapper.findComponent(InstallationTitle);
function createComponent() {
wrapper = shallowMount(NugetInstallation, {
@@ -40,13 +42,23 @@ describe('NugetInstallation', () => {
});
afterEach(() => {
- if (wrapper) wrapper.destroy();
+ wrapper.destroy();
});
it('renders all the messages', () => {
expect(wrapper.element).toMatchSnapshot();
});
+ describe('install command switch', () => {
+ it('has the installation title component', () => {
+ expect(findInstallationTitle().exists()).toBe(true);
+ expect(findInstallationTitle().props()).toMatchObject({
+ packageType: 'nuget',
+ options: [{ value: 'nuget', label: 'Show Nuget commands' }],
+ });
+ });
+ });
+
describe('installation commands', () => {
it('renders the correct command', () => {
expect(findCodeInstructions().at(0).props()).toMatchObject({
diff --git a/spec/frontend/packages/details/components/pypi_installation_spec.js b/spec/frontend/packages/details/components/pypi_installation_spec.js
index a6ccba71554..2cec84282d9 100644
--- a/spec/frontend/packages/details/components/pypi_installation_spec.js
+++ b/spec/frontend/packages/details/components/pypi_installation_spec.js
@@ -1,6 +1,7 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { pypiPackage as packageEntity } from 'jest/packages/mock_data';
+import InstallationTitle from '~/packages/details/components/installation_title.vue';
import PypiInstallation from '~/packages/details/components/pypi_installation.vue';
const localVue = createLocalVue();
@@ -26,6 +27,8 @@ describe('PypiInstallation', () => {
const pipCommand = () => wrapper.find('[data-testid="pip-command"]');
const setupInstruction = () => wrapper.find('[data-testid="pypi-setup-content"]');
+ const findInstallationTitle = () => wrapper.findComponent(InstallationTitle);
+
function createComponent() {
wrapper = shallowMount(PypiInstallation, {
localVue,
@@ -39,7 +42,16 @@ describe('PypiInstallation', () => {
afterEach(() => {
wrapper.destroy();
- wrapper = null;
+ });
+
+ describe('install command switch', () => {
+ it('has the installation title component', () => {
+ expect(findInstallationTitle().exists()).toBe(true);
+ expect(findInstallationTitle().props()).toMatchObject({
+ packageType: 'pypi',
+ options: [{ value: 'pypi', label: 'Show PyPi commands' }],
+ });
+ });
});
it('renders all the messages', () => {
diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js
index 07c120f57f7..f12b75d3b70 100644
--- a/spec/frontend/packages/details/store/getters_spec.js
+++ b/spec/frontend/packages/details/store/getters_spec.js
@@ -17,6 +17,8 @@ import {
composerRegistryInclude,
composerPackageInclude,
groupExists,
+ gradleGroovyInstalCommand,
+ gradleGroovyAddSourceCommand,
} from '~/packages/details/store/getters';
import {
conanPackage,
@@ -99,7 +101,7 @@ describe('Getters PackageDetails Store', () => {
packageEntity | expectedResult
${conanPackage} | ${'Conan'}
${packageWithoutBuildInfo} | ${'Maven'}
- ${npmPackage} | ${'NPM'}
+ ${npmPackage} | ${'npm'}
${nugetPackage} | ${'NuGet'}
${pypiPackage} | ${'PyPI'}
`(`package type`, ({ packageEntity, expectedResult }) => {
@@ -168,13 +170,13 @@ describe('Getters PackageDetails Store', () => {
});
describe('npm string getters', () => {
- it('gets the correct npmInstallationCommand for NPM', () => {
+ it('gets the correct npmInstallationCommand for npm', () => {
setupState({ packageEntity: npmPackage });
expect(npmInstallationCommand(state)(NpmManager.NPM)).toBe(npmInstallStr);
});
- it('gets the correct npmSetupCommand for NPM', () => {
+ it('gets the correct npmSetupCommand for npm', () => {
setupState({ packageEntity: npmPackage });
expect(npmSetupCommand(state)(NpmManager.NPM)).toBe(npmSetupStr);
@@ -235,6 +237,26 @@ describe('Getters PackageDetails Store', () => {
});
});
+ describe('gradle groovy string getters', () => {
+ it('gets the correct gradleGroovyInstalCommand', () => {
+ setupState();
+
+ expect(gradleGroovyInstalCommand(state)).toMatchInlineSnapshot(
+ `"implementation 'com.test.app:test-app:1.0-SNAPSHOT'"`,
+ );
+ });
+
+ it('gets the correct gradleGroovyAddSourceCommand', () => {
+ setupState();
+
+ expect(gradleGroovyAddSourceCommand(state)).toMatchInlineSnapshot(`
+ "maven {
+ url 'foo/registry'
+ }"
+ `);
+ });
+ });
+
describe('check if group', () => {
it('is set', () => {
setupState({ groupListUrl: '/groups/composer/-/packages' });
diff --git a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
index 4a75deebcf9..77095f7c611 100644
--- a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
+++ b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
@@ -6,7 +6,7 @@ exports[`packages_list_row renders 1`] = `
data-qa-selector="package_row"
>
<div
- class="gl-display-flex gl-align-items-center gl-py-3"
+ class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5"
>
<!---->
@@ -102,7 +102,7 @@ exports[`packages_list_row renders 1`] = `
<gl-button-stub
aria-label="Remove package"
buttontextclasses=""
- category="primary"
+ category="secondary"
data-testid="action-delete"
icon="remove"
size="medium"
diff --git a/spec/frontend/packages/shared/components/package_list_row_spec.js b/spec/frontend/packages/shared/components/package_list_row_spec.js
index bd122167273..1c0ef7e3539 100644
--- a/spec/frontend/packages/shared/components/package_list_row_spec.js
+++ b/spec/frontend/packages/shared/components/package_list_row_spec.js
@@ -60,11 +60,9 @@ describe('packages_list_row', () => {
});
describe('when is is group', () => {
- beforeEach(() => {
+ it('has a package path component', () => {
mountComponent({ isGroup: true });
- });
- it('has a package path component', () => {
expect(findPackagePath().exists()).toBe(true);
expect(findPackagePath().props()).toMatchObject({ path: 'foo/bar/baz' });
});
@@ -92,10 +90,22 @@ describe('packages_list_row', () => {
});
});
- describe('delete event', () => {
- beforeEach(() => mountComponent({ packageEntity: packageWithoutTags }));
+ describe('delete button', () => {
+ it('exists and has the correct props', () => {
+ mountComponent({ packageEntity: packageWithoutTags });
+
+ expect(findDeleteButton().exists()).toBe(true);
+ expect(findDeleteButton().attributes()).toMatchObject({
+ icon: 'remove',
+ category: 'secondary',
+ variant: 'danger',
+ title: 'Remove package',
+ });
+ });
it('emits the packageToDelete event when the delete button is clicked', async () => {
+ mountComponent({ packageEntity: packageWithoutTags });
+
findDeleteButton().vm.$emit('click');
await wrapper.vm.$nextTick();
diff --git a/spec/frontend/packages/shared/utils_spec.js b/spec/frontend/packages/shared/utils_spec.js
index 506f37f8895..4a95def1bef 100644
--- a/spec/frontend/packages/shared/utils_spec.js
+++ b/spec/frontend/packages/shared/utils_spec.js
@@ -35,7 +35,7 @@ describe('Packages shared utils', () => {
packageType | expectedResult
${'conan'} | ${'Conan'}
${'maven'} | ${'Maven'}
- ${'npm'} | ${'NPM'}
+ ${'npm'} | ${'npm'}
${'nuget'} | ${'NuGet'}
${'pypi'} | ${'PyPI'}
${'composer'} | ${'Composer'}
diff --git a/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js b/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js
index 2c76adf761f..71c9da238b4 100644
--- a/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js
+++ b/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js
@@ -14,8 +14,6 @@ describe('Abuse Reports', () => {
const findMessage = (searchText) =>
$messages.filter((index, element) => element.innerText.indexOf(searchText) > -1).first();
- preloadFixtures(FIXTURE);
-
beforeEach(() => {
loadFixtures(FIXTURE);
new AbuseReports(); // eslint-disable-line no-new
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 8816609d1d2..9f326dc33c0 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
@@ -8,7 +8,6 @@ describe('AccountAndLimits', () => {
const FIXTURE = 'application_settings/accounts_and_limit.html';
let $userDefaultExternal;
let $userInternalRegex;
- preloadFixtures(FIXTURE);
beforeEach(() => {
loadFixtures(FIXTURE);
diff --git a/spec/frontend/pages/admin/users/new/index_spec.js b/spec/frontend/pages/admin/users/new/index_spec.js
index 60482860e84..ec9fe487030 100644
--- a/spec/frontend/pages/admin/users/new/index_spec.js
+++ b/spec/frontend/pages/admin/users/new/index_spec.js
@@ -7,8 +7,6 @@ describe('UserInternalRegexHandler', () => {
let $userEmail;
let $warningMessage;
- preloadFixtures(FIXTURE);
-
beforeEach(() => {
loadFixtures(FIXTURE);
// eslint-disable-next-line no-new
diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js
index fb612f17669..de8b29d54fc 100644
--- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js
+++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js
@@ -14,7 +14,6 @@ const TEST_COUNT_BIG = 2000;
const TEST_DONE_COUNT_BIG = 7300;
describe('Todos', () => {
- preloadFixtures('todos/todos.html');
let todoItem;
let mock;
diff --git a/spec/frontend/pages/projects/forks/new/components/app_spec.js b/spec/frontend/pages/projects/forks/new/components/app_spec.js
new file mode 100644
index 00000000000..e1820606704
--- /dev/null
+++ b/spec/frontend/pages/projects/forks/new/components/app_spec.js
@@ -0,0 +1,42 @@
+import { shallowMount } from '@vue/test-utils';
+import App from '~/pages/projects/forks/new/components/app.vue';
+
+describe('App component', () => {
+ let wrapper;
+
+ const DEFAULT_PROPS = {
+ forkIllustration: 'illustrations/project-create-new-sm.svg',
+ endpoint: '/some/project-full-path/-/forks/new.json',
+ projectFullPath: '/some/project-full-path',
+ projectId: '10',
+ projectName: 'Project Name',
+ projectPath: 'project-name',
+ projectDescription: 'some project description',
+ projectVisibility: 'private',
+ };
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(App, {
+ propsData: {
+ ...DEFAULT_PROPS,
+ ...props,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('displays the correct svg illustration', () => {
+ expect(wrapper.find('img').attributes('src')).toBe('illustrations/project-create-new-sm.svg');
+ });
+
+ it('renders ForkForm component with prop', () => {
+ expect(wrapper.props()).toEqual(expect.objectContaining(DEFAULT_PROPS));
+ });
+});
diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
new file mode 100644
index 00000000000..694a0c2b9c1
--- /dev/null
+++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
@@ -0,0 +1,275 @@
+import { GlForm, GlFormInputGroup } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import axios from 'axios';
+import AxiosMockAdapter from 'axios-mock-adapter';
+import createFlash from '~/flash';
+import httpStatus from '~/lib/utils/http_status';
+import * as urlUtility from '~/lib/utils/url_utility';
+import ForkForm from '~/pages/projects/forks/new/components/fork_form.vue';
+
+jest.mock('~/flash');
+jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
+
+describe('ForkForm component', () => {
+ let wrapper;
+ let axiosMock;
+
+ const GON_GITLAB_URL = 'https://gitlab.com';
+ const GON_API_VERSION = 'v7';
+
+ const MOCK_NAMESPACES_RESPONSE = [
+ {
+ name: 'one',
+ id: 1,
+ },
+ {
+ name: 'two',
+ id: 2,
+ },
+ ];
+
+ const DEFAULT_PROPS = {
+ endpoint: '/some/project-full-path/-/forks/new.json',
+ projectFullPath: '/some/project-full-path',
+ projectId: '10',
+ projectName: 'Project Name',
+ projectPath: 'project-name',
+ projectDescription: 'some project description',
+ projectVisibility: 'private',
+ };
+
+ const mockGetRequest = (data = {}, statusCode = httpStatus.OK) => {
+ axiosMock.onGet(DEFAULT_PROPS.endpoint).replyOnce(statusCode, data);
+ };
+
+ const createComponent = (props = {}, data = {}) => {
+ wrapper = shallowMount(ForkForm, {
+ provide: {
+ newGroupPath: 'some/groups/path',
+ visibilityHelpPath: 'some/visibility/help/path',
+ },
+ propsData: {
+ ...DEFAULT_PROPS,
+ ...props,
+ },
+ data() {
+ return {
+ ...data,
+ };
+ },
+ stubs: {
+ GlFormInputGroup,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ axiosMock = new AxiosMockAdapter(axios);
+ window.gon = {
+ gitlab_url: GON_GITLAB_URL,
+ api_version: GON_API_VERSION,
+ };
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ axiosMock.restore();
+ });
+
+ const findPrivateRadio = () => wrapper.find('[data-testid="radio-private"]');
+ const findInternalRadio = () => wrapper.find('[data-testid="radio-internal"]');
+ const findPublicRadio = () => wrapper.find('[data-testid="radio-public"]');
+ const findForkNameInput = () => wrapper.find('[data-testid="fork-name-input"]');
+ const findForkUrlInput = () => wrapper.find('[data-testid="fork-url-input"]');
+ const findForkSlugInput = () => wrapper.find('[data-testid="fork-slug-input"]');
+ const findForkDescriptionTextarea = () =>
+ wrapper.find('[data-testid="fork-description-textarea"]');
+ const findVisibilityRadioGroup = () =>
+ wrapper.find('[data-testid="fork-visibility-radio-group"]');
+
+ it('will go to projectFullPath when click cancel button', () => {
+ mockGetRequest();
+ createComponent();
+
+ const { projectFullPath } = DEFAULT_PROPS;
+ const cancelButton = wrapper.find('[data-testid="cancel-button"]');
+
+ expect(cancelButton.attributes('href')).toBe(projectFullPath);
+ });
+
+ it('make POST request with project param', async () => {
+ jest.spyOn(axios, 'post');
+
+ const namespaceId = 20;
+
+ mockGetRequest();
+ createComponent(
+ {},
+ {
+ selectedNamespace: {
+ id: namespaceId,
+ },
+ },
+ );
+
+ wrapper.find(GlForm).vm.$emit('submit', { preventDefault: () => {} });
+
+ const {
+ projectId,
+ projectDescription,
+ projectName,
+ projectPath,
+ projectVisibility,
+ } = DEFAULT_PROPS;
+
+ const url = `/api/${GON_API_VERSION}/projects/${projectId}/fork`;
+ const project = {
+ description: projectDescription,
+ id: projectId,
+ name: projectName,
+ namespace_id: namespaceId,
+ path: projectPath,
+ visibility: projectVisibility,
+ };
+
+ expect(axios.post).toHaveBeenCalledWith(url, project);
+ });
+
+ it('has input with csrf token', () => {
+ mockGetRequest();
+ createComponent();
+
+ expect(wrapper.find('input[name="authenticity_token"]').attributes('value')).toBe(
+ 'mock-csrf-token',
+ );
+ });
+
+ it('pre-populate form from project props', () => {
+ mockGetRequest();
+ createComponent();
+
+ expect(findForkNameInput().attributes('value')).toBe(DEFAULT_PROPS.projectName);
+ expect(findForkSlugInput().attributes('value')).toBe(DEFAULT_PROPS.projectPath);
+ expect(findForkDescriptionTextarea().attributes('value')).toBe(
+ DEFAULT_PROPS.projectDescription,
+ );
+ });
+
+ it('sets project URL prepend text with gon.gitlab_url', () => {
+ mockGetRequest();
+ createComponent();
+
+ expect(wrapper.find(GlFormInputGroup).text()).toContain(`${GON_GITLAB_URL}/`);
+ });
+
+ it('will have required attribute for required fields', () => {
+ mockGetRequest();
+ createComponent();
+
+ expect(findForkNameInput().attributes('required')).not.toBeUndefined();
+ expect(findForkUrlInput().attributes('required')).not.toBeUndefined();
+ expect(findForkSlugInput().attributes('required')).not.toBeUndefined();
+ expect(findVisibilityRadioGroup().attributes('required')).not.toBeUndefined();
+ expect(findForkDescriptionTextarea().attributes('required')).toBeUndefined();
+ });
+
+ describe('forks namespaces', () => {
+ beforeEach(() => {
+ mockGetRequest({ namespaces: MOCK_NAMESPACES_RESPONSE });
+ createComponent();
+ });
+
+ it('make GET request from endpoint', async () => {
+ await axios.waitForAll();
+
+ expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROPS.endpoint);
+ });
+
+ it('generate default option', async () => {
+ await axios.waitForAll();
+
+ const optionsArray = findForkUrlInput().findAll('option');
+
+ expect(optionsArray.at(0).text()).toBe('Select a namespace');
+ });
+
+ it('populate project url namespace options', async () => {
+ await axios.waitForAll();
+
+ const optionsArray = findForkUrlInput().findAll('option');
+
+ expect(optionsArray).toHaveLength(MOCK_NAMESPACES_RESPONSE.length + 1);
+ expect(optionsArray.at(1).text()).toBe(MOCK_NAMESPACES_RESPONSE[0].name);
+ expect(optionsArray.at(2).text()).toBe(MOCK_NAMESPACES_RESPONSE[1].name);
+ });
+ });
+
+ describe('visibility level', () => {
+ it.each`
+ project | namespace | privateIsDisabled | internalIsDisabled | publicIsDisabled
+ ${'private'} | ${'private'} | ${undefined} | ${'true'} | ${'true'}
+ ${'private'} | ${'internal'} | ${undefined} | ${'true'} | ${'true'}
+ ${'private'} | ${'public'} | ${undefined} | ${'true'} | ${'true'}
+ ${'internal'} | ${'private'} | ${undefined} | ${'true'} | ${'true'}
+ ${'internal'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'}
+ ${'internal'} | ${'public'} | ${undefined} | ${undefined} | ${'true'}
+ ${'public'} | ${'private'} | ${undefined} | ${'true'} | ${'true'}
+ ${'public'} | ${'internal'} | ${undefined} | ${undefined} | ${'true'}
+ ${'public'} | ${'public'} | ${undefined} | ${undefined} | ${undefined}
+ `(
+ 'sets appropriate radio button disabled state',
+ async ({ project, namespace, privateIsDisabled, internalIsDisabled, publicIsDisabled }) => {
+ mockGetRequest();
+ createComponent(
+ {
+ projectVisibility: project,
+ },
+ {
+ selectedNamespace: {
+ visibility: namespace,
+ },
+ },
+ );
+
+ expect(findPrivateRadio().attributes('disabled')).toBe(privateIsDisabled);
+ expect(findInternalRadio().attributes('disabled')).toBe(internalIsDisabled);
+ expect(findPublicRadio().attributes('disabled')).toBe(publicIsDisabled);
+ },
+ );
+ });
+
+ describe('onSubmit', () => {
+ beforeEach(() => {
+ jest.spyOn(urlUtility, 'redirectTo').mockImplementation();
+ });
+
+ it('redirect to POST web_url response', async () => {
+ const webUrl = `new/fork-project`;
+
+ jest.spyOn(axios, 'post').mockResolvedValue({ data: { web_url: webUrl } });
+
+ mockGetRequest();
+ createComponent();
+
+ await wrapper.vm.onSubmit();
+
+ expect(urlUtility.redirectTo).toHaveBeenCalledWith(webUrl);
+ });
+
+ it('display flash when POST is unsuccessful', async () => {
+ const dummyError = 'Fork project failed';
+
+ jest.spyOn(axios, 'post').mockRejectedValue(dummyError);
+
+ mockGetRequest();
+ createComponent();
+
+ await wrapper.vm.onSubmit();
+
+ expect(urlUtility.redirectTo).not.toHaveBeenCalled();
+ expect(createFlash).toHaveBeenCalledWith({
+ message: dummyError,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap
index c9141d13a46..1c1327e7a4e 100644
--- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap
+++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap
@@ -4,7 +4,7 @@ exports[`Learn GitLab Design A should render the loading state 1`] = `
<ul>
<li>
<span>
- Create a repository
+ Create or import a repository
</span>
</li>
<li>
@@ -14,7 +14,11 @@ exports[`Learn GitLab Design A should render the loading state 1`] = `
</li>
<li>
<span>
- Set-up CI/CD
+ <gl-link-stub
+ href="http://example.com/"
+ >
+ Set up CI/CD
+ </gl-link-stub>
</span>
</li>
<li>
@@ -22,7 +26,7 @@ exports[`Learn GitLab Design A should render the loading state 1`] = `
<gl-link-stub
href="http://example.com/"
>
- Start a free trial of GitLab Gold
+ Start a free Ultimate trial
</gl-link-stub>
</span>
</li>
@@ -40,7 +44,7 @@ exports[`Learn GitLab Design A should render the loading state 1`] = `
<gl-link-stub
href="http://example.com/"
>
- Enable require merge approvals
+ Add merge request approval
</gl-link-stub>
</span>
</li>
@@ -49,7 +53,7 @@ exports[`Learn GitLab Design A should render the loading state 1`] = `
<gl-link-stub
href="http://example.com/"
>
- Submit a merge request (MR)
+ Submit a merge request
</gl-link-stub>
</span>
</li>
@@ -58,7 +62,7 @@ exports[`Learn GitLab Design A should render the loading state 1`] = `
<gl-link-stub
href="http://example.com/"
>
- Run a Security scan using CI/CD
+ Run a security scan
</gl-link-stub>
</span>
</li>
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap
index 85e3b675e5b..dd899b93302 100644
--- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap
+++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap
@@ -1,66 +1,519 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Learn GitLab Design B should render the loading state 1`] = `
-<ul>
- <li>
- <span>
- Create a repository
- </span>
- </li>
- <li>
- <span>
- Invite your colleagues
- </span>
- </li>
- <li>
- <span>
- Set-up CI/CD
- </span>
- </li>
- <li>
- <span>
- <gl-link-stub
- href="http://example.com/"
+exports[`Learn GitLab Design B renders correctly 1`] = `
+<div>
+ <div
+ class="row"
+ >
+ <div
+ class="gl-mb-7 col-md-8 col-lg-7"
+ >
+ <h1
+ class="gl-font-size-h1"
>
- Start a free trial of GitLab Gold
- </gl-link-stub>
- </span>
- </li>
- <li>
- <span>
- <gl-link-stub
- href="http://example.com/"
+ Learn GitLab
+ </h1>
+
+ <p
+ class="gl-text-gray-700 gl-mb-0"
>
- Add code owners
- </gl-link-stub>
- </span>
- </li>
- <li>
- <span>
- <gl-link-stub
- href="http://example.com/"
+ Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project.
+ </p>
+ </div>
+ </div>
+
+ <div
+ class="gl-mb-3"
+ >
+ <p
+ class="gl-text-gray-500 gl-mb-2"
+ data-testid="completion-percentage"
+ >
+ 25% completed
+ </p>
+
+ <div
+ class="progress"
+ max="8"
+ value="2"
+ >
+ <div
+ aria-valuemax="8"
+ aria-valuemin="0"
+ aria-valuenow="2"
+ class="progress-bar"
+ role="progressbar"
+ style="width: 25%;"
>
- Enable require merge approvals
- </gl-link-stub>
- </span>
- </li>
- <li>
- <span>
- <gl-link-stub
- href="http://example.com/"
+ <!---->
+ </div>
+ </div>
+ </div>
+
+ <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
+ class="row row-cols-2 row-cols-md-3 row-cols-lg-4"
+ >
+ <div
+ class="col gl-mb-6"
+ >
+ <div
+ class="gl-card gl-pt-0"
>
- Submit a merge request (MR)
- </gl-link-stub>
- </span>
- </li>
- <li>
- <span>
- <gl-link-stub
- href="http://example.com/"
+ <!---->
+
+ <div
+ class="gl-card-body"
+ >
+ <div
+ class="gl-text-right gl-h-5"
+ >
+ <svg
+ aria-hidden="true"
+ class="gl-text-green-500 gl-icon s16"
+ data-testid="completed-icon"
+ >
+ <use
+ href="#check-circle-filled"
+ />
+ </svg>
+ </div>
+
+ <div
+ class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content"
+ >
+ <img
+ src="http://example.com/images/illustration.svg"
+ />
+
+ <h6>
+ Invite your colleagues
+ </h6>
+
+ <p
+ class="gl-font-sm gl-text-gray-700"
+ >
+ GitLab works best as a team. Invite your colleague to enjoy all features.
+ </p>
+
+ <a
+ class="gl-link"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ Invite your colleagues
+ </a>
+ </div>
+ </div>
+
+ <!---->
+ </div>
+ </div>
+
+ <div
+ class="col gl-mb-6"
+ >
+ <div
+ class="gl-card gl-pt-0"
>
- Run a Security scan using CI/CD
- </gl-link-stub>
- </span>
- </li>
-</ul>
+ <!---->
+
+ <div
+ class="gl-card-body"
+ >
+ <div
+ class="gl-text-right gl-h-5"
+ >
+ <svg
+ aria-hidden="true"
+ class="gl-text-green-500 gl-icon s16"
+ data-testid="completed-icon"
+ >
+ <use
+ href="#check-circle-filled"
+ />
+ </svg>
+ </div>
+
+ <div
+ class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content"
+ >
+ <img
+ src="http://example.com/images/illustration.svg"
+ />
+
+ <h6>
+ Create or import a repository
+ </h6>
+
+ <p
+ class="gl-font-sm gl-text-gray-700"
+ >
+ Create or import your first repository into your new project.
+ </p>
+
+ <a
+ class="gl-link"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ Create or import a repository
+ </a>
+ </div>
+ </div>
+
+ <!---->
+ </div>
+ </div>
+
+ <div
+ class="col gl-mb-6"
+ >
+ <div
+ class="gl-card gl-pt-0"
+ >
+ <!---->
+
+ <div
+ class="gl-card-body"
+ >
+ <div
+ class="gl-text-right gl-h-5"
+ >
+ <!---->
+ </div>
+
+ <div
+ class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content"
+ >
+ <img
+ src="http://example.com/images/illustration.svg"
+ />
+
+ <h6>
+ Set up CI/CD
+ </h6>
+
+ <p
+ class="gl-font-sm gl-text-gray-700"
+ >
+ Save time by automating your integration and deployment tasks.
+ </p>
+
+ <a
+ class="gl-link"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ Set-up CI/CD
+ </a>
+ </div>
+ </div>
+
+ <!---->
+ </div>
+ </div>
+
+ <div
+ class="col gl-mb-6"
+ >
+ <div
+ class="gl-card gl-pt-0"
+ >
+ <!---->
+
+ <div
+ class="gl-card-body"
+ >
+ <div
+ class="gl-text-right gl-h-5"
+ >
+ <!---->
+ </div>
+
+ <div
+ class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content"
+ >
+ <img
+ src="http://example.com/images/illustration.svg"
+ />
+
+ <h6>
+ Start a free Ultimate trial
+ </h6>
+
+ <p
+ class="gl-font-sm gl-text-gray-700"
+ >
+ Try all GitLab features for 30 days, no credit card required.
+ </p>
+
+ <a
+ class="gl-link"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ Try GitLab Ultimate for free
+ </a>
+ </div>
+ </div>
+
+ <!---->
+ </div>
+ </div>
+
+ <div
+ class="col gl-mb-6"
+ >
+ <div
+ class="gl-card gl-pt-0"
+ >
+ <!---->
+
+ <div
+ class="gl-card-body"
+ >
+ <div
+ class="gl-text-right gl-h-5"
+ >
+ <span
+ class="gl-text-gray-500 gl-font-sm gl-font-style-italic"
+ data-testid="trial-only"
+ >
+ Trial only
+ </span>
+ </div>
+
+ <div
+ class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content"
+ >
+ <img
+ src="http://example.com/images/illustration.svg"
+ />
+
+ <h6>
+ Add code owners
+ </h6>
+
+ <p
+ class="gl-font-sm gl-text-gray-700"
+ >
+ Prevent unexpected changes to important assets by assigning ownership of files and paths.
+ </p>
+
+ <a
+ class="gl-link"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ Add code owners
+ </a>
+ </div>
+ </div>
+
+ <!---->
+ </div>
+ </div>
+
+ <div
+ class="col gl-mb-6"
+ >
+ <div
+ class="gl-card gl-pt-0"
+ >
+ <!---->
+
+ <div
+ class="gl-card-body"
+ >
+ <div
+ class="gl-text-right gl-h-5"
+ >
+ <span
+ class="gl-text-gray-500 gl-font-sm gl-font-style-italic"
+ data-testid="trial-only"
+ >
+ Trial only
+ </span>
+ </div>
+
+ <div
+ class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content"
+ >
+ <img
+ src="http://example.com/images/illustration.svg"
+ />
+
+ <h6>
+ Add merge request approval
+ </h6>
+
+ <p
+ class="gl-font-sm gl-text-gray-700"
+ >
+ Route code reviews to the right reviewers, every time.
+ </p>
+
+ <a
+ class="gl-link"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ Enable require merge approvals
+ </a>
+ </div>
+ </div>
+
+ <!---->
+ </div>
+ </div>
+ </div>
+
+ <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
+ class="row row-cols-2 row-cols-md-3 row-cols-lg-4"
+ >
+ <div
+ class="col gl-mb-6"
+ >
+ <div
+ class="gl-card gl-pt-0"
+ >
+ <!---->
+
+ <div
+ class="gl-card-body"
+ >
+ <div
+ class="gl-text-right gl-h-5"
+ >
+ <!---->
+ </div>
+
+ <div
+ class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content"
+ >
+ <img
+ src="http://example.com/images/illustration.svg"
+ />
+
+ <h6>
+ Submit a merge request
+ </h6>
+
+ <p
+ class="gl-font-sm gl-text-gray-700"
+ >
+ Review and edit proposed changes to source code.
+ </p>
+
+ <a
+ class="gl-link"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ Submit a merge request (MR)
+ </a>
+ </div>
+ </div>
+
+ <!---->
+ </div>
+ </div>
+ </div>
+
+ <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
+ class="row row-cols-2 row-cols-lg-4 g-2 g-lg-3"
+ >
+ <div
+ class="col gl-mb-6"
+ >
+ <div
+ class="gl-card gl-pt-0"
+ >
+ <!---->
+
+ <div
+ class="gl-card-body"
+ >
+ <div
+ class="gl-text-right gl-h-5"
+ >
+ <!---->
+ </div>
+
+ <div
+ class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content"
+ >
+ <img
+ src="http://example.com/images/illustration.svg"
+ />
+
+ <h6>
+ Run a security scan
+ </h6>
+
+ <p
+ class="gl-font-sm gl-text-gray-700"
+ >
+ Scan your code to uncover vulnerabilities before deploying.
+ </p>
+
+ <a
+ class="gl-link"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ Run a Security scan
+ </a>
+ </div>
+ </div>
+
+ <!---->
+ </div>
+ </div>
+ </div>
+</div>
`;
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js
index ddc5339e7e0..2154358de51 100644
--- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js
+++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js
@@ -1,41 +1,6 @@
import { shallowMount } from '@vue/test-utils';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import LearnGitlabA from '~/pages/projects/learn_gitlab/components/learn_gitlab_a.vue';
-
-const TEST_ACTIONS = {
- gitWrite: {
- url: 'http://example.com/',
- completed: true,
- },
- userAdded: {
- url: 'http://example.com/',
- completed: true,
- },
- pipelineCreated: {
- url: 'http://example.com/',
- completed: true,
- },
- trialStarted: {
- url: 'http://example.com/',
- completed: false,
- },
- codeOwnersEnabled: {
- url: 'http://example.com/',
- completed: false,
- },
- requiredMrApprovalsEnabled: {
- url: 'http://example.com/',
- completed: false,
- },
- mergeRequestCreated: {
- url: 'http://example.com/',
- completed: false,
- },
- securityScanEnabled: {
- url: 'http://example.com/',
- completed: false,
- },
-};
+import { testActions } from './mock_data';
describe('Learn GitLab Design A', () => {
let wrapper;
@@ -46,13 +11,7 @@ describe('Learn GitLab Design A', () => {
});
const createWrapper = () => {
- wrapper = extendedWrapper(
- shallowMount(LearnGitlabA, {
- propsData: {
- actions: TEST_ACTIONS,
- },
- }),
- );
+ wrapper = shallowMount(LearnGitlabA, { propsData: { actions: testActions } });
};
it('should render the loading state', () => {
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js
index be4f5768402..fbb989fae32 100644
--- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js
+++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js
@@ -1,63 +1,38 @@
-import { shallowMount } from '@vue/test-utils';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import LearnGitlabA from '~/pages/projects/learn_gitlab/components/learn_gitlab_a.vue';
-
-const TEST_ACTIONS = {
- gitWrite: {
- url: 'http://example.com/',
- completed: true,
- },
- userAdded: {
- url: 'http://example.com/',
- completed: true,
- },
- pipelineCreated: {
- url: 'http://example.com/',
- completed: true,
- },
- trialStarted: {
- url: 'http://example.com/',
- completed: false,
- },
- codeOwnersEnabled: {
- url: 'http://example.com/',
- completed: false,
- },
- requiredMrApprovalsEnabled: {
- url: 'http://example.com/',
- completed: false,
- },
- mergeRequestCreated: {
- url: 'http://example.com/',
- completed: false,
- },
- securityScanEnabled: {
- url: 'http://example.com/',
- completed: false,
- },
-};
+import { GlProgressBar } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import LearnGitlabB from '~/pages/projects/learn_gitlab/components/learn_gitlab_b.vue';
+import { testActions } from './mock_data';
describe('Learn GitLab Design B', () => {
let wrapper;
+ const createWrapper = () => {
+ wrapper = mount(LearnGitlabB, { propsData: { actions: testActions } });
+ };
+
+ beforeEach(() => {
+ createWrapper();
+ });
+
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
- const createWrapper = () => {
- wrapper = extendedWrapper(
- shallowMount(LearnGitlabA, {
- propsData: {
- actions: TEST_ACTIONS,
- },
- }),
- );
- };
+ it('renders correctly', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
- it('should render the loading state', () => {
- createWrapper();
+ it('renders the progress percentage', () => {
+ const text = wrapper.find('[data-testid="completion-percentage"]').text();
- expect(wrapper.element).toMatchSnapshot();
+ expect(text).toEqual('25% completed');
+ });
+
+ it('renders the progress bar with correct values', () => {
+ const progressBar = wrapper.find(GlProgressBar);
+
+ expect(progressBar.attributes('value')).toBe('2');
+ expect(progressBar.attributes('max')).toBe('8');
});
});
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_info_card_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_info_card_spec.js
new file mode 100644
index 00000000000..ad4bc826a9d
--- /dev/null
+++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_info_card_spec.js
@@ -0,0 +1,57 @@
+import { shallowMount } from '@vue/test-utils';
+import LearnGitlabInfoCard from '~/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue';
+
+const defaultProps = {
+ title: 'Create Repository',
+ description: 'Some description',
+ actionLabel: 'Create Repository now',
+ url: 'https://example.com',
+ completed: false,
+ svg: 'https://example.com/illustration.svg',
+};
+
+describe('Learn GitLab Info Card', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const createWrapper = (props = {}) => {
+ wrapper = shallowMount(LearnGitlabInfoCard, {
+ propsData: { ...defaultProps, ...props },
+ });
+ };
+
+ it('renders no icon when not completed', () => {
+ createWrapper({ completed: false });
+
+ expect(wrapper.find('[data-testid="completed-icon"]').exists()).toBe(false);
+ });
+
+ it('renders the completion icon when completed', () => {
+ createWrapper({ completed: true });
+
+ expect(wrapper.find('[data-testid="completed-icon"]').exists()).toBe(true);
+ });
+
+ it('renders no trial only when it is not required', () => {
+ createWrapper();
+
+ expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(false);
+ });
+
+ it('renders trial only when trial is required', () => {
+ createWrapper({ trialRequired: true });
+
+ expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(true);
+ });
+
+ it('renders completion icon when completed a trial-only feature', () => {
+ createWrapper({ trialRequired: true, completed: true });
+
+ expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(false);
+ expect(wrapper.find('[data-testid="completed-icon"]').exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js
new file mode 100644
index 00000000000..caac667e2b1
--- /dev/null
+++ b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js
@@ -0,0 +1,42 @@
+export const testActions = {
+ gitWrite: {
+ url: 'http://example.com/',
+ completed: true,
+ svg: 'http://example.com/images/illustration.svg',
+ },
+ userAdded: {
+ url: 'http://example.com/',
+ completed: true,
+ svg: 'http://example.com/images/illustration.svg',
+ },
+ pipelineCreated: {
+ url: 'http://example.com/',
+ completed: false,
+ svg: 'http://example.com/images/illustration.svg',
+ },
+ trialStarted: {
+ url: 'http://example.com/',
+ completed: false,
+ svg: 'http://example.com/images/illustration.svg',
+ },
+ codeOwnersEnabled: {
+ url: 'http://example.com/',
+ completed: false,
+ svg: 'http://example.com/images/illustration.svg',
+ },
+ requiredMrApprovalsEnabled: {
+ url: 'http://example.com/',
+ completed: false,
+ svg: 'http://example.com/images/illustration.svg',
+ },
+ mergeRequestCreated: {
+ url: 'http://example.com/',
+ completed: false,
+ svg: 'http://example.com/images/illustration.svg',
+ },
+ securityScanEnabled: {
+ url: 'http://example.com/',
+ completed: false,
+ svg: 'http://example.com/images/illustration.svg',
+ },
+};
diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js
index de63409b181..2a3b07f95f2 100644
--- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js
+++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js
@@ -6,8 +6,6 @@ import TimezoneDropdown, {
} from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown';
describe('Timezone Dropdown', () => {
- preloadFixtures('pipeline_schedules/edit.html');
-
let $inputEl = null;
let $dropdownEl = null;
let $wrapper = null;
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 d7c754fd3cc..bee628c3a56 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
@@ -29,6 +29,7 @@ const defaultProps = {
showDefaultAwardEmojis: true,
allowEditingCommitMessages: false,
},
+ isGitlabCom: true,
canDisableEmails: true,
canChangeVisibilityLevel: true,
allowedVisibilityOptions: [0, 10, 20],
diff --git a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
index 8632c852720..e39a3904613 100644
--- a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
+++ b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
@@ -6,8 +6,6 @@ describe('preserve_url_fragment', () => {
return $(`.omniauth-container ${selector}`).parent('form').attr('action');
};
- preloadFixtures('sessions/new.html');
-
beforeEach(() => {
loadFixtures('sessions/new.html');
});
diff --git a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js
index f04c16d2ddb..6aa725fbd7d 100644
--- a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js
+++ b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js
@@ -18,8 +18,6 @@ describe('SigninTabsMemoizer', () => {
return memo;
}
- preloadFixtures(fixtureTemplate);
-
beforeEach(() => {
loadFixtures(fixtureTemplate);
diff --git a/spec/frontend/pages/shared/wikis/wiki_alert_spec.js b/spec/frontend/pages/shared/wikis/wiki_alert_spec.js
new file mode 100644
index 00000000000..6a18473b1a7
--- /dev/null
+++ b/spec/frontend/pages/shared/wikis/wiki_alert_spec.js
@@ -0,0 +1,40 @@
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import WikiAlert from '~/pages/shared/wikis/components/wiki_alert.vue';
+
+describe('WikiAlert', () => {
+ let wrapper;
+ const ERROR = 'There is already a page with the same title in that path.';
+ const ERROR_WITH_LINK = 'Before text %{wikiLinkStart}the page%{wikiLinkEnd} after text.';
+ const PATH = '/test';
+
+ function createWrapper(propsData = {}, stubs = {}) {
+ wrapper = shallowMount(WikiAlert, {
+ propsData: { wikiPagePath: PATH, ...propsData },
+ stubs,
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findGlAlert = () => wrapper.findComponent(GlAlert);
+ const findGlLink = () => wrapper.findComponent(GlLink);
+ const findGlSprintf = () => wrapper.findComponent(GlSprintf);
+
+ describe('Wiki Alert', () => {
+ it('shows an alert when there is an error', () => {
+ createWrapper({ error: ERROR });
+ expect(findGlAlert().exists()).toBe(true);
+ expect(findGlSprintf().exists()).toBe(true);
+ expect(findGlSprintf().attributes('message')).toBe(ERROR);
+ });
+
+ it('shows a the link to the help path', () => {
+ createWrapper({ error: ERROR_WITH_LINK }, { GlAlert, GlSprintf });
+ expect(findGlLink().attributes('href')).toBe(PATH);
+ });
+ });
+});
diff --git a/spec/frontend/performance_bar/components/performance_bar_app_spec.js b/spec/frontend/performance_bar/components/performance_bar_app_spec.js
index 417a655093c..67a4259a8e3 100644
--- a/spec/frontend/performance_bar/components/performance_bar_app_spec.js
+++ b/spec/frontend/performance_bar/components/performance_bar_app_spec.js
@@ -9,6 +9,7 @@ describe('performance bar app', () => {
store,
env: 'development',
requestId: '123',
+ statsUrl: 'https://log.gprd.gitlab.net/app/dashboards#/view/',
peekUrl: '/-/peek/results',
profileUrl: '?lineprofiler=true',
},
diff --git a/spec/frontend/performance_bar/index_spec.js b/spec/frontend/performance_bar/index_spec.js
index 8d9c32b7f12..819b2bcbacf 100644
--- a/spec/frontend/performance_bar/index_spec.js
+++ b/spec/frontend/performance_bar/index_spec.js
@@ -19,6 +19,7 @@ describe('performance bar wrapper', () => {
peekWrapper.setAttribute('data-env', 'development');
peekWrapper.setAttribute('data-request-id', '123');
peekWrapper.setAttribute('data-peek-url', '/-/peek/results');
+ peekWrapper.setAttribute('data-stats-url', 'https://log.gprd.gitlab.net/app/dashboards#/view/');
peekWrapper.setAttribute('data-profile-url', '?lineprofiler=true');
mock = new MockAdapter(axios);
diff --git a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js
index 5dae77a4626..8040c9d701c 100644
--- a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js
+++ b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js
@@ -12,7 +12,7 @@ describe('Pipeline Editor | Commit Form', () => {
wrapper = mountFn(CommitForm, {
propsData: {
defaultMessage: mockCommitMessage,
- defaultBranch: mockDefaultBranch,
+ currentBranch: mockDefaultBranch,
...props,
},
@@ -41,7 +41,7 @@ describe('Pipeline Editor | Commit Form', () => {
expect(findCommitTextarea().attributes('value')).toBe(mockCommitMessage);
});
- it('shows a default branch', () => {
+ it('shows current branch', () => {
expect(findBranchInput().attributes('value')).toBe(mockDefaultBranch);
});
@@ -66,7 +66,7 @@ describe('Pipeline Editor | Commit Form', () => {
expect(wrapper.emitted('submit')[0]).toEqual([
{
message: mockCommitMessage,
- branch: mockDefaultBranch,
+ targetBranch: mockDefaultBranch,
openMergeRequest: false,
},
]);
@@ -101,7 +101,7 @@ describe('Pipeline Editor | Commit Form', () => {
expect(wrapper.emitted('submit')[0]).toEqual([
{
message: anotherMessage,
- branch: anotherBranch,
+ targetBranch: anotherBranch,
openMergeRequest: true,
},
]);
diff --git a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js
index b87ff6ec0de..9e677425807 100644
--- a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js
+++ b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js
@@ -3,7 +3,11 @@ import { mount } from '@vue/test-utils';
import { objectToQuery, redirectTo } from '~/lib/utils/url_utility';
import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue';
import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue';
-import { COMMIT_SUCCESS } from '~/pipeline_editor/constants';
+import {
+ COMMIT_ACTION_CREATE,
+ COMMIT_ACTION_UPDATE,
+ COMMIT_SUCCESS,
+} from '~/pipeline_editor/constants';
import commitCreate from '~/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql';
import {
@@ -25,6 +29,7 @@ jest.mock('~/lib/utils/url_utility', () => ({
}));
const mockVariables = {
+ action: COMMIT_ACTION_UPDATE,
projectPath: mockProjectFullPath,
startBranch: mockDefaultBranch,
message: mockCommitMessage,
@@ -35,7 +40,6 @@ const mockVariables = {
const mockProvide = {
ciConfigPath: mockCiConfigPath,
- defaultBranch: mockDefaultBranch,
projectFullPath: mockProjectFullPath,
newMergeRequestPath: mockNewMergeRequestPath,
};
@@ -64,6 +68,8 @@ describe('Pipeline Editor | Commit section', () => {
data() {
return {
commitSha: mockCommitSha,
+ currentBranch: mockDefaultBranch,
+ isNewCiConfigFile: Boolean(options?.isNewCiConfigfile),
};
},
mocks: {
@@ -100,23 +106,58 @@ describe('Pipeline Editor | Commit section', () => {
await findCancelBtn().trigger('click');
};
- beforeEach(() => {
- createComponent();
- });
-
afterEach(() => {
mockMutate.mockReset();
-
wrapper.destroy();
- wrapper = null;
+ });
+
+ describe('when the user commits a new file', () => {
+ beforeEach(async () => {
+ createComponent({ options: { isNewCiConfigfile: true } });
+ await submitCommit();
+ });
+
+ it('calls the mutation with the CREATE action', () => {
+ expect(mockMutate).toHaveBeenCalledTimes(1);
+ expect(mockMutate).toHaveBeenCalledWith({
+ mutation: commitCreate,
+ update: expect.any(Function),
+ variables: {
+ ...mockVariables,
+ action: COMMIT_ACTION_CREATE,
+ branch: mockDefaultBranch,
+ },
+ });
+ });
+ });
+
+ describe('when the user commits an update to an existing file', () => {
+ beforeEach(async () => {
+ createComponent();
+ await submitCommit();
+ });
+
+ it('calls the mutation with the UPDATE action', () => {
+ expect(mockMutate).toHaveBeenCalledTimes(1);
+ expect(mockMutate).toHaveBeenCalledWith({
+ mutation: commitCreate,
+ update: expect.any(Function),
+ variables: {
+ ...mockVariables,
+ action: COMMIT_ACTION_UPDATE,
+ branch: mockDefaultBranch,
+ },
+ });
+ });
});
describe('when the user commits changes to the current branch', () => {
beforeEach(async () => {
+ createComponent();
await submitCommit();
});
- it('calls the mutation with the default branch', () => {
+ it('calls the mutation with the current branch', () => {
expect(mockMutate).toHaveBeenCalledTimes(1);
expect(mockMutate).toHaveBeenCalledWith({
mutation: commitCreate,
@@ -157,6 +198,7 @@ describe('Pipeline Editor | Commit section', () => {
const newBranch = 'new-branch';
beforeEach(async () => {
+ createComponent();
await submitCommit({
branch: newBranch,
});
@@ -178,6 +220,7 @@ describe('Pipeline Editor | Commit section', () => {
const newBranch = 'new-branch';
beforeEach(async () => {
+ createComponent();
await submitCommit({
branch: newBranch,
openMergeRequest: true,
@@ -195,6 +238,10 @@ describe('Pipeline Editor | Commit section', () => {
});
describe('when the commit is ocurring', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
it('shows a saving state', async () => {
mockMutate.mockImplementationOnce(() => {
expect(findCommitBtnLoadingIcon().exists()).toBe(true);
diff --git a/spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js b/spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js
index df15a6c8e7f..ef8ca574e59 100644
--- a/spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js
+++ b/spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js
@@ -1,21 +1,33 @@
import { shallowMount } from '@vue/test-utils';
import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue';
+import PipelineStatus from '~/pipeline_editor/components/header/pipeline_status.vue';
import ValidationSegment from '~/pipeline_editor/components/header/validation_segment.vue';
-import { mockLintResponse } from '../../mock_data';
+import { mockCiYml, mockLintResponse } from '../../mock_data';
describe('Pipeline editor header', () => {
let wrapper;
+ const mockProvide = {
+ glFeatures: {
+ pipelineStatusForPipelineEditor: true,
+ },
+ };
- const createComponent = () => {
+ const createComponent = ({ provide = {} } = {}) => {
wrapper = shallowMount(PipelineEditorHeader, {
- props: {
+ provide: {
+ ...mockProvide,
+ ...provide,
+ },
+ propsData: {
ciConfigData: mockLintResponse,
+ ciFileContent: mockCiYml,
isCiConfigDataLoading: false,
},
});
};
+ const findPipelineStatus = () => wrapper.findComponent(PipelineStatus);
const findValidationSegment = () => wrapper.findComponent(ValidationSegment);
afterEach(() => {
@@ -27,8 +39,27 @@ describe('Pipeline editor header', () => {
beforeEach(() => {
createComponent();
});
+
+ it('renders the pipeline status', () => {
+ expect(findPipelineStatus().exists()).toBe(true);
+ });
+
it('renders the validation segment', () => {
expect(findValidationSegment().exists()).toBe(true);
});
});
+
+ describe('with pipeline status feature flag off', () => {
+ beforeEach(() => {
+ createComponent({
+ provide: {
+ glFeatures: { pipelineStatusForPipelineEditor: false },
+ },
+ });
+ });
+
+ it('does not render the pipeline status', () => {
+ expect(findPipelineStatus().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js
new file mode 100644
index 00000000000..de6e112866b
--- /dev/null
+++ b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js
@@ -0,0 +1,150 @@
+import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import PipelineStatus, { i18n } from '~/pipeline_editor/components/header/pipeline_status.vue';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import { mockCommitSha, mockProjectPipeline, mockProjectFullPath } from '../../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+const mockProvide = {
+ projectFullPath: mockProjectFullPath,
+};
+
+describe('Pipeline Status', () => {
+ let wrapper;
+ let mockApollo;
+ let mockPipelineQuery;
+
+ const createComponent = ({ hasPipeline = true, isQueryLoading = false }) => {
+ const pipeline = hasPipeline
+ ? { loading: isQueryLoading, ...mockProjectPipeline.pipeline }
+ : { loading: isQueryLoading };
+
+ wrapper = shallowMount(PipelineStatus, {
+ provide: mockProvide,
+ stubs: { GlLink, GlSprintf },
+ data: () => (hasPipeline ? { pipeline } : {}),
+ mocks: {
+ $apollo: {
+ queries: {
+ pipeline,
+ },
+ },
+ },
+ });
+ };
+
+ const createComponentWithApollo = () => {
+ const resolvers = {
+ Query: {
+ project: mockPipelineQuery,
+ },
+ };
+ mockApollo = createMockApollo([], resolvers);
+
+ wrapper = shallowMount(PipelineStatus, {
+ localVue,
+ apolloProvider: mockApollo,
+ provide: mockProvide,
+ stubs: { GlLink, GlSprintf },
+ data() {
+ return {
+ commitSha: mockCommitSha,
+ };
+ },
+ });
+ };
+
+ const findIcon = () => wrapper.findComponent(GlIcon);
+ const findCiIcon = () => wrapper.findComponent(CiIcon);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findPipelineId = () => wrapper.find('[data-testid="pipeline-id"]');
+ const findPipelineCommit = () => wrapper.find('[data-testid="pipeline-commit"]');
+ const findPipelineErrorMsg = () => wrapper.find('[data-testid="pipeline-error-msg"]');
+ const findPipelineLoadingMsg = () => wrapper.find('[data-testid="pipeline-loading-msg"]');
+
+ beforeEach(() => {
+ mockPipelineQuery = jest.fn();
+ });
+
+ afterEach(() => {
+ mockPipelineQuery.mockReset();
+
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('while querying', () => {
+ it('renders loading icon', () => {
+ createComponent({ isQueryLoading: true, hasPipeline: false });
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findPipelineLoadingMsg().text()).toBe(i18n.fetchLoading);
+ });
+
+ it('does not render loading icon if pipeline data is already set', () => {
+ createComponent({ isQueryLoading: true });
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('when querying data', () => {
+ describe('when data is set', () => {
+ beforeEach(async () => {
+ mockPipelineQuery.mockResolvedValue(mockProjectPipeline);
+
+ createComponentWithApollo();
+ await waitForPromises();
+ });
+
+ it('query is called with correct variables', async () => {
+ expect(mockPipelineQuery).toHaveBeenCalledTimes(1);
+ expect(mockPipelineQuery).toHaveBeenCalledWith(
+ expect.anything(),
+ {
+ fullPath: mockProjectFullPath,
+ },
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('does not render error', () => {
+ expect(findIcon().exists()).toBe(false);
+ });
+
+ it('renders pipeline data', () => {
+ const { id } = mockProjectPipeline.pipeline;
+
+ expect(findCiIcon().exists()).toBe(true);
+ expect(findPipelineId().text()).toBe(`#${id.match(/\d+/g)[0]}`);
+ expect(findPipelineCommit().text()).toBe(mockCommitSha);
+ });
+ });
+
+ describe('when data cannot be fetched', () => {
+ beforeEach(async () => {
+ mockPipelineQuery.mockRejectedValue(new Error());
+
+ createComponentWithApollo();
+ await waitForPromises();
+ });
+
+ it('renders error', () => {
+ expect(findIcon().attributes('name')).toBe('warning-solid');
+ expect(findPipelineErrorMsg().text()).toBe(i18n.fetchError);
+ });
+
+ it('does not render pipeline data', () => {
+ expect(findCiIcon().exists()).toBe(false);
+ expect(findPipelineId().exists()).toBe(false);
+ expect(findPipelineCommit().exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js b/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js
index cf1d89e1d7c..274c2d1b8da 100644
--- a/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js
+++ b/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js
@@ -7,9 +7,9 @@ import ValidationSegment, {
i18n,
} from '~/pipeline_editor/components/header/validation_segment.vue';
import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
-import { mockYmlHelpPagePath, mergeUnwrappedCiConfig } from '../../mock_data';
+import { mockYmlHelpPagePath, mergeUnwrappedCiConfig, mockCiYml } from '../../mock_data';
-describe('~/pipeline_editor/components/info/validation_segment.vue', () => {
+describe('Validation segment component', () => {
let wrapper;
const createComponent = (props = {}) => {
@@ -20,6 +20,7 @@ describe('~/pipeline_editor/components/info/validation_segment.vue', () => {
},
propsData: {
ciConfig: mergeUnwrappedCiConfig(),
+ ciFileContent: mockCiYml,
loading: false,
...props,
},
@@ -42,6 +43,20 @@ describe('~/pipeline_editor/components/info/validation_segment.vue', () => {
expect(wrapper.text()).toBe(i18n.loading);
});
+ describe('when config is empty', () => {
+ beforeEach(() => {
+ createComponent({ ciFileContent: '' });
+ });
+
+ it('has check icon', () => {
+ expect(findIcon().props('name')).toBe('check');
+ });
+
+ it('shows a message for empty state', () => {
+ expect(findValidationMsg().text()).toBe(i18n.empty);
+ });
+ });
+
describe('when config is valid', () => {
beforeEach(() => {
createComponent({});
@@ -61,7 +76,7 @@ describe('~/pipeline_editor/components/info/validation_segment.vue', () => {
});
});
- describe('when config is not valid', () => {
+ describe('when config is invalid', () => {
beforeEach(() => {
createComponent({
ciConfig: mergeUnwrappedCiConfig({
diff --git a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js
new file mode 100644
index 00000000000..b444d9dcfea
--- /dev/null
+++ b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js
@@ -0,0 +1,79 @@
+import { GlButton, GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue';
+
+describe('Pipeline editor empty state', () => {
+ let wrapper;
+ const defaultProvide = {
+ glFeatures: {
+ pipelineEditorEmptyStateAction: false,
+ },
+ emptyStateIllustrationPath: 'my/svg/path',
+ };
+
+ const createComponent = ({ provide } = {}) => {
+ wrapper = shallowMount(PipelineEditorEmptyState, {
+ provide: { ...defaultProvide, ...provide },
+ });
+ };
+
+ const findSvgImage = () => wrapper.find('img');
+ const findTitle = () => wrapper.find('h1');
+ const findConfirmButton = () => wrapper.findComponent(GlButton);
+ const findDescription = () => wrapper.findComponent(GlSprintf);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders an svg image', () => {
+ expect(findSvgImage().exists()).toBe(true);
+ });
+
+ it('renders a title', () => {
+ expect(findTitle().exists()).toBe(true);
+ expect(findTitle().text()).toBe(wrapper.vm.$options.i18n.title);
+ });
+
+ it('renders a description', () => {
+ expect(findDescription().exists()).toBe(true);
+ expect(findDescription().html()).toContain(wrapper.vm.$options.i18n.body);
+ });
+
+ describe('with feature flag off', () => {
+ it('does not renders a CTA button', () => {
+ expect(findConfirmButton().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('with feature flag on', () => {
+ beforeEach(() => {
+ createComponent({
+ provide: {
+ glFeatures: {
+ pipelineEditorEmptyStateAction: true,
+ },
+ },
+ });
+ });
+
+ it('renders a CTA button', () => {
+ expect(findConfirmButton().exists()).toBe(true);
+ expect(findConfirmButton().text()).toBe(wrapper.vm.$options.i18n.btnText);
+ });
+
+ it('emits an event when clicking on the CTA', async () => {
+ const expectedEvent = 'createEmptyConfigFile';
+ expect(wrapper.emitted(expectedEvent)).toBeUndefined();
+
+ await findConfirmButton().vm.$emit('click');
+ expect(wrapper.emitted(expectedEvent)).toHaveLength(1);
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js
index d39c0d80296..196a4133eea 100644
--- a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js
+++ b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js
@@ -46,6 +46,24 @@ describe('~/pipeline_editor/graphql/resolvers', () => {
await expect(result.rawData).resolves.toBe(mockCiYml);
});
});
+
+ describe('pipeline', () => {
+ it('resolves pipeline data with type names', async () => {
+ const result = await resolvers.Query.project(null);
+
+ // eslint-disable-next-line no-underscore-dangle
+ expect(result.__typename).toBe('Project');
+ });
+
+ it('resolves pipeline data with necessary data', async () => {
+ const result = await resolvers.Query.project(null);
+ const pipelineKeys = Object.keys(result.pipeline);
+ const statusKeys = Object.keys(result.pipeline.detailedStatus);
+
+ expect(pipelineKeys).toContain('id', 'commitPath', 'detailedStatus', 'shortSha');
+ expect(statusKeys).toContain('detailsPath', 'text');
+ });
+ });
});
describe('Mutation', () => {
diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js
index 8e248c11b87..16d5ba0e714 100644
--- a/spec/frontend/pipeline_editor/mock_data.js
+++ b/spec/frontend/pipeline_editor/mock_data.js
@@ -138,6 +138,22 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => {
};
};
+export const mockProjectPipeline = {
+ pipeline: {
+ commitPath: '/-/commit/aabbccdd',
+ id: 'gid://gitlab/Ci::Pipeline/118',
+ iid: '28',
+ shortSha: mockCommitSha,
+ status: 'SUCCESS',
+ detailedStatus: {
+ detailsPath: '/root/sample-ci-project/-/pipelines/118"',
+ group: 'success',
+ icon: 'status_success',
+ text: 'passed',
+ },
+ },
+};
+
export const mockLintResponse = {
valid: true,
mergedYaml: mockCiYml,
diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js
index 46d0452f437..887d296222f 100644
--- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js
+++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js
@@ -7,6 +7,8 @@ import httpStatusCodes from '~/lib/utils/http_status';
import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue';
import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue';
+import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
+import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue';
import { COMMIT_SUCCESS, COMMIT_FAILURE, LOAD_FAILURE_UNKNOWN } from '~/pipeline_editor/constants';
import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.graphql';
import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue';
@@ -29,6 +31,9 @@ const MockEditorLite = {
const mockProvide = {
ciConfigPath: mockCiConfigPath,
defaultBranch: mockDefaultBranch,
+ glFeatures: {
+ pipelineEditorEmptyStateAction: false,
+ },
projectFullPath: mockProjectFullPath,
};
@@ -39,14 +44,17 @@ describe('Pipeline editor app component', () => {
let mockBlobContentData;
let mockCiConfigData;
- const createComponent = ({ blobLoading = false, options = {} } = {}) => {
+ const createComponent = ({ blobLoading = false, options = {}, provide = {} } = {}) => {
wrapper = shallowMount(PipelineEditorApp, {
- provide: mockProvide,
+ provide: { ...mockProvide, ...provide },
stubs: {
GlTabs,
GlButton,
CommitForm,
+ PipelineEditorHome,
+ PipelineEditorTabs,
EditorLite: MockEditorLite,
+ PipelineEditorEmptyState,
},
mocks: {
$apollo: {
@@ -64,7 +72,7 @@ describe('Pipeline editor app component', () => {
});
};
- const createComponentWithApollo = ({ props = {} } = {}) => {
+ const createComponentWithApollo = ({ props = {}, provide = {} } = {}) => {
const handlers = [[getCiConfigData, mockCiConfigData]];
const resolvers = {
Query: {
@@ -85,13 +93,16 @@ describe('Pipeline editor app component', () => {
apolloProvider: mockApollo,
};
- createComponent({ props, options });
+ createComponent({ props, provide, options });
};
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findAlert = () => wrapper.findComponent(GlAlert);
const findEditorHome = () => wrapper.findComponent(PipelineEditorHome);
const findTextEditor = () => wrapper.findComponent(TextEditor);
+ const findEmptyState = () => wrapper.findComponent(PipelineEditorEmptyState);
+ const findEmptyStateButton = () =>
+ wrapper.findComponent(PipelineEditorEmptyState).findComponent(GlButton);
beforeEach(() => {
mockBlobContentData = jest.fn();
@@ -103,7 +114,6 @@ describe('Pipeline editor app component', () => {
mockCiConfigData.mockReset();
wrapper.destroy();
- wrapper = null;
});
it('displays a loading icon if the blob query is loading', () => {
@@ -146,45 +156,79 @@ describe('Pipeline editor app component', () => {
});
});
- describe('when no file exists', () => {
- const noFileAlertMsg =
- 'There is no .gitlab-ci.yml file in this repository, please add one and visit the Pipeline Editor again.';
+ describe('when no CI config file exists', () => {
+ describe('in a project without a repository', () => {
+ it('shows an empty state and does not show editor home component', async () => {
+ mockBlobContentData.mockRejectedValueOnce({
+ response: {
+ status: httpStatusCodes.BAD_REQUEST,
+ },
+ });
+ createComponentWithApollo();
- it('shows a 404 error message and does not show editor home component', async () => {
- mockBlobContentData.mockRejectedValueOnce({
- response: {
- status: httpStatusCodes.NOT_FOUND,
- },
+ await waitForPromises();
+
+ expect(findEmptyState().exists()).toBe(true);
+ expect(findAlert().exists()).toBe(false);
+ expect(findEditorHome().exists()).toBe(false);
});
- createComponentWithApollo();
+ });
- await waitForPromises();
+ describe('in a project with a repository', () => {
+ it('shows an empty state and does not show editor home component', async () => {
+ mockBlobContentData.mockRejectedValueOnce({
+ response: {
+ status: httpStatusCodes.NOT_FOUND,
+ },
+ });
+ createComponentWithApollo();
- expect(findAlert().text()).toBe(noFileAlertMsg);
- expect(findEditorHome().exists()).toBe(false);
+ await waitForPromises();
+
+ expect(findEmptyState().exists()).toBe(true);
+ expect(findAlert().exists()).toBe(false);
+ expect(findEditorHome().exists()).toBe(false);
+ });
+ });
+
+ describe('because of a fetching error', () => {
+ it('shows a unkown error message', async () => {
+ mockBlobContentData.mockRejectedValueOnce(new Error('My error!'));
+ createComponentWithApollo();
+ await waitForPromises();
+
+ expect(findEmptyState().exists()).toBe(false);
+ expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[LOAD_FAILURE_UNKNOWN]);
+ expect(findEditorHome().exists()).toBe(true);
+ });
});
+ });
- it('shows a 400 error message and does not show editor home component', async () => {
+ describe('when landing on the empty state with feature flag on', () => {
+ it('user can click on CTA button and see an empty editor', async () => {
mockBlobContentData.mockRejectedValueOnce({
response: {
- status: httpStatusCodes.BAD_REQUEST,
+ status: httpStatusCodes.NOT_FOUND,
+ },
+ });
+
+ createComponentWithApollo({
+ provide: {
+ glFeatures: {
+ pipelineEditorEmptyStateAction: true,
+ },
},
});
- createComponentWithApollo();
await waitForPromises();
- expect(findAlert().text()).toBe(noFileAlertMsg);
- expect(findEditorHome().exists()).toBe(false);
- });
+ expect(findEmptyState().exists()).toBe(true);
+ expect(findTextEditor().exists()).toBe(false);
- it('shows a unkown error message', async () => {
- mockBlobContentData.mockRejectedValueOnce(new Error('My error!'));
- createComponentWithApollo();
- await waitForPromises();
+ await findEmptyStateButton().vm.$emit('click');
- expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[LOAD_FAILURE_UNKNOWN]);
- expect(findEditorHome().exists()).toBe(true);
+ expect(findEmptyState().exists()).toBe(false);
+ expect(findTextEditor().exists()).toBe(true);
});
});
@@ -193,6 +237,7 @@ describe('Pipeline editor app component', () => {
describe('and the commit mutation succeeds', () => {
beforeEach(() => {
+ window.scrollTo = jest.fn();
createComponent();
findEditorHome().vm.$emit('commit', { type: COMMIT_SUCCESS });
@@ -201,11 +246,16 @@ describe('Pipeline editor app component', () => {
it('shows a confirmation message', () => {
expect(findAlert().text()).toBe(wrapper.vm.$options.successTexts[COMMIT_SUCCESS]);
});
+
+ it('scrolls to the top of the page to bring attention to the confirmation message', () => {
+ expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
+ });
});
describe('and the commit mutation fails', () => {
const commitFailedReasons = ['Commit failed'];
beforeEach(() => {
+ window.scrollTo = jest.fn();
createComponent();
findEditorHome().vm.$emit('showError', {
@@ -219,11 +269,17 @@ describe('Pipeline editor app component', () => {
`${updateFailureMessage} ${commitFailedReasons[0]}`,
);
});
+
+ it('scrolls to the top of the page to bring attention to the error message', () => {
+ expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
+ });
});
+
describe('when an unknown error occurs', () => {
const unknownReasons = ['Commit failed'];
beforeEach(() => {
+ window.scrollTo = jest.fn();
createComponent();
findEditorHome().vm.$emit('showError', {
@@ -237,6 +293,10 @@ describe('Pipeline editor app component', () => {
`${updateFailureMessage} ${unknownReasons[0]}`,
);
});
+
+ it('scrolls to the top of the page to bring attention to the error message', () => {
+ expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
+ });
});
});
});
diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js
index 51bb0ecee9c..7ec5818010a 100644
--- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js
+++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlDropdownItem, GlForm, GlSprintf, GlLoadingIcon } from '@gitlab/ui';
+import { GlForm, GlSprintf, GlLoadingIcon } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
@@ -6,34 +6,26 @@ import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import { redirectTo } from '~/lib/utils/url_utility';
import PipelineNewForm from '~/pipeline_new/components/pipeline_new_form.vue';
-import {
- mockBranches,
- mockTags,
- mockParams,
- mockPostParams,
- mockProjectId,
- mockError,
-} from '../mock_data';
+import RefsDropdown from '~/pipeline_new/components/refs_dropdown.vue';
+import { mockQueryParams, mockPostParams, mockProjectId, mockError, mockRefs } from '../mock_data';
jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(),
}));
+const projectRefsEndpoint = '/root/project/refs';
const pipelinesPath = '/root/project/-/pipelines';
const configVariablesPath = '/root/project/-/pipelines/config_variables';
-const postResponse = { id: 1 };
+const newPipelinePostResponse = { id: 1 };
+const defaultBranch = 'master';
describe('Pipeline New Form', () => {
let wrapper;
let mock;
-
- const dummySubmitEvent = {
- preventDefault() {},
- };
+ let dummySubmitEvent;
const findForm = () => wrapper.find(GlForm);
- const findDropdown = () => wrapper.find(GlDropdown);
- const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
+ const findRefsDropdown = () => wrapper.findComponent(RefsDropdown);
const findSubmitButton = () => wrapper.find('[data-testid="run_pipeline_button"]');
const findVariableRows = () => wrapper.findAll('[data-testid="ci-variable-row"]');
const findRemoveIcons = () => wrapper.findAll('[data-testid="remove-ci-variable-row"]');
@@ -44,33 +36,42 @@ describe('Pipeline New Form', () => {
const findWarningAlertSummary = () => findWarningAlert().find(GlSprintf);
const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]');
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
- const getExpectedPostParams = () => JSON.parse(mock.history.post[0].data);
- const changeRef = (i) => findDropdownItems().at(i).vm.$emit('click');
+ const getFormPostParams = () => JSON.parse(mock.history.post[0].data);
+
+ const selectBranch = (branch) => {
+ // Select a branch in the dropdown
+ findRefsDropdown().vm.$emit('input', {
+ shortName: branch,
+ fullName: `refs/heads/${branch}`,
+ });
+ };
- const createComponent = (term = '', props = {}, method = shallowMount) => {
+ const createComponent = (props = {}, method = shallowMount) => {
wrapper = method(PipelineNewForm, {
+ provide: {
+ projectRefsEndpoint,
+ },
propsData: {
projectId: mockProjectId,
pipelinesPath,
configVariablesPath,
- branches: mockBranches,
- tags: mockTags,
- defaultBranch: 'master',
+ defaultBranch,
+ refParam: defaultBranch,
settingsLink: '',
maxWarnings: 25,
...props,
},
- data() {
- return {
- searchTerm: term,
- };
- },
});
};
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {});
+ mock.onGet(projectRefsEndpoint).reply(httpStatusCodes.OK, mockRefs);
+
+ dummySubmitEvent = {
+ preventDefault: jest.fn(),
+ };
});
afterEach(() => {
@@ -80,38 +81,17 @@ describe('Pipeline New Form', () => {
mock.restore();
});
- describe('Dropdown with branches and tags', () => {
- beforeEach(() => {
- mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse);
- });
-
- it('displays dropdown with all branches and tags', () => {
- const refLength = mockBranches.length + mockTags.length;
-
- createComponent();
-
- expect(findDropdownItems()).toHaveLength(refLength);
- });
-
- it('when user enters search term the list is filtered', () => {
- createComponent('master');
-
- expect(findDropdownItems()).toHaveLength(1);
- expect(findDropdownItems().at(0).text()).toBe('master');
- });
- });
-
describe('Form', () => {
beforeEach(async () => {
- createComponent('', mockParams, mount);
+ createComponent(mockQueryParams, mount);
- mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse);
+ mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse);
await waitForPromises();
});
it('displays the correct values for the provided query params', async () => {
- expect(findDropdown().props('text')).toBe('tag-1');
+ expect(findRefsDropdown().props('value')).toEqual({ shortName: 'tag-1' });
expect(findVariableRows()).toHaveLength(3);
});
@@ -152,11 +132,19 @@ describe('Pipeline New Form', () => {
describe('Pipeline creation', () => {
beforeEach(async () => {
- mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse);
+ mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse);
await waitForPromises();
});
+ it('does not submit the native HTML form', async () => {
+ createComponent();
+
+ findForm().vm.$emit('submit', dummySubmitEvent);
+
+ expect(dummySubmitEvent.preventDefault).toHaveBeenCalled();
+ });
+
it('disables the submit button immediately after submitting', async () => {
createComponent();
@@ -171,19 +159,15 @@ describe('Pipeline New Form', () => {
it('creates pipeline with full ref and variables', async () => {
createComponent();
- changeRef(0);
-
findForm().vm.$emit('submit', dummySubmitEvent);
-
await waitForPromises();
- expect(getExpectedPostParams().ref).toEqual(wrapper.vm.$data.refValue.fullName);
- expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${postResponse.id}`);
+ expect(getFormPostParams().ref).toEqual(`refs/heads/${defaultBranch}`);
+ expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`);
});
- it('creates a pipeline with short ref and variables', async () => {
- // query params are used
- createComponent('', mockParams);
+ it('creates a pipeline with short ref and variables from the query params', async () => {
+ createComponent(mockQueryParams);
await waitForPromises();
@@ -191,19 +175,19 @@ describe('Pipeline New Form', () => {
await waitForPromises();
- expect(getExpectedPostParams()).toEqual(mockPostParams);
- expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${postResponse.id}`);
+ expect(getFormPostParams()).toEqual(mockPostParams);
+ expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`);
});
});
describe('When the ref has been changed', () => {
beforeEach(async () => {
- createComponent('', {}, mount);
+ createComponent({}, mount);
await waitForPromises();
});
it('variables persist between ref changes', async () => {
- changeRef(0); // change to master
+ selectBranch('master');
await waitForPromises();
@@ -213,7 +197,7 @@ describe('Pipeline New Form', () => {
await wrapper.vm.$nextTick();
- changeRef(1); // change to branch-1
+ selectBranch('branch-1');
await waitForPromises();
@@ -223,14 +207,14 @@ describe('Pipeline New Form', () => {
await wrapper.vm.$nextTick();
- changeRef(0); // change back to master
+ selectBranch('master');
await waitForPromises();
expect(findKeyInputs().at(0).element.value).toBe('build_var');
expect(findVariableRows().length).toBe(2);
- changeRef(1); // change back to branch-1
+ selectBranch('branch-1');
await waitForPromises();
@@ -248,7 +232,7 @@ describe('Pipeline New Form', () => {
const mockYmlDesc = 'A var from yml.';
it('loading icon is shown when content is requested and hidden when received', async () => {
- createComponent('', mockParams, mount);
+ createComponent(mockQueryParams, mount);
mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {
[mockYmlKey]: {
@@ -265,7 +249,7 @@ describe('Pipeline New Form', () => {
});
it('multi-line strings are added to the value field without removing line breaks', async () => {
- createComponent('', mockParams, mount);
+ createComponent(mockQueryParams, mount);
mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {
[mockYmlKey]: {
@@ -281,7 +265,7 @@ describe('Pipeline New Form', () => {
describe('with description', () => {
beforeEach(async () => {
- createComponent('', mockParams, mount);
+ createComponent(mockQueryParams, mount);
mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {
[mockYmlKey]: {
@@ -323,7 +307,7 @@ describe('Pipeline New Form', () => {
describe('without description', () => {
beforeEach(async () => {
- createComponent('', mockParams, mount);
+ createComponent(mockQueryParams, mount);
mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {
[mockYmlKey]: {
@@ -346,6 +330,21 @@ describe('Pipeline New Form', () => {
createComponent();
});
+ describe('when the refs cannot be loaded', () => {
+ beforeEach(() => {
+ mock
+ .onGet(projectRefsEndpoint, { params: { search: '' } })
+ .reply(httpStatusCodes.INTERNAL_SERVER_ERROR);
+
+ findRefsDropdown().vm.$emit('loadingError');
+ });
+
+ it('shows both an error alert', () => {
+ expect(findErrorAlert().exists()).toBe(true);
+ expect(findWarningAlert().exists()).toBe(false);
+ });
+ });
+
describe('when the error response can be handled', () => {
beforeEach(async () => {
mock.onPost(pipelinesPath).reply(httpStatusCodes.BAD_REQUEST, mockError);
diff --git a/spec/frontend/pipeline_new/components/refs_dropdown_spec.js b/spec/frontend/pipeline_new/components/refs_dropdown_spec.js
new file mode 100644
index 00000000000..8dafbf230f9
--- /dev/null
+++ b/spec/frontend/pipeline_new/components/refs_dropdown_spec.js
@@ -0,0 +1,182 @@
+import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import axios from '~/lib/utils/axios_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
+
+import RefsDropdown from '~/pipeline_new/components/refs_dropdown.vue';
+
+import { mockRefs, mockFilteredRefs } from '../mock_data';
+
+const projectRefsEndpoint = '/root/project/refs';
+const refShortName = 'master';
+const refFullName = 'refs/heads/master';
+
+jest.mock('~/flash');
+
+describe('Pipeline New Form', () => {
+ let wrapper;
+ let mock;
+
+ const findDropdown = () => wrapper.find(GlDropdown);
+ const findRefsDropdownItems = () => wrapper.findAll(GlDropdownItem);
+ const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
+
+ const createComponent = (props = {}, mountFn = shallowMount) => {
+ wrapper = mountFn(RefsDropdown, {
+ provide: {
+ projectRefsEndpoint,
+ },
+ propsData: {
+ value: {
+ shortName: refShortName,
+ fullName: refFullName,
+ },
+ ...props,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet(projectRefsEndpoint, { params: { search: '' } }).reply(httpStatusCodes.OK, mockRefs);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+
+ mock.restore();
+ });
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('displays empty dropdown initially', async () => {
+ await findDropdown().vm.$emit('show');
+
+ expect(findRefsDropdownItems()).toHaveLength(0);
+ });
+
+ it('does not make requests immediately', async () => {
+ expect(mock.history.get).toHaveLength(0);
+ });
+
+ describe('when user opens dropdown', () => {
+ beforeEach(async () => {
+ await findDropdown().vm.$emit('show');
+ await waitForPromises();
+ });
+
+ it('requests unfiltered tags and branches', async () => {
+ expect(mock.history.get).toHaveLength(1);
+ expect(mock.history.get[0].url).toBe(projectRefsEndpoint);
+ expect(mock.history.get[0].params).toEqual({ search: '' });
+ });
+
+ it('displays dropdown with branches and tags', async () => {
+ const refLength = mockRefs.Tags.length + mockRefs.Branches.length;
+
+ expect(findRefsDropdownItems()).toHaveLength(refLength);
+ });
+
+ it('displays the names of refs', () => {
+ // Branches
+ expect(findRefsDropdownItems().at(0).text()).toBe(mockRefs.Branches[0]);
+
+ // Tags (appear after branches)
+ const firstTag = mockRefs.Branches.length;
+ expect(findRefsDropdownItems().at(firstTag).text()).toBe(mockRefs.Tags[0]);
+ });
+
+ it('when user shows dropdown a second time, only one request is done', () => {
+ expect(mock.history.get).toHaveLength(1);
+ });
+
+ describe('when user selects a value', () => {
+ const selectedIndex = 1;
+
+ beforeEach(async () => {
+ await findRefsDropdownItems().at(selectedIndex).vm.$emit('click');
+ });
+
+ it('component emits @input', () => {
+ const inputs = wrapper.emitted('input');
+
+ expect(inputs).toHaveLength(1);
+ expect(inputs[0]).toEqual([{ shortName: 'branch-1', fullName: 'refs/heads/branch-1' }]);
+ });
+ });
+
+ describe('when user types searches for a tag', () => {
+ const mockSearchTerm = 'my-search';
+
+ beforeEach(async () => {
+ mock
+ .onGet(projectRefsEndpoint, { params: { search: mockSearchTerm } })
+ .reply(httpStatusCodes.OK, mockFilteredRefs);
+
+ await findSearchBox().vm.$emit('input', mockSearchTerm);
+ await waitForPromises();
+ });
+
+ it('requests filtered tags and branches', async () => {
+ expect(mock.history.get).toHaveLength(2);
+ expect(mock.history.get[1].params).toEqual({
+ search: mockSearchTerm,
+ });
+ });
+
+ it('displays dropdown with branches and tags', async () => {
+ const filteredRefLength = mockFilteredRefs.Tags.length + mockFilteredRefs.Branches.length;
+
+ expect(findRefsDropdownItems()).toHaveLength(filteredRefLength);
+ });
+ });
+ });
+
+ describe('when user has selected a value', () => {
+ const selectedIndex = 1;
+ const mockShortName = mockRefs.Branches[selectedIndex];
+ const mockFullName = `refs/heads/${mockShortName}`;
+
+ beforeEach(async () => {
+ mock
+ .onGet(projectRefsEndpoint, {
+ params: { ref: mockFullName },
+ })
+ .reply(httpStatusCodes.OK, mockRefs);
+
+ createComponent({
+ value: {
+ shortName: mockShortName,
+ fullName: mockFullName,
+ },
+ });
+ await findDropdown().vm.$emit('show');
+ await waitForPromises();
+ });
+
+ it('branch is checked', () => {
+ expect(findRefsDropdownItems().at(selectedIndex).props('isChecked')).toBe(true);
+ });
+ });
+
+ describe('when server returns an error', () => {
+ beforeEach(async () => {
+ mock
+ .onGet(projectRefsEndpoint, { params: { search: '' } })
+ .reply(httpStatusCodes.INTERNAL_SERVER_ERROR);
+
+ await findDropdown().vm.$emit('show');
+ await waitForPromises();
+ });
+
+ it('loading error event is emitted', () => {
+ expect(wrapper.emitted('loadingError')).toHaveLength(1);
+ expect(wrapper.emitted('loadingError')[0]).toEqual([expect.any(Error)]);
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_new/mock_data.js b/spec/frontend/pipeline_new/mock_data.js
index feb24ec602d..4fb58cb8e62 100644
--- a/spec/frontend/pipeline_new/mock_data.js
+++ b/spec/frontend/pipeline_new/mock_data.js
@@ -1,16 +1,14 @@
-export const mockBranches = [
- { shortName: 'master', fullName: 'refs/heads/master' },
- { shortName: 'branch-1', fullName: 'refs/heads/branch-1' },
- { shortName: 'branch-2', fullName: 'refs/heads/branch-2' },
-];
+export const mockRefs = {
+ Branches: ['master', 'branch-1', 'branch-2'],
+ Tags: ['1.0.0', '1.1.0', '1.2.0'],
+};
-export const mockTags = [
- { shortName: '1.0.0', fullName: 'refs/tags/1.0.0' },
- { shortName: '1.1.0', fullName: 'refs/tags/1.1.0' },
- { shortName: '1.2.0', fullName: 'refs/tags/1.2.0' },
-];
+export const mockFilteredRefs = {
+ Branches: ['branch-1'],
+ Tags: ['1.0.0', '1.1.0'],
+};
-export const mockParams = {
+export const mockQueryParams = {
refParam: 'tag-1',
variableParams: {
test_var: 'test_var_val',
diff --git a/spec/frontend/pipelines/components/pipelines_list/pipeline_mini_graph_spec.js b/spec/frontend/pipelines/components/pipelines_list/pipeline_mini_graph_spec.js
new file mode 100644
index 00000000000..154828aff4b
--- /dev/null
+++ b/spec/frontend/pipelines/components/pipelines_list/pipeline_mini_graph_spec.js
@@ -0,0 +1,83 @@
+import { shallowMount } from '@vue/test-utils';
+import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
+import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue';
+
+const { pipelines } = getJSONFixture('pipelines/pipelines.json');
+const mockStages = pipelines[0].details.stages;
+
+describe('Pipeline Mini Graph', () => {
+ let wrapper;
+
+ const findPipelineStages = () => wrapper.findAll(PipelineStage);
+ const findPipelineStagesAt = (i) => findPipelineStages().at(i);
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(PipelineMiniGraph, {
+ propsData: {
+ stages: mockStages,
+ ...props,
+ },
+ });
+ };
+
+ it('renders stages', () => {
+ createComponent();
+
+ expect(findPipelineStages()).toHaveLength(mockStages.length);
+ });
+
+ it('renders stages with a custom class', () => {
+ createComponent({ stagesClass: 'my-class' });
+
+ expect(wrapper.findAll('.my-class')).toHaveLength(mockStages.length);
+ });
+
+ it('does not fail when stages are empty', () => {
+ createComponent({ stages: [] });
+
+ expect(wrapper.exists()).toBe(true);
+ expect(findPipelineStages()).toHaveLength(0);
+ });
+
+ it('triggers events in "action request complete" in stages', () => {
+ createComponent();
+
+ findPipelineStagesAt(0).vm.$emit('pipelineActionRequestComplete');
+ findPipelineStagesAt(1).vm.$emit('pipelineActionRequestComplete');
+
+ expect(wrapper.emitted('pipelineActionRequestComplete')).toHaveLength(2);
+ });
+
+ it('update dropdown is false by default', () => {
+ createComponent();
+
+ expect(findPipelineStagesAt(0).props('updateDropdown')).toBe(false);
+ expect(findPipelineStagesAt(1).props('updateDropdown')).toBe(false);
+ });
+
+ it('update dropdown is set to true', () => {
+ createComponent({ updateDropdown: true });
+
+ expect(findPipelineStagesAt(0).props('updateDropdown')).toBe(true);
+ expect(findPipelineStagesAt(1).props('updateDropdown')).toBe(true);
+ });
+
+ it('is merge train is false by default', () => {
+ createComponent();
+
+ expect(findPipelineStagesAt(0).props('isMergeTrain')).toBe(false);
+ expect(findPipelineStagesAt(1).props('isMergeTrain')).toBe(false);
+ });
+
+ it('is merge train is set to true', () => {
+ createComponent({ isMergeTrain: true });
+
+ expect(findPipelineStagesAt(0).props('isMergeTrain')).toBe(true);
+ expect(findPipelineStagesAt(1).props('isMergeTrain')).toBe(true);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+});
diff --git a/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js b/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js
new file mode 100644
index 00000000000..60026f69b84
--- /dev/null
+++ b/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js
@@ -0,0 +1,210 @@
+import { GlDropdown } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue';
+import eventHub from '~/pipelines/event_hub';
+import { stageReply } from '../../mock_data';
+
+const dropdownPath = 'path.json';
+
+describe('Pipelines stage component', () => {
+ let wrapper;
+ let mock;
+
+ const createComponent = (props = {}) => {
+ wrapper = mount(PipelineStage, {
+ attachTo: document.body,
+ propsData: {
+ stage: {
+ status: {
+ group: 'success',
+ icon: 'status_success',
+ title: 'success',
+ },
+ dropdown_path: dropdownPath,
+ },
+ updateDropdown: false,
+ ...props,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ jest.spyOn(eventHub, '$emit');
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+
+ eventHub.$emit.mockRestore();
+ mock.restore();
+ });
+
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdownToggle = () => wrapper.find('button.dropdown-toggle');
+ const findDropdownMenu = () =>
+ wrapper.find('[data-testid="mini-pipeline-graph-dropdown-menu-list"]');
+ const findCiActionBtn = () => wrapper.find('.js-ci-action');
+ const findMergeTrainWarning = () => wrapper.find('[data-testid="warning-message-merge-trains"]');
+
+ const openStageDropdown = () => {
+ findDropdownToggle().trigger('click');
+ return new Promise((resolve) => {
+ wrapper.vm.$root.$on('bv::dropdown::show', resolve);
+ });
+ };
+
+ describe('default appearance', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should render a dropdown with the status icon', () => {
+ expect(findDropdown().exists()).toBe(true);
+ expect(findDropdownToggle().exists()).toBe(true);
+ expect(wrapper.find('[data-testid="status_success_borderless-icon"]').exists()).toBe(true);
+ });
+ });
+
+ describe('when update dropdown is changed', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+ });
+
+ describe('when user opens dropdown and stage request is successful', () => {
+ beforeEach(async () => {
+ mock.onGet(dropdownPath).reply(200, stageReply);
+ createComponent();
+
+ await openStageDropdown();
+ await axios.waitForAll();
+ });
+
+ it('should render the received data and emit `clickedDropdown` event', async () => {
+ expect(findDropdownMenu().text()).toContain(stageReply.latest_statuses[0].name);
+ expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown');
+ });
+
+ it('should refresh when updateDropdown is set to true', async () => {
+ expect(mock.history.get).toHaveLength(1);
+
+ wrapper.setProps({ updateDropdown: true });
+ await axios.waitForAll();
+
+ expect(mock.history.get).toHaveLength(2);
+ });
+ });
+
+ describe('when user opens dropdown and stage request fails', () => {
+ beforeEach(async () => {
+ mock.onGet(dropdownPath).reply(500);
+ createComponent();
+
+ await openStageDropdown();
+ await axios.waitForAll();
+ });
+
+ it('should close the dropdown', () => {
+ expect(findDropdown().classes('show')).toBe(false);
+ });
+ });
+
+ describe('update endpoint correctly', () => {
+ beforeEach(async () => {
+ const copyStage = { ...stageReply };
+ copyStage.latest_statuses[0].name = 'this is the updated content';
+ mock.onGet('bar.json').reply(200, copyStage);
+ createComponent({
+ stage: {
+ status: {
+ group: 'running',
+ icon: 'status_running',
+ title: 'running',
+ },
+ dropdown_path: 'bar.json',
+ },
+ });
+ await axios.waitForAll();
+ });
+
+ it('should update the stage to request the new endpoint provided', async () => {
+ await openStageDropdown();
+ await axios.waitForAll();
+
+ expect(findDropdownMenu().text()).toContain('this is the updated content');
+ });
+ });
+
+ describe('pipelineActionRequestComplete', () => {
+ beforeEach(() => {
+ mock.onGet(dropdownPath).reply(200, stageReply);
+ mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200);
+
+ createComponent();
+ });
+
+ const clickCiAction = async () => {
+ await openStageDropdown();
+ await axios.waitForAll();
+
+ findCiActionBtn().trigger('click');
+ await axios.waitForAll();
+ };
+
+ it('closes dropdown when job item action is clicked', async () => {
+ const hidden = jest.fn();
+
+ wrapper.vm.$root.$on('bv::dropdown::hide', hidden);
+
+ expect(hidden).toHaveBeenCalledTimes(0);
+
+ await clickCiAction();
+
+ expect(hidden).toHaveBeenCalledTimes(1);
+ });
+
+ it('emits `pipelineActionRequestComplete` when job item action is clicked', async () => {
+ await clickCiAction();
+
+ expect(wrapper.emitted('pipelineActionRequestComplete')).toHaveLength(1);
+ });
+ });
+
+ describe('With merge trains enabled', () => {
+ beforeEach(async () => {
+ mock.onGet(dropdownPath).reply(200, stageReply);
+ createComponent({
+ isMergeTrain: true,
+ });
+
+ await openStageDropdown();
+ await axios.waitForAll();
+ });
+
+ it('shows a warning on the dropdown', () => {
+ const warning = findMergeTrainWarning();
+
+ expect(warning.text()).toBe('Merge train pipeline jobs can not be retried');
+ });
+ });
+
+ describe('With merge trains disabled', () => {
+ beforeEach(async () => {
+ mock.onGet(dropdownPath).reply(200, stageReply);
+ createComponent();
+
+ await openStageDropdown();
+ await axios.waitForAll();
+ });
+
+ it('does not show a warning on the dropdown', () => {
+ const warning = findMergeTrainWarning();
+
+ expect(warning.exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js
index 3ebedc9ac87..912bc7a104a 100644
--- a/spec/frontend/pipelines/empty_state_spec.js
+++ b/spec/frontend/pipelines/empty_state_spec.js
@@ -1,24 +1,25 @@
-import { shallowMount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue';
describe('Pipelines Empty State', () => {
let wrapper;
- const findGetStartedButton = () => wrapper.find('[data-testid="get-started-pipelines"]');
- const findInfoText = () => wrapper.find('[data-testid="info-text"]').text();
- const createWrapper = () => {
- wrapper = shallowMount(EmptyState, {
+ const findIllustration = () => wrapper.find('img');
+ const findButton = () => wrapper.find('a');
+
+ const createWrapper = (props = {}) => {
+ wrapper = mount(EmptyState, {
propsData: {
- helpPagePath: 'foo',
- emptyStateSvgPath: 'foo',
+ emptyStateSvgPath: 'foo.svg',
canSetCi: true,
+ ...props,
},
});
};
- describe('renders', () => {
+ describe('when user can configure CI', () => {
beforeEach(() => {
- createWrapper();
+ createWrapper({}, mount);
});
afterEach(() => {
@@ -27,26 +28,49 @@ describe('Pipelines Empty State', () => {
});
it('should render empty state SVG', () => {
- expect(wrapper.find('img').attributes('src')).toBe('foo');
+ expect(findIllustration().attributes('src')).toBe('foo.svg');
});
it('should render empty state header', () => {
- expect(wrapper.find('[data-testid="header-text"]').text()).toBe('Build with confidence');
- });
-
- it('should render a link with provided help path', () => {
- expect(findGetStartedButton().attributes('href')).toBe('foo');
+ expect(wrapper.text()).toContain('Build with confidence');
});
it('should render empty state information', () => {
- expect(findInfoText()).toContain(
+ expect(wrapper.text()).toContain(
'GitLab CI/CD can automatically build, test, and deploy your code. Let GitLab take care of time',
'consuming tasks, so you can spend more time creating',
);
});
+ it('should render button with help path', () => {
+ expect(findButton().attributes('href')).toBe('/help/ci/quick_start/index.md');
+ });
+
it('should render button text', () => {
- expect(findGetStartedButton().text()).toBe('Get started with CI/CD');
+ expect(findButton().text()).toBe('Get started with CI/CD');
+ });
+ });
+
+ describe('when user cannot configure CI', () => {
+ beforeEach(() => {
+ createWrapper({ canSetCi: false }, mount);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('should render empty state SVG', () => {
+ expect(findIllustration().attributes('src')).toBe('foo.svg');
+ });
+
+ it('should render empty state header', () => {
+ expect(wrapper.text()).toBe('This project is not currently set up to run pipelines.');
+ });
+
+ it('should not render a link', () => {
+ expect(findButton().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js
index 3e8d4ba314c..6c3f848333c 100644
--- a/spec/frontend/pipelines/graph/graph_component_spec.js
+++ b/spec/frontend/pipelines/graph/graph_component_spec.js
@@ -20,6 +20,10 @@ describe('graph component', () => {
const defaultProps = {
pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'),
+ configPaths: {
+ metricsPath: '',
+ graphqlResourceEtag: 'this/is/a/path',
+ },
};
const defaultData = {
diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
index 202365ecd35..44d8e467f51 100644
--- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
+++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
@@ -9,6 +9,8 @@ import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_w
import { mockPipelineResponse } from './mock_data';
const defaultProvide = {
+ graphqlResourceEtag: 'frog/amphibirama/etag/',
+ metricsPath: '',
pipelineProjectPath: 'frog/amphibirama',
pipelineIid: '22',
};
@@ -87,6 +89,13 @@ describe('Pipeline graph wrapper', () => {
it('displays the graph', () => {
expect(getGraph().exists()).toBe(true);
});
+
+ it('passes the etag resource and metrics path to the graph', () => {
+ expect(getGraph().props('configPaths')).toMatchObject({
+ graphqlResourceEtag: defaultProvide.graphqlResourceEtag,
+ metricsPath: defaultProvide.metricsPath,
+ });
+ });
});
describe('when there is an error', () => {
@@ -121,4 +130,48 @@ describe('Pipeline graph wrapper', () => {
expect(wrapper.vm.$apollo.queries.pipeline.refetch).toHaveBeenCalled();
});
});
+
+ describe('when query times out', () => {
+ const advanceApolloTimers = async () => {
+ jest.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+ };
+
+ beforeEach(async () => {
+ const errorData = {
+ data: {
+ project: {
+ pipelines: null,
+ },
+ },
+ errors: [{ message: 'timeout' }],
+ };
+
+ const failSucceedFail = jest
+ .fn()
+ .mockResolvedValueOnce(errorData)
+ .mockResolvedValueOnce(mockPipelineResponse)
+ .mockResolvedValueOnce(errorData);
+
+ createComponentWithApollo(failSucceedFail);
+ await wrapper.vm.$nextTick();
+ });
+
+ it('shows correct errors and does not overwrite populated data when data is empty', async () => {
+ /* fails at first, shows error, no data yet */
+ expect(getAlert().exists()).toBe(true);
+ expect(getGraph().exists()).toBe(false);
+
+ /* succeeds, clears error, shows graph */
+ await advanceApolloTimers();
+ expect(getAlert().exists()).toBe(false);
+ expect(getGraph().exists()).toBe(true);
+
+ /* fails again, alert returns but data persists */
+ await advanceApolloTimers();
+ expect(getAlert().exists()).toBe(true);
+ expect(getGraph().exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js
index 8f01accccc1..4c72dad735e 100644
--- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js
+++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js
@@ -20,6 +20,10 @@ describe('Linked Pipelines Column', () => {
columnTitle: 'Downstream',
linkedPipelines: processedPipeline.downstream,
type: DOWNSTREAM,
+ configPaths: {
+ metricsPath: '',
+ graphqlResourceEtag: 'this/is/a/path',
+ },
};
let wrapper;
@@ -112,7 +116,7 @@ describe('Linked Pipelines Column', () => {
it('emits the error', async () => {
await clickExpandButton();
- expect(wrapper.emitted().error).toEqual([[LOAD_FAILURE]]);
+ expect(wrapper.emitted().error).toEqual([[{ type: LOAD_FAILURE, skipSentry: true }]]);
});
it('does not show the pipeline', async () => {
@@ -163,7 +167,7 @@ describe('Linked Pipelines Column', () => {
it('emits the error', async () => {
await clickExpandButton();
- expect(wrapper.emitted().error).toEqual([[LOAD_FAILURE]]);
+ expect(wrapper.emitted().error).toEqual([[{ type: LOAD_FAILURE, skipSentry: true }]]);
});
it('does not show the pipeline', async () => {
diff --git a/spec/frontend/pipelines/graph_shared/links_inner_spec.js b/spec/frontend/pipelines/graph_shared/links_inner_spec.js
index 6cabe2bc8a7..6fef1c9b62e 100644
--- a/spec/frontend/pipelines/graph_shared/links_inner_spec.js
+++ b/spec/frontend/pipelines/graph_shared/links_inner_spec.js
@@ -1,5 +1,15 @@
import { shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
import { setHTMLFixture } from 'helpers/fixtures';
+import axios from '~/lib/utils/axios_utils';
+import {
+ PIPELINES_DETAIL_LINK_DURATION,
+ PIPELINES_DETAIL_LINKS_TOTAL,
+ PIPELINES_DETAIL_LINKS_JOB_RATIO,
+} from '~/performance/constants';
+import * as perfUtils from '~/performance/utils';
+import * as sentryUtils from '~/pipelines/components/graph/utils';
+import * as Api from '~/pipelines/components/graph_shared/api';
import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue';
import { createJobsHash } from '~/pipelines/utils';
import {
@@ -18,7 +28,9 @@ describe('Links Inner component', () => {
containerMeasurements: { width: 1019, height: 445 },
pipelineId: 1,
pipelineData: [],
+ totalGroups: 10,
};
+
let wrapper;
const createComponent = (props) => {
@@ -194,4 +206,141 @@ describe('Links Inner component', () => {
expect(firstLink.classes(hoverColorClass)).toBe(true);
});
});
+
+ describe('performance metrics', () => {
+ let markAndMeasure;
+ let reportToSentry;
+ let reportPerformance;
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb());
+ markAndMeasure = jest.spyOn(perfUtils, 'performanceMarkAndMeasure');
+ reportToSentry = jest.spyOn(sentryUtils, 'reportToSentry');
+ reportPerformance = jest.spyOn(Api, 'reportPerformance');
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('with no metrics config object', () => {
+ beforeEach(() => {
+ setFixtures(pipelineData);
+ createComponent({
+ pipelineData: pipelineData.stages,
+ });
+ });
+
+ it('is not called', () => {
+ expect(markAndMeasure).not.toHaveBeenCalled();
+ expect(reportToSentry).not.toHaveBeenCalled();
+ expect(reportPerformance).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('with metrics config set to false', () => {
+ beforeEach(() => {
+ setFixtures(pipelineData);
+ createComponent({
+ pipelineData: pipelineData.stages,
+ metricsConfig: {
+ collectMetrics: false,
+ metricsPath: '/path/to/metrics',
+ },
+ });
+ });
+
+ it('is not called', () => {
+ expect(markAndMeasure).not.toHaveBeenCalled();
+ expect(reportToSentry).not.toHaveBeenCalled();
+ expect(reportPerformance).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('with no metrics path', () => {
+ beforeEach(() => {
+ setFixtures(pipelineData);
+ createComponent({
+ pipelineData: pipelineData.stages,
+ metricsConfig: {
+ collectMetrics: true,
+ metricsPath: '',
+ },
+ });
+ });
+
+ it('is not called', () => {
+ expect(markAndMeasure).not.toHaveBeenCalled();
+ expect(reportToSentry).not.toHaveBeenCalled();
+ expect(reportPerformance).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('with metrics path and collect set to true', () => {
+ const metricsPath = '/root/project/-/ci/prometheus_metrics/histograms.json';
+ const duration = 0.0478;
+ const numLinks = 1;
+ const metricsData = {
+ histograms: [
+ { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 },
+ { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks },
+ {
+ name: PIPELINES_DETAIL_LINKS_JOB_RATIO,
+ value: numLinks / defaultProps.totalGroups,
+ },
+ ],
+ };
+
+ describe('when no duration is obtained', () => {
+ beforeEach(() => {
+ jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => {
+ return [];
+ });
+
+ setFixtures(pipelineData);
+
+ createComponent({
+ pipelineData: pipelineData.stages,
+ metricsConfig: {
+ collectMetrics: true,
+ path: metricsPath,
+ },
+ });
+ });
+
+ it('attempts to collect metrics', () => {
+ expect(markAndMeasure).toHaveBeenCalled();
+ expect(reportPerformance).not.toHaveBeenCalled();
+ expect(reportToSentry).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('with duration and no error', () => {
+ beforeEach(() => {
+ jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => {
+ return [{ duration }];
+ });
+
+ setFixtures(pipelineData);
+
+ createComponent({
+ pipelineData: pipelineData.stages,
+ metricsConfig: {
+ collectMetrics: true,
+ path: metricsPath,
+ },
+ });
+ });
+
+ it('it calls reportPerformance with expected arguments', () => {
+ expect(markAndMeasure).toHaveBeenCalled();
+ expect(reportPerformance).toHaveBeenCalled();
+ expect(reportPerformance).toHaveBeenCalledWith(metricsPath, metricsData);
+ expect(reportToSentry).not.toHaveBeenCalled();
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/pipelines/graph_shared/links_layer_spec.js b/spec/frontend/pipelines/graph_shared/links_layer_spec.js
index 0ff8583fbff..43d8fe28893 100644
--- a/spec/frontend/pipelines/graph_shared/links_layer_spec.js
+++ b/spec/frontend/pipelines/graph_shared/links_layer_spec.js
@@ -79,6 +79,24 @@ describe('links layer component', () => {
});
});
+ describe('with width or height measurement at 0', () => {
+ beforeEach(() => {
+ createComponent({ props: { containerMeasurements: { width: 0, height: 100 } } });
+ });
+
+ it('renders the default slot', () => {
+ expect(wrapper.html()).toContain(slotContent);
+ });
+
+ it('does not render the alert component', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('does not render the inner links component', () => {
+ expect(findLinksInner().exists()).toBe(false);
+ });
+ });
+
describe('interactions', () => {
beforeEach(() => {
createComponent({ mountFn: mount, props: { pipelineData: tooManyStages } });
diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js
index 2afdbb05107..337838c41b3 100644
--- a/spec/frontend/pipelines/mock_data.js
+++ b/spec/frontend/pipelines/mock_data.js
@@ -2,328 +2,6 @@ const PIPELINE_RUNNING = 'RUNNING';
const PIPELINE_CANCELED = 'CANCELED';
const PIPELINE_FAILED = 'FAILED';
-export const pipelineWithStages = {
- id: 20333396,
- user: {
- id: 128633,
- name: 'Rémy Coutable',
- username: 'rymai',
- state: 'active',
- avatar_url:
- 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon',
- web_url: 'https://gitlab.com/rymai',
- path: '/rymai',
- },
- active: true,
- coverage: '58.24',
- source: 'push',
- created_at: '2018-04-11T14:04:53.881Z',
- updated_at: '2018-04-11T14:05:00.792Z',
- path: '/gitlab-org/gitlab/pipelines/20333396',
- flags: {
- latest: true,
- stuck: false,
- auto_devops: false,
- yaml_errors: false,
- retryable: false,
- cancelable: true,
- failure_reason: false,
- },
- details: {
- status: {
- icon: 'status_running',
- text: 'running',
- label: 'running',
- group: 'running',
- has_details: true,
- details_path: '/gitlab-org/gitlab/pipelines/20333396',
- favicon:
- 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_running-2eb56be2871937954b2ba6d6f4ee9fdf7e5e1c146ac45f7be98119ccaca1aca9.ico',
- },
- duration: null,
- finished_at: null,
- stages: [
- {
- name: 'build',
- title: 'build: skipped',
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- has_details: true,
- details_path: '/gitlab-org/gitlab/pipelines/20333396#build',
- favicon:
- 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_skipped-a2eee568a5bffdb494050c7b62dde241de9189280836288ac8923d369f16222d.ico',
- },
- path: '/gitlab-org/gitlab/pipelines/20333396#build',
- dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=build',
- },
- {
- name: 'prepare',
- title: 'prepare: passed',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- has_details: true,
- details_path: '/gitlab-org/gitlab/pipelines/20333396#prepare',
- favicon:
- 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_success-26f59841becbef8c6fe414e9e74471d8bfd6a91b5855c19fe7f5923a40a7da47.ico',
- },
- path: '/gitlab-org/gitlab/pipelines/20333396#prepare',
- dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=prepare',
- },
- {
- name: 'test',
- title: 'test: running',
- status: {
- icon: 'status_running',
- text: 'running',
- label: 'running',
- group: 'running',
- has_details: true,
- details_path: '/gitlab-org/gitlab/pipelines/20333396#test',
- favicon:
- 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_running-2eb56be2871937954b2ba6d6f4ee9fdf7e5e1c146ac45f7be98119ccaca1aca9.ico',
- },
- path: '/gitlab-org/gitlab/pipelines/20333396#test',
- dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=test',
- },
- {
- name: 'post-test',
- title: 'post-test: created',
- status: {
- icon: 'status_created',
- text: 'created',
- label: 'created',
- group: 'created',
- has_details: true,
- details_path: '/gitlab-org/gitlab/pipelines/20333396#post-test',
- favicon:
- 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico',
- },
- path: '/gitlab-org/gitlab/pipelines/20333396#post-test',
- dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=post-test',
- },
- {
- name: 'pages',
- title: 'pages: created',
- status: {
- icon: 'status_created',
- text: 'created',
- label: 'created',
- group: 'created',
- has_details: true,
- details_path: '/gitlab-org/gitlab/pipelines/20333396#pages',
- favicon:
- 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico',
- },
- path: '/gitlab-org/gitlab/pipelines/20333396#pages',
- dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=pages',
- },
- {
- name: 'post-cleanup',
- title: 'post-cleanup: created',
- status: {
- icon: 'status_created',
- text: 'created',
- label: 'created',
- group: 'created',
- has_details: true,
- details_path: '/gitlab-org/gitlab/pipelines/20333396#post-cleanup',
- favicon:
- 'https://assets.gitlab-static.net/assets/ci_favicons/favicon_status_created-e997aa0b7db73165df8a9d6803932b18d7b7cc37d604d2d96e378fea2dba9c5f.ico',
- },
- path: '/gitlab-org/gitlab/pipelines/20333396#post-cleanup',
- dropdown_path: '/gitlab-org/gitlab/pipelines/20333396/stage.json?stage=post-cleanup',
- },
- ],
- artifacts: [
- {
- name: 'gitlab:assets:compile',
- expired: false,
- expire_at: '2018-05-12T14:22:54.730Z',
- path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411438/artifacts/browse',
- },
- {
- name: 'rspec-mysql 12 28',
- expired: false,
- expire_at: '2018-05-12T14:22:45.136Z',
- path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411397/artifacts/browse',
- },
- {
- name: 'rspec-mysql 6 28',
- expired: false,
- expire_at: '2018-05-12T14:22:41.523Z',
- path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411391/artifacts/browse',
- },
- {
- name: 'rspec-pg geo 0 1',
- expired: false,
- expire_at: '2018-05-12T14:22:13.287Z',
- path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411353/artifacts/browse',
- },
- {
- name: 'rspec-mysql 0 28',
- expired: false,
- expire_at: '2018-05-12T14:22:06.834Z',
- path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411385/artifacts/browse',
- },
- {
- name: 'spinach-mysql 0 2',
- expired: false,
- expire_at: '2018-05-12T14:21:51.409Z',
- path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411423/artifacts/browse',
- },
- {
- name: 'karma',
- expired: false,
- expire_at: '2018-05-12T14:21:20.934Z',
- path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411440/artifacts/browse',
- },
- {
- name: 'spinach-pg 0 2',
- expired: false,
- expire_at: '2018-05-12T14:20:01.028Z',
- path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411419/artifacts/browse',
- },
- {
- name: 'spinach-pg 1 2',
- expired: false,
- expire_at: '2018-05-12T14:19:04.336Z',
- path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411421/artifacts/browse',
- },
- {
- name: 'sast',
- expired: null,
- expire_at: null,
- path: '/gitlab-org/gitlab/-/jobs/62411442/artifacts/download',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411442/artifacts/browse',
- },
- {
- name: 'code_quality',
- expired: false,
- expire_at: '2018-04-18T14:16:24.484Z',
- path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411441/artifacts/browse',
- },
- {
- name: 'cache gems',
- expired: null,
- expire_at: null,
- path: '/gitlab-org/gitlab/-/jobs/62411447/artifacts/download',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411447/artifacts/browse',
- },
- {
- name: 'dependency_scanning',
- expired: null,
- expire_at: null,
- path: '/gitlab-org/gitlab/-/jobs/62411443/artifacts/download',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411443/artifacts/browse',
- },
- {
- name: 'compile-assets',
- expired: false,
- expire_at: '2018-04-18T14:12:07.638Z',
- path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411334/artifacts/browse',
- },
- {
- name: 'setup-test-env',
- expired: false,
- expire_at: '2018-04-18T14:10:27.024Z',
- path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411336/artifacts/browse',
- },
- {
- name: 'retrieve-tests-metadata',
- expired: false,
- expire_at: '2018-05-12T14:06:35.926Z',
- path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/download',
- keep_path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/keep',
- browse_path: '/gitlab-org/gitlab/-/jobs/62411333/artifacts/browse',
- },
- ],
- manual_actions: [
- {
- name: 'package-and-qa',
- path: '/gitlab-org/gitlab/-/jobs/62411330/play',
- playable: true,
- },
- {
- name: 'review-docs-deploy',
- path: '/gitlab-org/gitlab/-/jobs/62411332/play',
- playable: true,
- },
- ],
- },
- ref: {
- name: 'master',
- path: '/gitlab-org/gitlab/commits/master',
- tag: false,
- branch: true,
- },
- commit: {
- id: 'e6a2885c503825792cb8a84a8731295e361bd059',
- short_id: 'e6a2885c',
- title: "Merge branch 'ce-to-ee-2018-04-11' into 'master'",
- created_at: '2018-04-11T14:04:39.000Z',
- parent_ids: [
- '5d9b5118f6055f72cff1a82b88133609912f2c1d',
- '6fdc6ee76a8062fe41b1a33f7c503334a6ebdc02',
- ],
- message:
- "Merge branch 'ce-to-ee-2018-04-11' into 'master'\n\nCE upstream - 2018-04-11 12:26 UTC\n\nSee merge request gitlab-org/gitlab-ee!5326",
- author_name: 'Rémy Coutable',
- author_email: 'remy@rymai.me',
- authored_date: '2018-04-11T14:04:39.000Z',
- committer_name: 'Rémy Coutable',
- committer_email: 'remy@rymai.me',
- committed_date: '2018-04-11T14:04:39.000Z',
- author: {
- id: 128633,
- name: 'Rémy Coutable',
- username: 'rymai',
- state: 'active',
- avatar_url:
- 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon',
- web_url: 'https://gitlab.com/rymai',
- path: '/rymai',
- },
- author_gravatar_url:
- 'https://secure.gravatar.com/avatar/263da227929cc0035cb0eba512bcf81a?s=80\u0026d=identicon',
- commit_url:
- 'https://gitlab.com/gitlab-org/gitlab/commit/e6a2885c503825792cb8a84a8731295e361bd059',
- commit_path: '/gitlab-org/gitlab/commit/e6a2885c503825792cb8a84a8731295e361bd059',
- },
- cancel_path: '/gitlab-org/gitlab/pipelines/20333396/cancel',
- triggered_by: null,
- triggered: [],
-};
-
const threeWeeksAgo = new Date();
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
diff --git a/spec/frontend/pipelines/pipeline_triggerer_spec.js b/spec/frontend/pipelines/pipeline_triggerer_spec.js
index 467a97d95c7..ffb2721f159 100644
--- a/spec/frontend/pipelines/pipeline_triggerer_spec.js
+++ b/spec/frontend/pipelines/pipeline_triggerer_spec.js
@@ -35,8 +35,8 @@ describe('Pipelines Triggerer', () => {
wrapper.destroy();
});
- it('should render a table cell', () => {
- expect(wrapper.find('.table-section').exists()).toBe(true);
+ it('should render pipeline triggerer table cell', () => {
+ expect(wrapper.find('[data-testid="pipeline-triggerer"]').exists()).toBe(true);
});
it('should pass triggerer information when triggerer is provided', () => {
diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js
index 44c9def99cc..367c7f2b2f6 100644
--- a/spec/frontend/pipelines/pipeline_url_spec.js
+++ b/spec/frontend/pipelines/pipeline_url_spec.js
@@ -1,19 +1,20 @@
import { shallowMount } from '@vue/test-utils';
-import $ from 'jquery';
import { trimText } from 'helpers/text_helper';
import PipelineUrlComponent from '~/pipelines/components/pipelines_list/pipeline_url.vue';
-$.fn.popover = () => {};
+const projectPath = 'test/test';
describe('Pipeline Url Component', () => {
let wrapper;
+ const findTableCell = () => wrapper.find('[data-testid="pipeline-url-table-cell"]');
const findPipelineUrlLink = () => wrapper.find('[data-testid="pipeline-url-link"]');
const findScheduledTag = () => wrapper.find('[data-testid="pipeline-url-scheduled"]');
const findLatestTag = () => wrapper.find('[data-testid="pipeline-url-latest"]');
const findYamlTag = () => wrapper.find('[data-testid="pipeline-url-yaml"]');
const findFailureTag = () => wrapper.find('[data-testid="pipeline-url-failure"]');
const findAutoDevopsTag = () => wrapper.find('[data-testid="pipeline-url-autodevops"]');
+ const findAutoDevopsTagLink = () => wrapper.find('[data-testid="pipeline-url-autodevops-link"]');
const findStuckTag = () => wrapper.find('[data-testid="pipeline-url-stuck"]');
const findDetachedTag = () => wrapper.find('[data-testid="pipeline-url-detached"]');
const findForkTag = () => wrapper.find('[data-testid="pipeline-url-fork"]');
@@ -23,9 +24,9 @@ describe('Pipeline Url Component', () => {
pipeline: {
id: 1,
path: 'foo',
+ project: { full_path: `/${projectPath}` },
flags: {},
},
- autoDevopsHelpPath: 'foo',
pipelineScheduleUrl: 'foo',
};
@@ -33,7 +34,7 @@ describe('Pipeline Url Component', () => {
wrapper = shallowMount(PipelineUrlComponent, {
propsData: { ...defaultProps, ...props },
provide: {
- targetProjectFullPath: 'test/test',
+ targetProjectFullPath: projectPath,
},
});
};
@@ -43,10 +44,10 @@ describe('Pipeline Url Component', () => {
wrapper = null;
});
- it('should render a table cell', () => {
+ it('should render pipeline url table cell', () => {
createComponent();
- expect(wrapper.attributes('class')).toContain('table-section');
+ expect(findTableCell().exists()).toBe(true);
});
it('should render a link the provided path and id', () => {
@@ -57,6 +58,19 @@ describe('Pipeline Url Component', () => {
expect(findPipelineUrlLink().text()).toBe('#1');
});
+ it('should not render tags when flags are not set', () => {
+ createComponent();
+
+ expect(findStuckTag().exists()).toBe(false);
+ expect(findLatestTag().exists()).toBe(false);
+ expect(findYamlTag().exists()).toBe(false);
+ expect(findAutoDevopsTag().exists()).toBe(false);
+ expect(findFailureTag().exists()).toBe(false);
+ expect(findScheduledTag().exists()).toBe(false);
+ expect(findForkTag().exists()).toBe(false);
+ expect(findTrainTag().exists()).toBe(false);
+ });
+
it('should render the stuck tag when flag is provided', () => {
createComponent({
pipeline: {
@@ -96,6 +110,7 @@ describe('Pipeline Url Component', () => {
it('should render an autodevops badge when flag is provided', () => {
createComponent({
pipeline: {
+ ...defaultProps.pipeline,
flags: {
auto_devops: true,
},
@@ -103,6 +118,11 @@ describe('Pipeline Url Component', () => {
});
expect(trimText(findAutoDevopsTag().text())).toBe('Auto DevOps');
+
+ expect(findAutoDevopsTagLink().attributes()).toMatchObject({
+ href: '/help/topics/autodevops/index.md',
+ target: '_blank',
+ });
});
it('should render a detached badge when flag is provided', () => {
@@ -147,7 +167,7 @@ describe('Pipeline Url Component', () => {
createComponent({
pipeline: {
flags: {},
- project: { fullPath: 'test/forked' },
+ project: { fullPath: '/test/forked' },
},
});
diff --git a/spec/frontend/pipelines/pipelines_actions_spec.js b/spec/frontend/pipelines/pipelines_actions_spec.js
index 1e6c9e50a7e..c4bfec8ae14 100644
--- a/spec/frontend/pipelines/pipelines_actions_spec.js
+++ b/spec/frontend/pipelines/pipelines_actions_spec.js
@@ -1,11 +1,11 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { shallowMount, mount } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import { TEST_HOST } from 'spec/test_constants';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
-import PipelinesActions from '~/pipelines/components/pipelines_list/pipelines_actions.vue';
+import PipelinesManualActions from '~/pipelines/components/pipelines_list/pipelines_manual_actions.vue';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
jest.mock('~/flash');
@@ -15,7 +15,7 @@ describe('Pipelines Actions dropdown', () => {
let mock;
const createComponent = (props, mountFn = shallowMount) => {
- wrapper = mountFn(PipelinesActions, {
+ wrapper = mountFn(PipelinesManualActions, {
propsData: {
...props,
},
@@ -63,10 +63,6 @@ describe('Pipelines Actions dropdown', () => {
});
describe('on click', () => {
- beforeEach(() => {
- createComponent({ actions: mockActions }, mount);
- });
-
it('makes a request and toggles the loading state', async () => {
mock.onPost(mockActions.path).reply(200);
diff --git a/spec/frontend/pipelines/pipelines_artifacts_spec.js b/spec/frontend/pipelines/pipelines_artifacts_spec.js
index f077833ae16..d4a2db08d97 100644
--- a/spec/frontend/pipelines/pipelines_artifacts_spec.js
+++ b/spec/frontend/pipelines/pipelines_artifacts_spec.js
@@ -1,24 +1,27 @@
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
+import { GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue';
describe('Pipelines Artifacts dropdown', () => {
let wrapper;
const createComponent = () => {
- wrapper = mount(PipelineArtifacts, {
+ wrapper = shallowMount(PipelineArtifacts, {
propsData: {
artifacts: [
{
- name: 'artifact',
+ name: 'job my-artifact',
path: '/download/path',
},
{
- name: 'artifact two',
+ name: 'job-2 my-artifact-2',
path: '/download/path-two',
},
],
},
+ stubs: {
+ GlSprintf,
+ },
});
};
@@ -39,8 +42,8 @@ describe('Pipelines Artifacts dropdown', () => {
});
it('should render a link with the provided path', () => {
- expect(findFirstGlDropdownItem().find('a').attributes('href')).toEqual('/download/path');
+ expect(findFirstGlDropdownItem().attributes('href')).toBe('/download/path');
- expect(findFirstGlDropdownItem().text()).toContain('artifact');
+ expect(findFirstGlDropdownItem().text()).toBe('Download job my-artifact artifact');
});
});
diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js
index 811303a5624..b04880b43ae 100644
--- a/spec/frontend/pipelines/pipelines_spec.js
+++ b/spec/frontend/pipelines/pipelines_spec.js
@@ -1,4 +1,4 @@
-import { GlFilteredSearch, GlButton, GlLoadingIcon, GlPagination } from '@gitlab/ui';
+import { GlButton, GlFilteredSearch, GlLoadingIcon, GlPagination } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { chunk } from 'lodash';
@@ -18,7 +18,7 @@ import Store from '~/pipelines/stores/pipelines_store';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
-import { pipelineWithStages, stageReply, users, mockSearch, branches } from './mock_data';
+import { stageReply, users, mockSearch, branches } from './mock_data';
jest.mock('~/flash');
@@ -27,6 +27,9 @@ const mockProjectId = '21';
const mockPipelinesEndpoint = `/${mockProjectPath}/pipelines.json`;
const mockPipelinesResponse = getJSONFixture('pipelines/pipelines.json');
const mockPipelinesIds = mockPipelinesResponse.pipelines.map(({ id }) => id);
+const mockPipelineWithStages = mockPipelinesResponse.pipelines.find(
+ (p) => p.details.stages && p.details.stages.length,
+);
describe('Pipelines', () => {
let wrapper;
@@ -34,8 +37,6 @@ describe('Pipelines', () => {
let origWindowLocation;
const paths = {
- autoDevopsHelpPath: '/help/topics/autodevops/index.md',
- helpPagePath: '/help/ci/quick_start/README',
emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg',
errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg',
noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg',
@@ -45,8 +46,6 @@ describe('Pipelines', () => {
};
const noPermissions = {
- autoDevopsHelpPath: '/help/topics/autodevops/index.md',
- helpPagePath: '/help/ci/quick_start/README',
emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg',
errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg',
noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg',
@@ -70,7 +69,8 @@ describe('Pipelines', () => {
const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button');
const findCiLintButton = () => wrapper.findByTestId('ci-lint-button');
const findCleanCacheButton = () => wrapper.findByTestId('clear-cache-button');
- const findStagesDropdown = () => wrapper.findByTestId('mini-pipeline-graph-dropdown-toggle');
+ const findStagesDropdownToggle = () =>
+ wrapper.find('[data-testid="mini-pipeline-graph-dropdown"] .dropdown-toggle');
const findPipelineUrlLinks = () => wrapper.findAll('[data-testid="pipeline-url-link"]');
const createComponent = (props = defaultProps) => {
@@ -539,14 +539,15 @@ describe('Pipelines', () => {
});
it('renders empty state', () => {
- expect(findEmptyState().find('[data-testid="header-text"]').text()).toBe(
- 'Build with confidence',
- );
- expect(findEmptyState().find('[data-testid="info-text"]').text()).toContain(
+ expect(findEmptyState().text()).toContain('Build with confidence');
+ expect(findEmptyState().text()).toContain(
'GitLab CI/CD can automatically build, test, and deploy your code.',
);
+
expect(findEmptyState().find(GlButton).text()).toBe('Get started with CI/CD');
- expect(findEmptyState().find(GlButton).attributes('href')).toBe(paths.helpPagePath);
+ expect(findEmptyState().find(GlButton).attributes('href')).toBe(
+ '/help/ci/quick_start/index.md',
+ );
});
it('does not render tabs nor buttons', () => {
@@ -613,14 +614,15 @@ describe('Pipelines', () => {
mock.onGet(mockPipelinesEndpoint, { scope: 'all', page: '1' }).reply(
200,
{
- pipelines: [pipelineWithStages],
+ pipelines: [mockPipelineWithStages],
count: { all: '1' },
},
{
'POLL-INTERVAL': 100,
},
);
- mock.onGet(pipelineWithStages.details.stages[0].dropdown_path).reply(200, stageReply);
+
+ mock.onGet(mockPipelineWithStages.details.stages[0].dropdown_path).reply(200, stageReply);
createComponent();
@@ -640,7 +642,7 @@ describe('Pipelines', () => {
// Mock init a polling cycle
wrapper.vm.poll.options.notificationCallback(true);
- findStagesDropdown().trigger('click');
+ findStagesDropdownToggle().trigger('click');
await waitForPromises();
@@ -650,7 +652,9 @@ describe('Pipelines', () => {
});
it('stops polling & restarts polling', async () => {
- findStagesDropdown().trigger('click');
+ findStagesDropdownToggle().trigger('click');
+
+ await waitForPromises();
expect(cancelMock).not.toHaveBeenCalled();
expect(stopMock).toHaveBeenCalled();
diff --git a/spec/frontend/pipelines/pipelines_table_row_spec.js b/spec/frontend/pipelines/pipelines_table_row_spec.js
index 660651547fc..68d46575081 100644
--- a/spec/frontend/pipelines/pipelines_table_row_spec.js
+++ b/spec/frontend/pipelines/pipelines_table_row_spec.js
@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
import PipelinesTableRowComponent from '~/pipelines/components/pipelines_list/pipelines_table_row.vue';
import eventHub from '~/pipelines/event_hub';
@@ -9,7 +10,6 @@ describe('Pipelines Table Row', () => {
mount(PipelinesTableRowComponent, {
propsData: {
pipeline,
- autoDevopsHelpPath: 'foo',
viewType: 'root',
},
});
@@ -19,8 +19,6 @@ describe('Pipelines Table Row', () => {
let pipelineWithoutAuthor;
let pipelineWithoutCommit;
- preloadFixtures(jsonFixtureName);
-
beforeEach(() => {
const { pipelines } = getJSONFixture(jsonFixtureName);
@@ -149,16 +147,22 @@ describe('Pipelines Table Row', () => {
});
describe('stages column', () => {
- beforeEach(() => {
+ const findAllMiniPipelineStages = () =>
+ wrapper.findAll('.table-section:nth-child(5) [data-testid="mini-pipeline-graph-dropdown"]');
+
+ it('should render an icon for each stage', () => {
wrapper = createWrapper(pipeline);
+
+ expect(findAllMiniPipelineStages()).toHaveLength(pipeline.details.stages.length);
});
- it('should render an icon for each stage', () => {
- expect(
- wrapper.findAll(
- '.table-section:nth-child(4) [data-testid="mini-pipeline-graph-dropdown-toggle"]',
- ).length,
- ).toEqual(pipeline.details.stages.length);
+ it('should not render stages when stages are empty', () => {
+ const withoutStages = { ...pipeline };
+ withoutStages.details = { ...withoutStages.details, stages: null };
+
+ wrapper = createWrapper(withoutStages);
+
+ expect(findAllMiniPipelineStages()).toHaveLength(0);
});
});
@@ -183,9 +187,16 @@ describe('Pipelines Table Row', () => {
expect(wrapper.find('.js-pipelines-retry-button').attributes('title')).toMatch('Retry');
expect(wrapper.find('.js-pipelines-cancel-button').exists()).toBe(true);
expect(wrapper.find('.js-pipelines-cancel-button').attributes('title')).toMatch('Cancel');
- const dropdownMenu = wrapper.find('.dropdown-menu');
+ });
+
+ it('should render the manual actions', async () => {
+ const manualActions = wrapper.find('[data-testid="pipelines-manual-actions-dropdown"]');
+
+ // Click on the dropdown and wait for `lazy` dropdown items
+ manualActions.find('.dropdown-toggle').trigger('click');
+ await waitForPromises();
- expect(dropdownMenu.text()).toContain(scheduledJobAction.name);
+ expect(manualActions.text()).toContain(scheduledJobAction.name);
});
it('emits `retryPipeline` event when retry button is clicked and toggles loading', () => {
diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js
index fd73d507919..952bea81052 100644
--- a/spec/frontend/pipelines/pipelines_table_spec.js
+++ b/spec/frontend/pipelines/pipelines_table_spec.js
@@ -1,5 +1,18 @@
+import { GlTable } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
+import PipelineOperations from '~/pipelines/components/pipelines_list/pipeline_operations.vue';
+import PipelineTriggerer from '~/pipelines/components/pipelines_list/pipeline_triggerer.vue';
+import PipelineUrl from '~/pipelines/components/pipelines_list/pipeline_url.vue';
+import PipelinesStatusBadge from '~/pipelines/components/pipelines_list/pipelines_status_badge.vue';
import PipelinesTable from '~/pipelines/components/pipelines_list/pipelines_table.vue';
+import PipelinesTimeago from '~/pipelines/components/pipelines_list/time_ago.vue';
+
+import eventHub from '~/pipelines/event_hub';
+import CommitComponent from '~/vue_shared/components/commit.vue';
+
+jest.mock('~/pipelines/event_hub');
describe('Pipelines Table', () => {
let pipeline;
@@ -9,24 +22,52 @@ describe('Pipelines Table', () => {
const defaultProps = {
pipelines: [],
- autoDevopsHelpPath: 'foo',
viewType: 'root',
};
- const createComponent = (props = defaultProps) => {
- wrapper = mount(PipelinesTable, {
- propsData: props,
- });
+ const createMockPipeline = () => {
+ const { pipelines } = getJSONFixture(jsonFixtureName);
+ return pipelines.find((p) => p.user !== null && p.commit !== null);
};
- const findRows = () => wrapper.findAll('.commit.gl-responsive-table-row');
- preloadFixtures(jsonFixtureName);
+ const createComponent = (props = {}, flagState = false) => {
+ wrapper = extendedWrapper(
+ mount(PipelinesTable, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ provide: {
+ glFeatures: {
+ newPipelinesTable: flagState,
+ },
+ },
+ }),
+ );
+ };
- beforeEach(() => {
- const { pipelines } = getJSONFixture(jsonFixtureName);
- pipeline = pipelines.find((p) => p.user !== null && p.commit !== null);
+ const findRows = () => wrapper.findAll('.commit.gl-responsive-table-row');
+ const findGlTable = () => wrapper.findComponent(GlTable);
+ const findStatusBadge = () => wrapper.findComponent(PipelinesStatusBadge);
+ const findPipelineInfo = () => wrapper.findComponent(PipelineUrl);
+ const findTriggerer = () => wrapper.findComponent(PipelineTriggerer);
+ const findCommit = () => wrapper.findComponent(CommitComponent);
+ const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph);
+ const findTimeAgo = () => wrapper.findComponent(PipelinesTimeago);
+ const findActions = () => wrapper.findComponent(PipelineOperations);
+
+ const findLegacyTable = () => wrapper.findByTestId('legacy-ci-table');
+ const findTableRows = () => wrapper.findAll('[data-testid="pipeline-table-row"]');
+ const findStatusTh = () => wrapper.findByTestId('status-th');
+ const findPipelineTh = () => wrapper.findByTestId('pipeline-th');
+ const findTriggererTh = () => wrapper.findByTestId('triggerer-th');
+ const findCommitTh = () => wrapper.findByTestId('commit-th');
+ const findStagesTh = () => wrapper.findByTestId('stages-th');
+ const findTimeAgoTh = () => wrapper.findByTestId('timeago-th');
+ const findActionsTh = () => wrapper.findByTestId('actions-th');
- createComponent();
+ beforeEach(() => {
+ pipeline = createMockPipeline();
});
afterEach(() => {
@@ -34,33 +75,161 @@ describe('Pipelines Table', () => {
wrapper = null;
});
- describe('table', () => {
- it('should render a table', () => {
- expect(wrapper.classes()).toContain('ci-table');
+ describe('table with feature flag off', () => {
+ describe('renders the table correctly', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should render a table', () => {
+ expect(wrapper.classes()).toContain('ci-table');
+ });
+
+ it('should render table head with correct columns', () => {
+ expect(wrapper.find('.table-section.js-pipeline-status').text()).toEqual('Status');
+
+ expect(wrapper.find('.table-section.js-pipeline-info').text()).toEqual('Pipeline');
+
+ expect(wrapper.find('.table-section.js-pipeline-commit').text()).toEqual('Commit');
+
+ expect(wrapper.find('.table-section.js-pipeline-stages').text()).toEqual('Stages');
+ });
});
- it('should render table head with correct columns', () => {
- expect(wrapper.find('.table-section.js-pipeline-status').text()).toEqual('Status');
+ describe('without data', () => {
+ it('should render an empty table', () => {
+ createComponent();
- expect(wrapper.find('.table-section.js-pipeline-info').text()).toEqual('Pipeline');
+ expect(findRows()).toHaveLength(0);
+ });
+ });
- expect(wrapper.find('.table-section.js-pipeline-commit').text()).toEqual('Commit');
+ describe('with data', () => {
+ it('should render rows', () => {
+ createComponent({ pipelines: [pipeline], viewType: 'root' });
- expect(wrapper.find('.table-section.js-pipeline-stages').text()).toEqual('Stages');
+ expect(findRows()).toHaveLength(1);
+ });
});
});
- describe('without data', () => {
- it('should render an empty table', () => {
- expect(findRows()).toHaveLength(0);
+ describe('table with feature flag on', () => {
+ beforeEach(() => {
+ createComponent({ pipelines: [pipeline], viewType: 'root' }, true);
+ });
+
+ it('displays new table', () => {
+ expect(findGlTable().exists()).toBe(true);
+ expect(findLegacyTable().exists()).toBe(false);
+ });
+
+ it('should render table head with correct columns', () => {
+ expect(findStatusTh().text()).toBe('Status');
+ expect(findPipelineTh().text()).toBe('Pipeline');
+ expect(findTriggererTh().text()).toBe('Triggerer');
+ expect(findCommitTh().text()).toBe('Commit');
+ expect(findStagesTh().text()).toBe('Stages');
+ expect(findTimeAgoTh().text()).toBe('Duration');
+ expect(findActionsTh().text()).toBe('Actions');
+ });
+
+ it('should display a table row', () => {
+ expect(findTableRows()).toHaveLength(1);
});
- });
- describe('with data', () => {
- it('should render rows', () => {
- createComponent({ pipelines: [pipeline], autoDevopsHelpPath: 'foo', viewType: 'root' });
+ describe('status cell', () => {
+ it('should render a status badge', () => {
+ expect(findStatusBadge().exists()).toBe(true);
+ });
+
+ it('should render status badge with correct path', () => {
+ expect(findStatusBadge().attributes('href')).toBe(pipeline.path);
+ });
+ });
+
+ describe('pipeline cell', () => {
+ it('should render pipeline information', () => {
+ expect(findPipelineInfo().exists()).toBe(true);
+ });
+
+ it('should display the pipeline id', () => {
+ expect(findPipelineInfo().text()).toContain(`#${pipeline.id}`);
+ });
+ });
+
+ describe('triggerer cell', () => {
+ it('should render the pipeline triggerer', () => {
+ expect(findTriggerer().exists()).toBe(true);
+ });
+ });
+
+ describe('commit cell', () => {
+ it('should render commit information', () => {
+ expect(findCommit().exists()).toBe(true);
+ });
+
+ it('should display and link to commit', () => {
+ expect(findCommit().text()).toContain(pipeline.commit.short_id);
+ expect(findCommit().props('commitUrl')).toBe(pipeline.commit.commit_path);
+ });
+
+ it('should display the commit author', () => {
+ expect(findCommit().props('author')).toEqual(pipeline.commit.author);
+ });
+ });
+
+ describe('stages cell', () => {
+ it('should render a pipeline mini graph', () => {
+ expect(findPipelineMiniGraph().exists()).toBe(true);
+ });
+
+ it('should render the right number of stages', () => {
+ const stagesLength = pipeline.details.stages.length;
+ expect(
+ findPipelineMiniGraph().findAll('[data-testid="mini-pipeline-graph-dropdown"]'),
+ ).toHaveLength(stagesLength);
+ });
+
+ describe('when pipeline does not have stages', () => {
+ beforeEach(() => {
+ pipeline = createMockPipeline();
+ pipeline.details.stages = null;
+
+ createComponent({ pipelines: [pipeline] }, true);
+ });
+
+ it('stages are not rendered', () => {
+ expect(findPipelineMiniGraph().exists()).toBe(false);
+ });
+ });
+
+ it('should not update dropdown', () => {
+ expect(findPipelineMiniGraph().props('updateDropdown')).toBe(false);
+ });
+
+ it('when update graph dropdown is set, should update graph dropdown', () => {
+ createComponent({ pipelines: [pipeline], updateGraphDropdown: true }, true);
+
+ expect(findPipelineMiniGraph().props('updateDropdown')).toBe(true);
+ });
+
+ it('when action request is complete, should refresh table', () => {
+ findPipelineMiniGraph().vm.$emit('pipelineActionRequestComplete');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable');
+ });
+ });
+
+ describe('duration cell', () => {
+ it('should render duration information', () => {
+ expect(findTimeAgo().exists()).toBe(true);
+ });
+ });
- expect(findRows()).toHaveLength(1);
+ describe('operations cell', () => {
+ it('should render pipeline operations', () => {
+ expect(findActions().exists()).toBe(true);
+ });
});
});
});
diff --git a/spec/frontend/pipelines/stage_spec.js b/spec/frontend/pipelines/stage_spec.js
deleted file mode 100644
index 87b43558252..00000000000
--- a/spec/frontend/pipelines/stage_spec.js
+++ /dev/null
@@ -1,297 +0,0 @@
-import 'bootstrap/js/dist/dropdown';
-import { GlDropdown } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
-import MockAdapter from 'axios-mock-adapter';
-import $ from 'jquery';
-import axios from '~/lib/utils/axios_utils';
-import StageComponent from '~/pipelines/components/pipelines_list/stage.vue';
-import eventHub from '~/pipelines/event_hub';
-import { stageReply } from './mock_data';
-
-describe('Pipelines stage component', () => {
- let wrapper;
- let mock;
- let glFeatures;
-
- const defaultProps = {
- stage: {
- status: {
- group: 'success',
- icon: 'status_success',
- title: 'success',
- },
- dropdown_path: 'path.json',
- },
- updateDropdown: false,
- };
-
- const createComponent = (props = {}) => {
- wrapper = mount(StageComponent, {
- attachTo: document.body,
- propsData: {
- ...defaultProps,
- ...props,
- },
- provide: {
- glFeatures,
- },
- });
- };
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- jest.spyOn(eventHub, '$emit');
- glFeatures = {};
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
-
- eventHub.$emit.mockRestore();
- mock.restore();
- });
-
- describe('when ci_mini_pipeline_gl_dropdown feature flag is disabled', () => {
- const isDropdownOpen = () => wrapper.classes('show');
-
- describe('default', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('should render a dropdown with the status icon', () => {
- expect(wrapper.attributes('class')).toEqual('dropdown');
- expect(wrapper.find('svg').exists()).toBe(true);
- expect(wrapper.find('button').attributes('data-toggle')).toEqual('dropdown');
- });
- });
-
- describe('with successful request', () => {
- beforeEach(() => {
- mock.onGet('path.json').reply(200, stageReply);
- createComponent();
- });
-
- it('should render the received data and emit `clickedDropdown` event', async () => {
- wrapper.find('button').trigger('click');
-
- await axios.waitForAll();
- expect(wrapper.find('.js-builds-dropdown-container ul').text()).toContain(
- stageReply.latest_statuses[0].name,
- );
-
- expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown');
- });
- });
-
- it('when request fails should close the dropdown', async () => {
- mock.onGet('path.json').reply(500);
- createComponent();
- wrapper.find({ ref: 'dropdown' }).trigger('click');
-
- expect(isDropdownOpen()).toBe(true);
-
- wrapper.find('button').trigger('click');
- await axios.waitForAll();
-
- expect(isDropdownOpen()).toBe(false);
- });
-
- describe('update endpoint correctly', () => {
- beforeEach(() => {
- const copyStage = { ...stageReply };
- copyStage.latest_statuses[0].name = 'this is the updated content';
- mock.onGet('bar.json').reply(200, copyStage);
- createComponent({
- stage: {
- status: {
- group: 'running',
- icon: 'status_running',
- title: 'running',
- },
- dropdown_path: 'bar.json',
- },
- });
- return axios.waitForAll();
- });
-
- it('should update the stage to request the new endpoint provided', async () => {
- wrapper.find('button').trigger('click');
- await axios.waitForAll();
-
- expect(wrapper.find('.js-builds-dropdown-container ul').text()).toContain(
- 'this is the updated content',
- );
- });
- });
-
- describe('pipelineActionRequestComplete', () => {
- beforeEach(() => {
- mock.onGet('path.json').reply(200, stageReply);
- mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200);
- });
-
- const clickCiAction = async () => {
- wrapper.find('button').trigger('click');
- await axios.waitForAll();
-
- wrapper.find('.js-ci-action').trigger('click');
- await axios.waitForAll();
- };
-
- describe('within pipeline table', () => {
- it('emits `refreshPipelinesTable` event when `pipelineActionRequestComplete` is triggered', async () => {
- createComponent({ type: 'PIPELINES_TABLE' });
-
- await clickCiAction();
-
- expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable');
- });
- });
-
- describe('in MR widget', () => {
- beforeEach(() => {
- jest.spyOn($.fn, 'dropdown');
- });
-
- it('closes the dropdown when `pipelineActionRequestComplete` is triggered', async () => {
- createComponent();
-
- await clickCiAction();
-
- expect($.fn.dropdown).toHaveBeenCalledWith('toggle');
- });
- });
- });
- });
-
- describe('when ci_mini_pipeline_gl_dropdown feature flag is enabled', () => {
- const findDropdown = () => wrapper.find(GlDropdown);
- const findDropdownToggle = () => wrapper.find('button.gl-dropdown-toggle');
- const findDropdownMenu = () =>
- wrapper.find('[data-testid="mini-pipeline-graph-dropdown-menu-list"]');
- const findCiActionBtn = () => wrapper.find('.js-ci-action');
-
- const openGlDropdown = () => {
- findDropdownToggle().trigger('click');
- return new Promise((resolve) => {
- wrapper.vm.$root.$on('bv::dropdown::show', resolve);
- });
- };
-
- beforeEach(() => {
- glFeatures = { ciMiniPipelineGlDropdown: true };
- });
-
- describe('default', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('should render a dropdown with the status icon', () => {
- expect(findDropdown().exists()).toBe(true);
- expect(findDropdownToggle().classes('gl-dropdown-toggle')).toEqual(true);
- expect(wrapper.find('[data-testid="status_success_borderless-icon"]').exists()).toBe(true);
- });
- });
-
- describe('with successful request', () => {
- beforeEach(() => {
- mock.onGet('path.json').reply(200, stageReply);
- createComponent();
- });
-
- it('should render the received data and emit `clickedDropdown` event', async () => {
- await openGlDropdown();
- await axios.waitForAll();
-
- expect(findDropdownMenu().text()).toContain(stageReply.latest_statuses[0].name);
- expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown');
- });
- });
-
- it('when request fails should close the dropdown', async () => {
- mock.onGet('path.json').reply(500);
-
- createComponent();
-
- await openGlDropdown();
- await axios.waitForAll();
-
- expect(findDropdown().classes('show')).toBe(false);
- });
-
- describe('update endpoint correctly', () => {
- beforeEach(async () => {
- const copyStage = { ...stageReply };
- copyStage.latest_statuses[0].name = 'this is the updated content';
- mock.onGet('bar.json').reply(200, copyStage);
- createComponent({
- stage: {
- status: {
- group: 'running',
- icon: 'status_running',
- title: 'running',
- },
- dropdown_path: 'bar.json',
- },
- });
- await axios.waitForAll();
- });
-
- it('should update the stage to request the new endpoint provided', async () => {
- await openGlDropdown();
- await axios.waitForAll();
-
- expect(findDropdownMenu().text()).toContain('this is the updated content');
- });
- });
-
- describe('pipelineActionRequestComplete', () => {
- beforeEach(() => {
- mock.onGet('path.json').reply(200, stageReply);
- mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200);
- });
-
- const clickCiAction = async () => {
- await openGlDropdown();
- await axios.waitForAll();
-
- findCiActionBtn().trigger('click');
- await axios.waitForAll();
- };
-
- describe('within pipeline table', () => {
- beforeEach(() => {
- createComponent({ type: 'PIPELINES_TABLE' });
- });
-
- it('emits `refreshPipelinesTable` event when `pipelineActionRequestComplete` is triggered', async () => {
- await clickCiAction();
-
- expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable');
- });
- });
-
- describe('in MR widget', () => {
- beforeEach(() => {
- jest.spyOn($.fn, 'dropdown');
- createComponent();
- });
-
- it('closes the dropdown when `pipelineActionRequestComplete` is triggered', async () => {
- const hidden = jest.fn();
-
- wrapper.vm.$root.$on('bv::dropdown::hide', hidden);
-
- expect(hidden).toHaveBeenCalledTimes(0);
-
- await clickCiAction();
-
- expect(hidden).toHaveBeenCalledTimes(1);
- });
- });
- });
- });
-});
diff --git a/spec/frontend/pipelines/time_ago_spec.js b/spec/frontend/pipelines/time_ago_spec.js
index 55a19ef5165..93aeb049434 100644
--- a/spec/frontend/pipelines/time_ago_spec.js
+++ b/spec/frontend/pipelines/time_ago_spec.js
@@ -8,7 +8,11 @@ describe('Timeago component', () => {
const createComponent = (props = {}) => {
wrapper = shallowMount(TimeAgo, {
propsData: {
- ...props,
+ pipeline: {
+ details: {
+ ...props,
+ },
+ },
},
data() {
return {
@@ -25,10 +29,11 @@ describe('Timeago component', () => {
const duration = () => wrapper.find('.duration');
const finishedAt = () => wrapper.find('.finished-at');
+ const findInProgress = () => wrapper.find('[data-testid="pipeline-in-progress"]');
describe('with duration', () => {
beforeEach(() => {
- createComponent({ duration: 10, finishedTime: '' });
+ createComponent({ duration: 10, finished_at: '' });
});
it('should render duration and timer svg', () => {
@@ -41,7 +46,7 @@ describe('Timeago component', () => {
describe('without duration', () => {
beforeEach(() => {
- createComponent({ duration: 0, finishedTime: '' });
+ createComponent({ duration: 0, finished_at: '' });
});
it('should not render duration and timer svg', () => {
@@ -51,7 +56,7 @@ describe('Timeago component', () => {
describe('with finishedTime', () => {
beforeEach(() => {
- createComponent({ duration: 0, finishedTime: '2017-04-26T12:40:23.277Z' });
+ createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' });
});
it('should render time and calendar icon', () => {
@@ -66,11 +71,28 @@ describe('Timeago component', () => {
describe('without finishedTime', () => {
beforeEach(() => {
- createComponent({ duration: 0, finishedTime: '' });
+ createComponent({ duration: 0, finished_at: '' });
});
it('should not render time and calendar icon', () => {
expect(finishedAt().exists()).toBe(false);
});
});
+
+ describe('in progress', () => {
+ it.each`
+ durationTime | finishedAtTime | shouldShow
+ ${10} | ${'2017-04-26T12:40:23.277Z'} | ${false}
+ ${10} | ${''} | ${false}
+ ${0} | ${'2017-04-26T12:40:23.277Z'} | ${false}
+ ${0} | ${''} | ${true}
+ `(
+ 'progress state shown: $shouldShow when pipeline duration is $durationTime and finished_at is $finishedAtTime',
+ ({ durationTime, finishedAtTime, shouldShow }) => {
+ createComponent({ duration: durationTime, finished_at: finishedAtTime });
+
+ expect(findInProgress().exists()).toBe(shouldShow);
+ },
+ );
+ });
});
diff --git a/spec/frontend/pipelines_spec.js b/spec/frontend/pipelines_spec.js
index 6d4d634c575..add91fbcc23 100644
--- a/spec/frontend/pipelines_spec.js
+++ b/spec/frontend/pipelines_spec.js
@@ -1,8 +1,6 @@
import Pipelines from '~/pipelines';
describe('Pipelines', () => {
- preloadFixtures('static/pipeline_graph.html');
-
beforeEach(() => {
loadFixtures('static/pipeline_graph.html');
});
diff --git a/spec/frontend/profile/account/components/update_username_spec.js b/spec/frontend/profile/account/components/update_username_spec.js
index 8295d1d43cf..a3d7b63373c 100644
--- a/spec/frontend/profile/account/components/update_username_spec.js
+++ b/spec/frontend/profile/account/components/update_username_spec.js
@@ -2,10 +2,13 @@ import { GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import UpdateUsername from '~/profile/account/components/update_username.vue';
+jest.mock('~/flash');
+
describe('UpdateUsername component', () => {
const rootUrl = TEST_HOST;
const actionUrl = `${TEST_HOST}/update/username`;
@@ -105,7 +108,8 @@ describe('UpdateUsername component', () => {
axiosMock.onPut(actionUrl).replyOnce(() => {
expect(input.attributes('disabled')).toBe('disabled');
- expect(openModalBtn.props('disabled')).toBe(true);
+ expect(openModalBtn.props('disabled')).toBe(false);
+ expect(openModalBtn.props('loading')).toBe(true);
return [200, { message: 'Username changed' }];
});
@@ -115,6 +119,7 @@ describe('UpdateUsername component', () => {
expect(input.attributes('disabled')).toBe(undefined);
expect(openModalBtn.props('disabled')).toBe(true);
+ expect(openModalBtn.props('loading')).toBe(false);
});
it('does not set the username after a erroneous update', async () => {
@@ -122,7 +127,8 @@ describe('UpdateUsername component', () => {
axiosMock.onPut(actionUrl).replyOnce(() => {
expect(input.attributes('disabled')).toBe('disabled');
- expect(openModalBtn.props('disabled')).toBe(true);
+ expect(openModalBtn.props('disabled')).toBe(false);
+ expect(openModalBtn.props('loading')).toBe(true);
return [400, { message: 'Invalid username' }];
});
@@ -130,6 +136,29 @@ describe('UpdateUsername component', () => {
await expect(wrapper.vm.onConfirm()).rejects.toThrow();
expect(input.attributes('disabled')).toBe(undefined);
expect(openModalBtn.props('disabled')).toBe(false);
+ expect(openModalBtn.props('loading')).toBe(false);
+ });
+
+ it('shows an error message if the error response has a `message` property', async () => {
+ axiosMock.onPut(actionUrl).replyOnce(() => {
+ return [400, { message: 'Invalid username' }];
+ });
+
+ await expect(wrapper.vm.onConfirm()).rejects.toThrow();
+
+ expect(createFlash).toBeCalledWith('Invalid username');
+ });
+
+ it("shows a fallback error message if the error response doesn't have a `message` property", async () => {
+ axiosMock.onPut(actionUrl).replyOnce(() => {
+ return [400];
+ });
+
+ await expect(wrapper.vm.onConfirm()).rejects.toThrow();
+
+ expect(createFlash).toBeCalledWith(
+ 'An error occurred while updating your username, please try again.',
+ );
});
});
});
diff --git a/spec/frontend/profile/preferences/components/profile_preferences_spec.js b/spec/frontend/profile/preferences/components/profile_preferences_spec.js
index 82c41178410..9e6f5594d26 100644
--- a/spec/frontend/profile/preferences/components/profile_preferences_spec.js
+++ b/spec/frontend/profile/preferences/components/profile_preferences_spec.js
@@ -1,10 +1,19 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import IntegrationView from '~/profile/preferences/components/integration_view.vue';
import ProfilePreferences from '~/profile/preferences/components/profile_preferences.vue';
import { i18n } from '~/profile/preferences/constants';
-import { integrationViews, userFields, bodyClasses } from '../mock_data';
+import {
+ integrationViews,
+ userFields,
+ bodyClasses,
+ themes,
+ lightModeThemeId1,
+ darkModeThemeId,
+ lightModeThemeId2,
+} from '../mock_data';
const expectedUrl = '/foo';
@@ -14,7 +23,7 @@ describe('ProfilePreferences component', () => {
integrationViews: [],
userFields,
bodyClasses,
- themes: [{ id: 1, css_class: 'foo' }],
+ themes,
profilePreferencesPath: '/update-profile',
formEl: document.createElement('form'),
};
@@ -49,6 +58,30 @@ describe('ProfilePreferences component', () => {
return document.querySelector('.flash-container .flash-text');
}
+ function createThemeInput(themeId = lightModeThemeId1) {
+ const input = document.createElement('input');
+ input.setAttribute('name', 'user[theme_id]');
+ input.setAttribute('type', 'radio');
+ input.setAttribute('value', themeId.toString());
+ input.setAttribute('checked', 'checked');
+ return input;
+ }
+
+ function createForm(themeInput = createThemeInput()) {
+ const form = document.createElement('form');
+ form.setAttribute('url', expectedUrl);
+ form.setAttribute('method', 'put');
+ form.appendChild(themeInput);
+ return form;
+ }
+
+ function setupBody() {
+ const div = document.createElement('div');
+ div.classList.add('container-fluid');
+ document.body.appendChild(div);
+ document.body.classList.add('content-wrapper');
+ }
+
beforeEach(() => {
setFixtures('<div class="flash-container"></div>');
});
@@ -84,30 +117,15 @@ describe('ProfilePreferences component', () => {
let form;
beforeEach(() => {
- const div = document.createElement('div');
- div.classList.add('container-fluid');
- document.body.appendChild(div);
- document.body.classList.add('content-wrapper');
-
- form = document.createElement('form');
- form.setAttribute('url', expectedUrl);
- form.setAttribute('method', 'put');
-
- const input = document.createElement('input');
- input.setAttribute('name', 'user[theme_id]');
- input.setAttribute('type', 'radio');
- input.setAttribute('value', '1');
- input.setAttribute('checked', 'checked');
- form.appendChild(input);
-
+ setupBody();
+ form = createForm();
wrapper = createComponent({ provide: { formEl: form }, attachTo: document.body });
-
const beforeSendEvent = new CustomEvent('ajax:beforeSend');
form.dispatchEvent(beforeSendEvent);
});
it('disables the submit button', async () => {
- await wrapper.vm.$nextTick();
+ await nextTick();
const button = findSubmitButton();
expect(button.props('disabled')).toBe(true);
});
@@ -116,7 +134,7 @@ describe('ProfilePreferences component', () => {
const successEvent = new CustomEvent('ajax:success');
form.dispatchEvent(successEvent);
- await wrapper.vm.$nextTick();
+ await nextTick();
const button = findSubmitButton();
expect(button.props('disabled')).toBe(false);
});
@@ -125,7 +143,7 @@ describe('ProfilePreferences component', () => {
const errorEvent = new CustomEvent('ajax:error');
form.dispatchEvent(errorEvent);
- await wrapper.vm.$nextTick();
+ await nextTick();
const button = findSubmitButton();
expect(button.props('disabled')).toBe(false);
});
@@ -160,4 +178,89 @@ describe('ProfilePreferences component', () => {
expect(findFlashError().innerText.trim()).toEqual(message);
});
});
+
+ describe('theme changes', () => {
+ const { location } = window;
+
+ let themeInput;
+ let form;
+
+ function setupWrapper() {
+ wrapper = createComponent({ provide: { formEl: form }, attachTo: document.body });
+ }
+
+ function selectThemeId(themeId) {
+ themeInput.setAttribute('value', themeId.toString());
+ }
+
+ function dispatchBeforeSendEvent() {
+ const beforeSendEvent = new CustomEvent('ajax:beforeSend');
+ form.dispatchEvent(beforeSendEvent);
+ }
+
+ function dispatchSuccessEvent() {
+ const successEvent = new CustomEvent('ajax:success');
+ form.dispatchEvent(successEvent);
+ }
+
+ beforeAll(() => {
+ delete window.location;
+ window.location = {
+ ...location,
+ reload: jest.fn(),
+ };
+ });
+
+ afterAll(() => {
+ window.location = location;
+ });
+
+ beforeEach(() => {
+ setupBody();
+ themeInput = createThemeInput();
+ form = createForm(themeInput);
+ });
+
+ it('reloads the page when switching from light to dark mode', async () => {
+ selectThemeId(lightModeThemeId1);
+ setupWrapper();
+
+ selectThemeId(darkModeThemeId);
+ dispatchBeforeSendEvent();
+ await nextTick();
+
+ dispatchSuccessEvent();
+ await nextTick();
+
+ expect(window.location.reload).toHaveBeenCalledTimes(1);
+ });
+
+ it('reloads the page when switching from dark to light mode', async () => {
+ selectThemeId(darkModeThemeId);
+ setupWrapper();
+
+ selectThemeId(lightModeThemeId1);
+ dispatchBeforeSendEvent();
+ await nextTick();
+
+ dispatchSuccessEvent();
+ await nextTick();
+
+ expect(window.location.reload).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not reload the page when switching between light mode themes', async () => {
+ selectThemeId(lightModeThemeId1);
+ setupWrapper();
+
+ selectThemeId(lightModeThemeId2);
+ dispatchBeforeSendEvent();
+ await nextTick();
+
+ dispatchSuccessEvent();
+ await nextTick();
+
+ expect(window.location.reload).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/spec/frontend/profile/preferences/mock_data.js b/spec/frontend/profile/preferences/mock_data.js
index ce33fc79a39..91cfdfadc78 100644
--- a/spec/frontend/profile/preferences/mock_data.js
+++ b/spec/frontend/profile/preferences/mock_data.js
@@ -18,3 +18,15 @@ export const userFields = {
};
export const bodyClasses = 'ui-light-indigo ui-light gl-dark';
+
+export const themes = [
+ { id: 1, css_class: 'foo' },
+ { id: 2, css_class: 'bar' },
+ { id: 3, css_class: 'gl-dark' },
+];
+
+export const lightModeThemeId1 = 1;
+
+export const lightModeThemeId2 = 2;
+
+export const darkModeThemeId = 3;
diff --git a/spec/frontend/project_select_combo_button_spec.js b/spec/frontend/project_select_combo_button_spec.js
index c47db71b4ac..5cdc3d174a1 100644
--- a/spec/frontend/project_select_combo_button_spec.js
+++ b/spec/frontend/project_select_combo_button_spec.js
@@ -10,8 +10,6 @@ describe('Project Select Combo Button', () => {
testContext = {};
});
- preloadFixtures(fixturePath);
-
beforeEach(() => {
testContext.defaults = {
label: 'Select project to create issue',
diff --git a/spec/frontend/projects/commit/components/form_modal_spec.js b/spec/frontend/projects/commit/components/form_modal_spec.js
index 1569f5b4bbe..708644cb7ee 100644
--- a/spec/frontend/projects/commit/components/form_modal_spec.js
+++ b/spec/frontend/projects/commit/components/form_modal_spec.js
@@ -7,6 +7,7 @@ import axios from '~/lib/utils/axios_utils';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import BranchesDropdown from '~/projects/commit/components/branches_dropdown.vue';
import CommitFormModal from '~/projects/commit/components/form_modal.vue';
+import ProjectsDropdown from '~/projects/commit/components/projects_dropdown.vue';
import eventHub from '~/projects/commit/event_hub';
import createStore from '~/projects/commit/store';
import mockData from '../mock_data';
@@ -20,7 +21,10 @@ describe('CommitFormModal', () => {
store = createStore({ ...mockData.mockModal, ...state });
wrapper = extendedWrapper(
method(CommitFormModal, {
- provide,
+ provide: {
+ ...provide,
+ glFeatures: { pickIntoProject: true },
+ },
propsData: { ...mockData.modalPropsData },
store,
attrs: {
@@ -33,7 +37,9 @@ describe('CommitFormModal', () => {
const findModal = () => wrapper.findComponent(GlModal);
const findStartBranch = () => wrapper.find('#start_branch');
- const findDropdown = () => wrapper.findComponent(BranchesDropdown);
+ const findTargetProject = () => wrapper.find('#target_project_id');
+ const findBranchesDropdown = () => wrapper.findComponent(BranchesDropdown);
+ const findProjectsDropdown = () => wrapper.findComponent(ProjectsDropdown);
const findForm = () => findModal().findComponent(GlForm);
const findCheckBox = () => findForm().findComponent(GlFormCheckbox);
const findPrependedText = () => wrapper.findByTestId('prepended-text');
@@ -146,11 +152,19 @@ describe('CommitFormModal', () => {
});
it('Changes the start_branch input value', async () => {
- findDropdown().vm.$emit('selectBranch', '_changed_branch_value_');
+ findBranchesDropdown().vm.$emit('selectBranch', '_changed_branch_value_');
await wrapper.vm.$nextTick();
expect(findStartBranch().attributes('value')).toBe('_changed_branch_value_');
});
+
+ it('Changes the target_project_id input value', async () => {
+ findProjectsDropdown().vm.$emit('selectProject', '_changed_project_value_');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findTargetProject().attributes('value')).toBe('_changed_project_value_');
+ });
});
});
diff --git a/spec/frontend/projects/commit/components/projects_dropdown_spec.js b/spec/frontend/projects/commit/components/projects_dropdown_spec.js
new file mode 100644
index 00000000000..bb20918e0cd
--- /dev/null
+++ b/spec/frontend/projects/commit/components/projects_dropdown_spec.js
@@ -0,0 +1,124 @@
+import { GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import ProjectsDropdown from '~/projects/commit/components/projects_dropdown.vue';
+
+Vue.use(Vuex);
+
+describe('ProjectsDropdown', () => {
+ let wrapper;
+ let store;
+ const spyFetchProjects = jest.fn();
+ const projectsMockData = [
+ { id: '1', name: '_project_1_', refsUrl: '_project_1_/refs' },
+ { id: '2', name: '_project_2_', refsUrl: '_project_2_/refs' },
+ { id: '3', name: '_project_3_', refsUrl: '_project_3_/refs' },
+ ];
+
+ const createComponent = (term, state = {}) => {
+ store = new Vuex.Store({
+ getters: {
+ sortedProjects: () => projectsMockData,
+ },
+ state,
+ });
+
+ wrapper = extendedWrapper(
+ shallowMount(ProjectsDropdown, {
+ store,
+ propsData: {
+ value: term,
+ },
+ }),
+ );
+ };
+
+ const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType);
+ const findDropdownItemByIndex = (index) => wrapper.findAllComponents(GlDropdownItem).at(index);
+ const findNoResults = () => wrapper.findByTestId('empty-result-message');
+
+ afterEach(() => {
+ wrapper.destroy();
+ spyFetchProjects.mockReset();
+ });
+
+ describe('No projects found', () => {
+ beforeEach(() => {
+ createComponent('_non_existent_project_');
+ });
+
+ it('renders empty results message', () => {
+ expect(findNoResults().text()).toBe('No matching results');
+ });
+
+ it('shows GlSearchBoxByType with default attributes', () => {
+ expect(findSearchBoxByType().exists()).toBe(true);
+ expect(findSearchBoxByType().vm.$attrs).toMatchObject({
+ placeholder: 'Search projects',
+ });
+ });
+ });
+
+ describe('Search term is empty', () => {
+ beforeEach(() => {
+ createComponent('');
+ });
+
+ it('renders all projects when search term is empty', () => {
+ expect(findAllDropdownItems()).toHaveLength(3);
+ expect(findDropdownItemByIndex(0).text()).toBe('_project_1_');
+ expect(findDropdownItemByIndex(1).text()).toBe('_project_2_');
+ expect(findDropdownItemByIndex(2).text()).toBe('_project_3_');
+ });
+
+ it('should not be selected on the inactive project', () => {
+ expect(wrapper.vm.isSelected('_project_1_')).toBe(false);
+ });
+ });
+
+ describe('Projects found', () => {
+ beforeEach(() => {
+ createComponent('_project_1_', { targetProjectId: '1' });
+ });
+
+ it('renders only the project searched for', () => {
+ expect(findAllDropdownItems()).toHaveLength(1);
+ expect(findDropdownItemByIndex(0).text()).toBe('_project_1_');
+ });
+
+ it('should not display empty results message', () => {
+ expect(findNoResults().exists()).toBe(false);
+ });
+
+ it('should signify this project is selected', () => {
+ expect(findDropdownItemByIndex(0).props('isChecked')).toBe(true);
+ });
+
+ it('should signify the project is not selected', () => {
+ expect(wrapper.vm.isSelected('_not_selected_project_')).toBe(false);
+ });
+
+ describe('Custom events', () => {
+ it('should emit selectProject if a project is clicked', () => {
+ findDropdownItemByIndex(0).vm.$emit('click');
+
+ expect(wrapper.emitted('selectProject')).toEqual([['1']]);
+ expect(wrapper.vm.filterTerm).toBe('_project_1_');
+ });
+ });
+ });
+
+ describe('Case insensitive for search term', () => {
+ beforeEach(() => {
+ createComponent('_PrOjEcT_1_');
+ });
+
+ it('renders only the project searched for', () => {
+ expect(findAllDropdownItems()).toHaveLength(1);
+ expect(findDropdownItemByIndex(0).text()).toBe('_project_1_');
+ });
+ });
+});
diff --git a/spec/frontend/projects/commit/mock_data.js b/spec/frontend/projects/commit/mock_data.js
index 2b3b5a14c98..e4dcb24c4c0 100644
--- a/spec/frontend/projects/commit/mock_data.js
+++ b/spec/frontend/projects/commit/mock_data.js
@@ -24,4 +24,5 @@ export default {
openModal: '_open_modal_',
},
mockBranches: ['_branch_1', '_abc_', '_master_'],
+ mockProjects: ['_project_1', '_abc_', '_project_'],
};
diff --git a/spec/frontend/projects/commit/store/actions_spec.js b/spec/frontend/projects/commit/store/actions_spec.js
index 458372229cf..305257c9ca5 100644
--- a/spec/frontend/projects/commit/store/actions_spec.js
+++ b/spec/frontend/projects/commit/store/actions_spec.js
@@ -47,7 +47,7 @@ describe('Commit form modal store actions', () => {
it('dispatch correct actions on fetchBranches', (done) => {
jest
.spyOn(axios, 'get')
- .mockImplementation(() => Promise.resolve({ data: mockData.mockBranches }));
+ .mockImplementation(() => Promise.resolve({ data: { Branches: mockData.mockBranches } }));
testAction(
actions.fetchBranches,
@@ -108,4 +108,43 @@ describe('Commit form modal store actions', () => {
]);
});
});
+
+ describe('setBranchesEndpoint', () => {
+ it('commits SET_BRANCHES_ENDPOINT mutation', () => {
+ const endpoint = 'some/endpoint';
+
+ testAction(actions.setBranchesEndpoint, endpoint, {}, [
+ {
+ type: types.SET_BRANCHES_ENDPOINT,
+ payload: endpoint,
+ },
+ ]);
+ });
+ });
+
+ describe('setSelectedProject', () => {
+ const id = 1;
+
+ it('commits SET_SELECTED_PROJECT mutation', () => {
+ testAction(
+ actions.setSelectedProject,
+ id,
+ {},
+ [
+ {
+ type: types.SET_SELECTED_PROJECT,
+ payload: id,
+ },
+ ],
+ [
+ {
+ type: 'setBranchesEndpoint',
+ },
+ {
+ type: 'fetchBranches',
+ },
+ ],
+ );
+ });
+ });
});
diff --git a/spec/frontend/projects/commit/store/getters_spec.js b/spec/frontend/projects/commit/store/getters_spec.js
index bd0cb356854..38c45af7aa0 100644
--- a/spec/frontend/projects/commit/store/getters_spec.js
+++ b/spec/frontend/projects/commit/store/getters_spec.js
@@ -18,4 +18,21 @@ describe('Commit form modal getters', () => {
expect(getters.joinedBranches(state)).toEqual(branches.slice(1));
});
});
+
+ describe('sortedProjects', () => {
+ it('should sort projects with variable branches', () => {
+ const state = {
+ projects: mockData.mockProjects,
+ };
+
+ expect(getters.sortedProjects(state)).toEqual(mockData.mockProjects.sort());
+ });
+
+ it('should provide a uniq list of projects', () => {
+ const projects = ['_project_', '_project_', '_some_other_project'];
+ const state = { projects };
+
+ expect(getters.sortedProjects(state)).toEqual(projects.slice(1));
+ });
+ });
});
diff --git a/spec/frontend/projects/commit/store/mutations_spec.js b/spec/frontend/projects/commit/store/mutations_spec.js
index 2ea50e71772..8989e769772 100644
--- a/spec/frontend/projects/commit/store/mutations_spec.js
+++ b/spec/frontend/projects/commit/store/mutations_spec.js
@@ -35,6 +35,16 @@ describe('Commit form modal mutations', () => {
});
});
+ describe('SET_BRANCHES_ENDPOINT', () => {
+ it('should set branchesEndpoint', () => {
+ stateCopy = { branchesEndpoint: 'endpoint/1' };
+
+ mutations[types.SET_BRANCHES_ENDPOINT](stateCopy, 'endpoint/2');
+
+ expect(stateCopy.branchesEndpoint).toBe('endpoint/2');
+ });
+ });
+
describe('SET_BRANCH', () => {
it('should set branch', () => {
stateCopy = { branch: '_master_' };
@@ -54,4 +64,14 @@ describe('Commit form modal mutations', () => {
expect(stateCopy.selectedBranch).toBe('_changed_branch_');
});
});
+
+ describe('SET_SELECTED_PROJECT', () => {
+ it('should set targetProjectId', () => {
+ stateCopy = { targetProjectId: '_project_1_' };
+
+ mutations[types.SET_SELECTED_PROJECT](stateCopy, '_project_2_');
+
+ expect(stateCopy.targetProjectId).toBe('_project_2_');
+ });
+ });
});
diff --git a/spec/frontend/projects/compare/components/app_legacy_spec.js b/spec/frontend/projects/compare/components/app_legacy_spec.js
new file mode 100644
index 00000000000..4c7f0d5cccc
--- /dev/null
+++ b/spec/frontend/projects/compare/components/app_legacy_spec.js
@@ -0,0 +1,116 @@
+import { GlButton } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import CompareApp from '~/projects/compare/components/app_legacy.vue';
+import RevisionDropdown from '~/projects/compare/components/revision_dropdown_legacy.vue';
+
+jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
+
+const projectCompareIndexPath = 'some/path';
+const refsProjectPath = 'some/refs/path';
+const paramsFrom = 'master';
+const paramsTo = 'master';
+
+describe('CompareApp component', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(CompareApp, {
+ propsData: {
+ projectCompareIndexPath,
+ refsProjectPath,
+ paramsFrom,
+ paramsTo,
+ projectMergeRequestPath: '',
+ createMrPath: '',
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders component with prop', () => {
+ expect(wrapper.props()).toEqual(
+ expect.objectContaining({
+ projectCompareIndexPath,
+ refsProjectPath,
+ paramsFrom,
+ paramsTo,
+ }),
+ );
+ });
+
+ it('contains the correct form attributes', () => {
+ expect(wrapper.attributes('action')).toBe(projectCompareIndexPath);
+ expect(wrapper.attributes('method')).toBe('POST');
+ });
+
+ it('has input with csrf token', () => {
+ expect(wrapper.find('input[name="authenticity_token"]').attributes('value')).toBe(
+ 'mock-csrf-token',
+ );
+ });
+
+ it('has ellipsis', () => {
+ expect(wrapper.find('[data-testid="ellipsis"]').exists()).toBe(true);
+ });
+
+ it('render Source and Target BranchDropdown components', () => {
+ const branchDropdowns = wrapper.findAll(RevisionDropdown);
+
+ expect(branchDropdowns.length).toBe(2);
+ expect(branchDropdowns.at(0).props('revisionText')).toBe('Source');
+ expect(branchDropdowns.at(1).props('revisionText')).toBe('Target');
+ });
+
+ describe('compare button', () => {
+ const findCompareButton = () => wrapper.find(GlButton);
+
+ it('renders button', () => {
+ expect(findCompareButton().exists()).toBe(true);
+ });
+
+ it('submits form', () => {
+ findCompareButton().vm.$emit('click');
+ expect(wrapper.find('form').element.submit).toHaveBeenCalled();
+ });
+
+ it('has compare text', () => {
+ expect(findCompareButton().text()).toBe('Compare');
+ });
+ });
+
+ describe('merge request buttons', () => {
+ const findProjectMrButton = () => wrapper.find('[data-testid="projectMrButton"]');
+ const findCreateMrButton = () => wrapper.find('[data-testid="createMrButton"]');
+
+ it('does not have merge request buttons', () => {
+ createComponent();
+ expect(findProjectMrButton().exists()).toBe(false);
+ expect(findCreateMrButton().exists()).toBe(false);
+ });
+
+ it('has "View open merge request" button', () => {
+ createComponent({
+ projectMergeRequestPath: 'some/project/merge/request/path',
+ });
+ expect(findProjectMrButton().exists()).toBe(true);
+ expect(findCreateMrButton().exists()).toBe(false);
+ });
+
+ it('has "Create merge request" button', () => {
+ createComponent({
+ createMrPath: 'some/create/create/mr/path',
+ });
+ expect(findProjectMrButton().exists()).toBe(false);
+ expect(findCreateMrButton().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/projects/compare/components/app_spec.js b/spec/frontend/projects/compare/components/app_spec.js
index d28a30e93b1..6de06e4373c 100644
--- a/spec/frontend/projects/compare/components/app_spec.js
+++ b/spec/frontend/projects/compare/components/app_spec.js
@@ -1,7 +1,7 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import CompareApp from '~/projects/compare/components/app.vue';
-import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vue';
+import RevisionCard from '~/projects/compare/components/revision_card.vue';
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
@@ -63,11 +63,11 @@ describe('CompareApp component', () => {
});
it('render Source and Target BranchDropdown components', () => {
- const branchDropdowns = wrapper.findAll(RevisionDropdown);
+ const revisionCards = wrapper.findAll(RevisionCard);
- expect(branchDropdowns.length).toBe(2);
- expect(branchDropdowns.at(0).props('revisionText')).toBe('Source');
- expect(branchDropdowns.at(1).props('revisionText')).toBe('Target');
+ expect(revisionCards.length).toBe(2);
+ expect(revisionCards.at(0).props('revisionText')).toBe('Source');
+ expect(revisionCards.at(1).props('revisionText')).toBe('Target');
});
describe('compare button', () => {
diff --git a/spec/frontend/projects/compare/components/repo_dropdown_spec.js b/spec/frontend/projects/compare/components/repo_dropdown_spec.js
new file mode 100644
index 00000000000..af76632515c
--- /dev/null
+++ b/spec/frontend/projects/compare/components/repo_dropdown_spec.js
@@ -0,0 +1,98 @@
+import { GlDropdown } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import RepoDropdown from '~/projects/compare/components/repo_dropdown.vue';
+
+const defaultProps = {
+ paramsName: 'to',
+};
+
+const projectToId = '1';
+const projectToName = 'some-to-name';
+const projectFromId = '2';
+const projectFromName = 'some-from-name';
+
+const defaultProvide = {
+ projectTo: { id: projectToId, name: projectToName },
+ projectsFrom: [
+ { id: projectFromId, name: projectFromName },
+ { id: 3, name: 'some-from-another-name' },
+ ],
+};
+
+describe('RepoDropdown component', () => {
+ let wrapper;
+
+ const createComponent = (props = {}, provide = {}) => {
+ wrapper = shallowMount(RepoDropdown, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ provide: {
+ ...defaultProvide,
+ ...provide,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findGlDropdown = () => wrapper.find(GlDropdown);
+ const findHiddenInput = () => wrapper.find('input[type="hidden"]');
+
+ describe('Source Revision', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('set hidden input', () => {
+ expect(findHiddenInput().attributes('value')).toBe(projectToId);
+ });
+
+ it('displays the project name in the disabled dropdown', () => {
+ expect(findGlDropdown().props('text')).toBe(projectToName);
+ expect(findGlDropdown().props('disabled')).toBe(true);
+ });
+
+ it('does not emit `changeTargetProject` event', async () => {
+ wrapper.vm.emitTargetProject('foo');
+ await wrapper.vm.$nextTick();
+ expect(wrapper.emitted('changeTargetProject')).toBeUndefined();
+ });
+ });
+
+ describe('Target Revision', () => {
+ beforeEach(() => {
+ createComponent({ paramsName: 'from' });
+ });
+
+ it('set hidden input of the first project', () => {
+ expect(findHiddenInput().attributes('value')).toBe(projectFromId);
+ });
+
+ it('displays the first project name initially in the dropdown', () => {
+ expect(findGlDropdown().props('text')).toBe(projectFromName);
+ });
+
+ it('updates the hiddin input value when onClick method is triggered', async () => {
+ const repoId = '100';
+ wrapper.vm.onClick({ id: repoId });
+ await wrapper.vm.$nextTick();
+ expect(findHiddenInput().attributes('value')).toBe(repoId);
+ });
+
+ it('emits initial `changeTargetProject` event with target project', () => {
+ expect(wrapper.emitted('changeTargetProject')).toEqual([[projectFromName]]);
+ });
+
+ it('emits `changeTargetProject` event when another target project is selected', async () => {
+ const newTargetProject = 'new-from-name';
+ wrapper.vm.$emit('changeTargetProject', newTargetProject);
+ await wrapper.vm.$nextTick();
+ expect(wrapper.emitted('changeTargetProject')[1]).toEqual([newTargetProject]);
+ });
+ });
+});
diff --git a/spec/frontend/projects/compare/components/revision_card_spec.js b/spec/frontend/projects/compare/components/revision_card_spec.js
new file mode 100644
index 00000000000..83f858f4454
--- /dev/null
+++ b/spec/frontend/projects/compare/components/revision_card_spec.js
@@ -0,0 +1,49 @@
+import { GlCard } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import RepoDropdown from '~/projects/compare/components/repo_dropdown.vue';
+import RevisionCard from '~/projects/compare/components/revision_card.vue';
+import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vue';
+
+const defaultProps = {
+ refsProjectPath: 'some/refs/path',
+ revisionText: 'Source',
+ paramsName: 'to',
+ paramsBranch: 'master',
+};
+
+describe('RepoDropdown component', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(RevisionCard, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ stubs: {
+ GlCard,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('displays revision text', () => {
+ expect(wrapper.find(GlCard).text()).toContain(defaultProps.revisionText);
+ });
+
+ it('renders RepoDropdown component', () => {
+ expect(wrapper.findAll(RepoDropdown).exists()).toBe(true);
+ });
+
+ it('renders RevisionDropdown component', () => {
+ expect(wrapper.findAll(RevisionDropdown).exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js
new file mode 100644
index 00000000000..270c89e674c
--- /dev/null
+++ b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js
@@ -0,0 +1,106 @@
+import { GlDropdown } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import AxiosMockAdapter from 'axios-mock-adapter';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import RevisionDropdown from '~/projects/compare/components/revision_dropdown_legacy.vue';
+
+const defaultProps = {
+ refsProjectPath: 'some/refs/path',
+ revisionText: 'Target',
+ paramsName: 'from',
+ paramsBranch: 'master',
+};
+
+jest.mock('~/flash');
+
+describe('RevisionDropdown component', () => {
+ let wrapper;
+ let axiosMock;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(RevisionDropdown, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ axiosMock = new AxiosMockAdapter(axios);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ axiosMock.restore();
+ });
+
+ const findGlDropdown = () => wrapper.find(GlDropdown);
+
+ it('sets hidden input', () => {
+ createComponent();
+ expect(wrapper.find('input[type="hidden"]').attributes('value')).toBe(
+ defaultProps.paramsBranch,
+ );
+ });
+
+ it('update the branches on success', async () => {
+ const Branches = ['branch-1', 'branch-2'];
+ const Tags = ['tag-1', 'tag-2', 'tag-3'];
+
+ axiosMock.onGet(defaultProps.refsProjectPath).replyOnce(200, {
+ Branches,
+ Tags,
+ });
+
+ createComponent();
+
+ await axios.waitForAll();
+
+ expect(wrapper.vm.branches).toEqual(Branches);
+ expect(wrapper.vm.tags).toEqual(Tags);
+ });
+
+ it('sets branches and tags to be an empty array when no tags or branches are given', async () => {
+ axiosMock.onGet(defaultProps.refsProjectPath).replyOnce(200, {
+ Branches: undefined,
+ Tags: undefined,
+ });
+
+ createComponent();
+
+ await axios.waitForAll();
+
+ expect(wrapper.vm.branches).toEqual([]);
+ expect(wrapper.vm.tags).toEqual([]);
+ });
+
+ it('shows flash message on error', async () => {
+ axiosMock.onGet('some/invalid/path').replyOnce(404);
+
+ createComponent();
+
+ await wrapper.vm.fetchBranchesAndTags();
+ expect(createFlash).toHaveBeenCalled();
+ });
+
+ describe('GlDropdown component', () => {
+ it('renders props', () => {
+ createComponent();
+ expect(wrapper.props()).toEqual(expect.objectContaining(defaultProps));
+ });
+
+ it('display default text', () => {
+ createComponent({
+ paramsBranch: null,
+ });
+ expect(findGlDropdown().props('text')).toBe('Select branch/tag');
+ });
+
+ it('display params branch text', () => {
+ createComponent();
+ expect(findGlDropdown().props('text')).toBe(defaultProps.paramsBranch);
+ });
+ });
+});
diff --git a/spec/frontend/projects/compare/components/revision_dropdown_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_spec.js
index f3ff5e26d2b..69d3167c99c 100644
--- a/spec/frontend/projects/compare/components/revision_dropdown_spec.js
+++ b/spec/frontend/projects/compare/components/revision_dropdown_spec.js
@@ -7,7 +7,6 @@ import RevisionDropdown from '~/projects/compare/components/revision_dropdown.vu
const defaultProps = {
refsProjectPath: 'some/refs/path',
- revisionText: 'Target',
paramsName: 'from',
paramsBranch: 'master',
};
@@ -57,7 +56,6 @@ describe('RevisionDropdown component', () => {
createComponent();
await axios.waitForAll();
-
expect(wrapper.vm.branches).toEqual(Branches);
expect(wrapper.vm.tags).toEqual(Tags);
});
@@ -71,6 +69,22 @@ describe('RevisionDropdown component', () => {
expect(createFlash).toHaveBeenCalled();
});
+ it('makes a new request when refsProjectPath is changed', async () => {
+ jest.spyOn(axios, 'get');
+
+ const newRefsProjectPath = 'new-selected-project-path';
+
+ createComponent();
+
+ wrapper.setProps({
+ ...defaultProps,
+ refsProjectPath: newRefsProjectPath,
+ });
+
+ await axios.waitForAll();
+ expect(axios.get).toHaveBeenLastCalledWith(newRefsProjectPath);
+ });
+
describe('GlDropdown component', () => {
it('renders props', () => {
createComponent();
diff --git a/spec/frontend/projects/details/upload_button_spec.js b/spec/frontend/projects/details/upload_button_spec.js
new file mode 100644
index 00000000000..ebb2b499ead
--- /dev/null
+++ b/spec/frontend/projects/details/upload_button_spec.js
@@ -0,0 +1,61 @@
+import { GlButton } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import UploadButton from '~/projects/details/upload_button.vue';
+import { trackFileUploadEvent } from '~/projects/upload_file_experiment_tracking';
+import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
+
+jest.mock('~/projects/upload_file_experiment_tracking');
+
+const MODAL_ID = 'details-modal-upload-blob';
+
+describe('UploadButton', () => {
+ let wrapper;
+ let glModalDirective;
+
+ const createComponent = () => {
+ glModalDirective = jest.fn();
+
+ return shallowMount(UploadButton, {
+ directives: {
+ glModal: {
+ bind(_, { value }) {
+ glModalDirective(value);
+ },
+ },
+ },
+ });
+ };
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('displays an upload button', () => {
+ expect(wrapper.find(GlButton).exists()).toBe(true);
+ });
+
+ it('contains a modal', () => {
+ const modal = wrapper.find(UploadBlobModal);
+
+ expect(modal.exists()).toBe(true);
+ expect(modal.props('modalId')).toBe(MODAL_ID);
+ });
+
+ describe('when clickinig the upload file button', () => {
+ beforeEach(() => {
+ wrapper.find(GlButton).vm.$emit('click');
+ });
+
+ it('tracks the click_upload_modal_trigger event', () => {
+ expect(trackFileUploadEvent).toHaveBeenCalledWith('click_upload_modal_trigger');
+ });
+
+ it('opens the modal', () => {
+ expect(glModalDirective).toHaveBeenCalledWith(MODAL_ID);
+ });
+ });
+});
diff --git a/spec/frontend/projects/experiment_new_project_creation/components/new_project_push_tip_popover_spec.js b/spec/frontend/projects/experiment_new_project_creation/components/new_project_push_tip_popover_spec.js
new file mode 100644
index 00000000000..1ce16640d4a
--- /dev/null
+++ b/spec/frontend/projects/experiment_new_project_creation/components/new_project_push_tip_popover_spec.js
@@ -0,0 +1,75 @@
+import { GlPopover, GlFormInputGroup } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import NewProjectPushTipPopover from '~/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+describe('New project push tip popover', () => {
+ let wrapper;
+ const targetId = 'target';
+ const pushToCreateProjectCommand = 'command';
+ const workingWithProjectsHelpPath = 'path';
+
+ const findPopover = () => wrapper.findComponent(GlPopover);
+ const findClipboardButton = () => wrapper.findComponent(ClipboardButton);
+ const findFormInput = () => wrapper.findComponent(GlFormInputGroup);
+ const findHelpLink = () => wrapper.find('a');
+ const findTarget = () => document.getElementById(targetId);
+
+ const buildWrapper = () => {
+ wrapper = shallowMount(NewProjectPushTipPopover, {
+ propsData: {
+ target: findTarget(),
+ },
+ stubs: {
+ GlFormInputGroup,
+ },
+ provide: {
+ pushToCreateProjectCommand,
+ workingWithProjectsHelpPath,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ setFixtures(`<a id="${targetId}"></a>`);
+ buildWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders popover that targets the specified target', () => {
+ expect(findPopover().props()).toMatchObject({
+ target: findTarget(),
+ triggers: 'click blur',
+ placement: 'top',
+ title: 'Push to create a project',
+ });
+ });
+
+ it('renders a readonly form input with the push to create command', () => {
+ expect(findFormInput().props()).toMatchObject({
+ value: pushToCreateProjectCommand,
+ selectOnClick: true,
+ });
+ expect(findFormInput().attributes()).toMatchObject({
+ 'aria-label': 'Push project from command line',
+ readonly: 'readonly',
+ });
+ });
+
+ it('allows copying the push command using the clipboard button', () => {
+ expect(findClipboardButton().props()).toMatchObject({
+ text: pushToCreateProjectCommand,
+ tooltipPlacement: 'right',
+ title: 'Copy command',
+ });
+ });
+
+ it('displays a link to open the push command help page reference', () => {
+ expect(findHelpLink().attributes().href).toBe(
+ `${workingWithProjectsHelpPath}#push-to-create-a-new-project`,
+ );
+ });
+});
diff --git a/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js b/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js
index d6764f75262..f26d1a6d2a3 100644
--- a/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js
+++ b/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js
@@ -1,5 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import { mockTracking } from 'helpers/tracking_helper';
+import NewProjectPushTipPopover from '~/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue';
import WelcomePage from '~/projects/experiment_new_project_creation/components/welcome.vue';
describe('Welcome page', () => {
@@ -28,4 +29,13 @@ describe('Welcome page', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', { label: 'test' });
});
});
+
+ it('renders new project push tip popover', () => {
+ createComponent({ panels: [{ name: 'test', href: '#' }] });
+
+ const popover = wrapper.findComponent(NewProjectPushTipPopover);
+
+ expect(popover.exists()).toBe(true);
+ expect(popover.props().target()).toBe(wrapper.find({ ref: 'clipTip' }).element);
+ });
});
diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
index f9fbb1b3016..8acf2376860 100644
--- a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
+++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
@@ -154,7 +154,7 @@ describe('ServiceDeskRoot', () => {
});
it('shows an error message', () => {
- expect(getAlertText()).toContain('An error occured while saving changes:');
+ expect(getAlertText()).toContain('An error occurred while saving changes:');
});
});
});
diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js
index f6744f4971e..5323c1afbb5 100644
--- a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js
+++ b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js
@@ -40,7 +40,7 @@ describe('ServiceDeskSetting', () => {
});
it('should see activation checkbox', () => {
- expect(findToggle().exists()).toBe(true);
+ expect(findToggle().props('label')).toBe(ServiceDeskSetting.i18n.toggleLabel);
});
it('should see main panel with the email info', () => {
diff --git a/spec/frontend/projects/upload_file_experiment_tracking_spec.js b/spec/frontend/projects/upload_file_experiment_tracking_spec.js
new file mode 100644
index 00000000000..6817529e07e
--- /dev/null
+++ b/spec/frontend/projects/upload_file_experiment_tracking_spec.js
@@ -0,0 +1,43 @@
+import ExperimentTracking from '~/experimentation/experiment_tracking';
+import { trackFileUploadEvent } from '~/projects/upload_file_experiment_tracking';
+
+jest.mock('~/experimentation/experiment_tracking');
+
+const eventName = 'click_upload_modal_form_submit';
+const fixture = `<a class='js-upload-file-experiment-trigger'></a><div class='project-home-panel empty-project'></div>`;
+
+beforeEach(() => {
+ document.body.innerHTML = fixture;
+});
+
+afterEach(() => {
+ document.body.innerHTML = '';
+});
+
+describe('trackFileUploadEvent', () => {
+ it('initializes ExperimentTracking with the correct tracking event', () => {
+ trackFileUploadEvent(eventName);
+
+ expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(eventName);
+ });
+
+ it('calls ExperimentTracking with the correct arguments', () => {
+ trackFileUploadEvent(eventName);
+
+ expect(ExperimentTracking).toHaveBeenCalledWith('empty_repo_upload', {
+ label: 'blob-upload-modal',
+ property: 'empty',
+ });
+ });
+
+ it('calls ExperimentTracking with the correct arguments when the project is not empty', () => {
+ document.querySelector('.empty-project').remove();
+
+ trackFileUploadEvent(eventName);
+
+ expect(ExperimentTracking).toHaveBeenCalledWith('empty_repo_upload', {
+ label: 'blob-upload-modal',
+ property: 'nonempty',
+ });
+ });
+});
diff --git a/spec/frontend/prometheus_metrics/custom_metrics_spec.js b/spec/frontend/prometheus_metrics/custom_metrics_spec.js
index 3e3d4ee361a..20593351ee5 100644
--- a/spec/frontend/prometheus_metrics/custom_metrics_spec.js
+++ b/spec/frontend/prometheus_metrics/custom_metrics_spec.js
@@ -9,7 +9,6 @@ describe('PrometheusMetrics', () => {
const customMetricsEndpoint =
'http://test.host/frontend-fixtures/services-project/prometheus/metrics';
let mock;
- preloadFixtures(FIXTURE);
beforeEach(() => {
mock = new MockAdapter(axios);
diff --git a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js
index 722a5274ad4..a703dc0a66f 100644
--- a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js
+++ b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js
@@ -6,7 +6,6 @@ import { metrics2 as metrics, missingVarMetrics } from './mock_data';
describe('PrometheusMetrics', () => {
const FIXTURE = 'services/prometheus/prometheus_service.html';
- preloadFixtures(FIXTURE);
beforeEach(() => {
loadFixtures(FIXTURE);
diff --git a/spec/frontend/protected_branches/protected_branch_edit_spec.js b/spec/frontend/protected_branches/protected_branch_edit_spec.js
new file mode 100644
index 00000000000..40e31e24a14
--- /dev/null
+++ b/spec/frontend/protected_branches/protected_branch_edit_spec.js
@@ -0,0 +1,88 @@
+import MockAdapter from 'axios-mock-adapter';
+import $ from 'jquery';
+import { TEST_HOST } from 'helpers/test_constants';
+import { deprecatedCreateFlash as flash } from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import ProtectedBranchEdit from '~/protected_branches/protected_branch_edit';
+
+jest.mock('~/flash');
+
+const TEST_URL = `${TEST_HOST}/url`;
+const IS_CHECKED_CLASS = 'is-checked';
+
+describe('ProtectedBranchEdit', () => {
+ let mock;
+
+ beforeEach(() => {
+ setFixtures(`<div id="wrap" data-url="${TEST_URL}">
+ <button class="js-force-push-toggle">Toggle</button>
+ </div>`);
+
+ jest.spyOn(ProtectedBranchEdit.prototype, 'buildDropdowns').mockImplementation();
+
+ mock = new MockAdapter(axios);
+ });
+
+ const findForcePushesToggle = () => document.querySelector('.js-force-push-toggle');
+
+ const create = ({ isChecked = false }) => {
+ if (isChecked) {
+ findForcePushesToggle().classList.add(IS_CHECKED_CLASS);
+ }
+
+ return new ProtectedBranchEdit({ $wrap: $('#wrap'), hasLicense: false });
+ };
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('when unchecked toggle button', () => {
+ let toggle;
+
+ beforeEach(() => {
+ create({ isChecked: false });
+
+ toggle = findForcePushesToggle();
+ });
+
+ it('is not changed', () => {
+ expect(toggle).not.toHaveClass(IS_CHECKED_CLASS);
+ expect(toggle).not.toBeDisabled();
+ });
+
+ describe('when clicked', () => {
+ beforeEach(() => {
+ mock.onPatch(TEST_URL, { protected_branch: { allow_force_push: true } }).replyOnce(200, {});
+
+ toggle.click();
+ });
+
+ it('checks and disables button', () => {
+ expect(toggle).toHaveClass(IS_CHECKED_CLASS);
+ expect(toggle).toBeDisabled();
+ });
+
+ it('sends update to BE', () =>
+ axios.waitForAll().then(() => {
+ // Args are asserted in the `.onPatch` call
+ expect(mock.history.patch).toHaveLength(1);
+
+ expect(toggle).not.toBeDisabled();
+ expect(flash).not.toHaveBeenCalled();
+ }));
+ });
+
+ describe('when clicked and BE error', () => {
+ beforeEach(() => {
+ mock.onPatch(TEST_URL).replyOnce(500);
+ toggle.click();
+ });
+
+ it('flashes error', () =>
+ axios.waitForAll().then(() => {
+ expect(flash).toHaveBeenCalled();
+ }));
+ });
+ });
+});
diff --git a/spec/frontend/read_more_spec.js b/spec/frontend/read_more_spec.js
index d1d01272403..16f0d7fb075 100644
--- a/spec/frontend/read_more_spec.js
+++ b/spec/frontend/read_more_spec.js
@@ -3,8 +3,6 @@ import initReadMore from '~/read_more';
describe('Read more click-to-expand functionality', () => {
const fixtureName = 'projects/overview.html';
- preloadFixtures(fixtureName);
-
beforeEach(() => {
loadFixtures(fixtureName);
});
diff --git a/spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap b/spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap
new file mode 100644
index 00000000000..5f05b7fc68b
--- /dev/null
+++ b/spec/frontend/ref/components/__snapshots__/ref_selector_spec.js.snap
@@ -0,0 +1,70 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Ref selector component footer slot passes the expected slot props 1`] = `
+Object {
+ "isLoading": false,
+ "matches": Object {
+ "branches": Object {
+ "error": null,
+ "list": Array [
+ Object {
+ "default": false,
+ "name": "add_images_and_changes",
+ },
+ Object {
+ "default": false,
+ "name": "conflict-contains-conflict-markers",
+ },
+ Object {
+ "default": false,
+ "name": "deleted-image-test",
+ },
+ Object {
+ "default": false,
+ "name": "diff-files-image-to-symlink",
+ },
+ Object {
+ "default": false,
+ "name": "diff-files-symlink-to-image",
+ },
+ Object {
+ "default": false,
+ "name": "markdown",
+ },
+ Object {
+ "default": true,
+ "name": "master",
+ },
+ ],
+ "totalCount": 123,
+ },
+ "commits": Object {
+ "error": null,
+ "list": Array [
+ Object {
+ "name": "b83d6e39",
+ "subtitle": "Merge branch 'branch-merged' into 'master'",
+ "value": "b83d6e391c22777fca1ed3012fce84f633d7fed0",
+ },
+ ],
+ "totalCount": 1,
+ },
+ "tags": Object {
+ "error": null,
+ "list": Array [
+ Object {
+ "name": "v1.1.1",
+ },
+ Object {
+ "name": "v1.1.0",
+ },
+ Object {
+ "name": "v1.0.0",
+ },
+ ],
+ "totalCount": 456,
+ },
+ },
+ "query": "abcd1234",
+}
+`;
diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js
index 27ada131ed6..a642a8cf8c2 100644
--- a/spec/frontend/ref/components/ref_selector_spec.js
+++ b/spec/frontend/ref/components/ref_selector_spec.js
@@ -1,13 +1,20 @@
-import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem, GlIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem, GlDropdown, GlIcon } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
+import { merge, last } from 'lodash';
import Vuex from 'vuex';
import { trimText } from 'helpers/text_helper';
import { ENTER_KEY } from '~/lib/utils/keys';
import { sprintf } from '~/locale';
import RefSelector from '~/ref/components/ref_selector.vue';
-import { X_TOTAL_HEADER, DEFAULT_I18N } from '~/ref/constants';
+import {
+ X_TOTAL_HEADER,
+ DEFAULT_I18N,
+ REF_TYPE_BRANCHES,
+ REF_TYPE_TAGS,
+ REF_TYPE_COMMITS,
+} from '~/ref/constants';
import createStore from '~/ref/stores/';
const localVue = createLocalVue();
@@ -26,27 +33,32 @@ describe('Ref selector component', () => {
let branchesApiCallSpy;
let tagsApiCallSpy;
let commitApiCallSpy;
-
- const createComponent = (props = {}, attrs = {}) => {
- wrapper = mount(RefSelector, {
- propsData: {
- projectId,
- value: '',
- ...props,
- },
- attrs,
- listeners: {
- // simulate a parent component v-model binding
- input: (selectedRef) => {
- wrapper.setProps({ value: selectedRef });
+ let requestSpies;
+
+ const createComponent = (mountOverrides = {}) => {
+ wrapper = mount(
+ RefSelector,
+ merge(
+ {
+ propsData: {
+ projectId,
+ value: '',
+ },
+ listeners: {
+ // simulate a parent component v-model binding
+ input: (selectedRef) => {
+ wrapper.setProps({ value: selectedRef });
+ },
+ },
+ stubs: {
+ GlSearchBoxByType: true,
+ },
+ localVue,
+ store: createStore(),
},
- },
- stubs: {
- GlSearchBoxByType: true,
- },
- localVue,
- store: createStore(),
- });
+ mountOverrides,
+ ),
+ );
};
beforeEach(() => {
@@ -58,6 +70,7 @@ describe('Ref selector component', () => {
.mockReturnValue([200, fixtures.branches, { [X_TOTAL_HEADER]: '123' }]);
tagsApiCallSpy = jest.fn().mockReturnValue([200, fixtures.tags, { [X_TOTAL_HEADER]: '456' }]);
commitApiCallSpy = jest.fn().mockReturnValue([200, fixtures.commit]);
+ requestSpies = { branchesApiCallSpy, tagsApiCallSpy, commitApiCallSpy };
mock
.onGet(`/api/v4/projects/${projectId}/repository/branches`)
@@ -78,7 +91,7 @@ describe('Ref selector component', () => {
//
// Finders
//
- const findButtonContent = () => wrapper.find('[data-testid="button-content"]');
+ const findButtonContent = () => wrapper.find('button');
const findNoResults = () => wrapper.find('[data-testid="no-results"]');
@@ -175,7 +188,7 @@ describe('Ref selector component', () => {
const id = 'git-ref';
beforeEach(() => {
- createComponent({}, { id });
+ createComponent({ attrs: { id } });
return waitForRequests();
});
@@ -189,7 +202,7 @@ describe('Ref selector component', () => {
const preselectedRef = fixtures.branches[0].name;
beforeEach(() => {
- createComponent({ value: preselectedRef });
+ createComponent({ propsData: { value: preselectedRef } });
return waitForRequests();
});
@@ -592,4 +605,152 @@ describe('Ref selector component', () => {
});
});
});
+
+ describe('with non-default ref types', () => {
+ it.each`
+ enabledRefTypes | reqsCalled | reqsNotCalled
+ ${[REF_TYPE_BRANCHES]} | ${['branchesApiCallSpy']} | ${['tagsApiCallSpy', 'commitApiCallSpy']}
+ ${[REF_TYPE_TAGS]} | ${['tagsApiCallSpy']} | ${['branchesApiCallSpy', 'commitApiCallSpy']}
+ ${[REF_TYPE_COMMITS]} | ${[]} | ${['branchesApiCallSpy', 'tagsApiCallSpy', 'commitApiCallSpy']}
+ ${[REF_TYPE_TAGS, REF_TYPE_COMMITS]} | ${['tagsApiCallSpy']} | ${['branchesApiCallSpy', 'commitApiCallSpy']}
+ `(
+ 'only calls $reqsCalled requests when $enabledRefTypes are enabled',
+ async ({ enabledRefTypes, reqsCalled, reqsNotCalled }) => {
+ createComponent({ propsData: { enabledRefTypes } });
+
+ await waitForRequests();
+
+ reqsCalled.forEach((req) => expect(requestSpies[req]).toHaveBeenCalledTimes(1));
+ reqsNotCalled.forEach((req) => expect(requestSpies[req]).not.toHaveBeenCalled());
+ },
+ );
+
+ it('only calls commitApiCallSpy when REF_TYPE_COMMITS is enabled', async () => {
+ createComponent({ propsData: { enabledRefTypes: [REF_TYPE_COMMITS] } });
+ updateQuery('abcd1234');
+
+ await waitForRequests();
+
+ expect(commitApiCallSpy).toHaveBeenCalledTimes(1);
+ expect(branchesApiCallSpy).not.toHaveBeenCalled();
+ expect(tagsApiCallSpy).not.toHaveBeenCalled();
+ });
+
+ it('triggers another search if enabled ref types change', async () => {
+ createComponent({ propsData: { enabledRefTypes: [REF_TYPE_BRANCHES] } });
+ await waitForRequests();
+
+ expect(branchesApiCallSpy).toHaveBeenCalledTimes(1);
+ expect(tagsApiCallSpy).not.toHaveBeenCalled();
+
+ wrapper.setProps({
+ enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_TAGS],
+ });
+ await waitForRequests();
+
+ expect(branchesApiCallSpy).toHaveBeenCalledTimes(2);
+ expect(tagsApiCallSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('if a ref type becomes disabled, its section is hidden, even if it had some results in store', async () => {
+ createComponent({ propsData: { enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_COMMITS] } });
+ updateQuery('abcd1234');
+ await waitForRequests();
+
+ expect(findBranchesSection().exists()).toBe(true);
+ expect(findCommitsSection().exists()).toBe(true);
+
+ wrapper.setProps({ enabledRefTypes: [REF_TYPE_COMMITS] });
+ await waitForRequests();
+
+ expect(findBranchesSection().exists()).toBe(false);
+ expect(findCommitsSection().exists()).toBe(true);
+ });
+
+ it.each`
+ enabledRefType | findVisibleSection | findHiddenSections
+ ${REF_TYPE_BRANCHES} | ${findBranchesSection} | ${[findTagsSection, findCommitsSection]}
+ ${REF_TYPE_TAGS} | ${findTagsSection} | ${[findBranchesSection, findCommitsSection]}
+ ${REF_TYPE_COMMITS} | ${findCommitsSection} | ${[findBranchesSection, findTagsSection]}
+ `(
+ 'hides section headers if a single ref type is enabled',
+ async ({ enabledRefType, findVisibleSection, findHiddenSections }) => {
+ createComponent({ propsData: { enabledRefTypes: [enabledRefType] } });
+ updateQuery('abcd1234');
+ await waitForRequests();
+
+ expect(findVisibleSection().exists()).toBe(true);
+ expect(findVisibleSection().find('[data-testid="section-header"]').exists()).toBe(false);
+ findHiddenSections.forEach((findHiddenSection) =>
+ expect(findHiddenSection().exists()).toBe(false),
+ );
+ },
+ );
+ });
+
+ describe('validation state', () => {
+ const invalidClass = 'gl-inset-border-1-red-500!';
+ const isInvalidClassApplied = () => wrapper.find(GlDropdown).props('toggleClass')[invalidClass];
+
+ describe('valid state', () => {
+ describe('when the state prop is not provided', () => {
+ it('does not render a red border', () => {
+ createComponent();
+
+ expect(isInvalidClassApplied()).toBe(false);
+ });
+ });
+
+ describe('when the state prop is true', () => {
+ it('does not render a red border', () => {
+ createComponent({ propsData: { state: true } });
+
+ expect(isInvalidClassApplied()).toBe(false);
+ });
+ });
+ });
+
+ describe('invalid state', () => {
+ it('renders the dropdown with a red border if the state prop is false', () => {
+ createComponent({ propsData: { state: false } });
+
+ expect(isInvalidClassApplied()).toBe(true);
+ });
+ });
+ });
+
+ describe('footer slot', () => {
+ const footerContent = 'This is the footer content';
+ const createFooter = jest.fn().mockImplementation(function createMockFooter() {
+ return this.$createElement('div', { attrs: { 'data-testid': 'footer-content' } }, [
+ footerContent,
+ ]);
+ });
+
+ beforeEach(() => {
+ createComponent({
+ scopedSlots: { footer: createFooter },
+ });
+
+ updateQuery('abcd1234');
+
+ return waitForRequests();
+ });
+
+ afterEach(() => {
+ createFooter.mockClear();
+ });
+
+ it('allows custom content to be shown at the bottom of the dropdown using the footer slot', () => {
+ expect(wrapper.find(`[data-testid="footer-content"]`).text()).toBe(footerContent);
+ });
+
+ it('passes the expected slot props', () => {
+ // The createFooter function gets called every time one of the scoped properties
+ // is updated. For the sake of this test, we'll just test the last call, which
+ // represents the final state of the slot props.
+ const lastCallProps = last(createFooter.mock.calls)[0];
+ expect(lastCallProps).toMatchSnapshot();
+ });
+ });
});
diff --git a/spec/frontend/ref/stores/actions_spec.js b/spec/frontend/ref/stores/actions_spec.js
index 11acec27165..099ce062a3a 100644
--- a/spec/frontend/ref/stores/actions_spec.js
+++ b/spec/frontend/ref/stores/actions_spec.js
@@ -1,4 +1,5 @@
import testAction from 'helpers/vuex_action_helper';
+import { ALL_REF_TYPES, REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS } from '~/ref/constants';
import * as actions from '~/ref/stores/actions';
import * as types from '~/ref/stores/mutation_types';
import createState from '~/ref/stores/state';
@@ -25,6 +26,14 @@ describe('Ref selector Vuex store actions', () => {
state = createState();
});
+ describe('setEnabledRefTypes', () => {
+ it(`commits ${types.SET_ENABLED_REF_TYPES} with the enabled ref types`, () => {
+ testAction(actions.setProjectId, ALL_REF_TYPES, state, [
+ { type: types.SET_PROJECT_ID, payload: ALL_REF_TYPES },
+ ]);
+ });
+ });
+
describe('setProjectId', () => {
it(`commits ${types.SET_PROJECT_ID} with the new project ID`, () => {
const projectId = '4';
@@ -46,12 +55,23 @@ describe('Ref selector Vuex store actions', () => {
describe('search', () => {
it(`commits ${types.SET_QUERY} with the new search query`, () => {
const query = 'hello';
+ testAction(actions.search, query, state, [{ type: types.SET_QUERY, payload: query }]);
+ });
+
+ it.each`
+ enabledRefTypes | expectedActions
+ ${[REF_TYPE_BRANCHES]} | ${['searchBranches']}
+ ${[REF_TYPE_COMMITS]} | ${['searchCommits']}
+ ${[REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS]} | ${['searchBranches', 'searchTags', 'searchCommits']}
+ `(`dispatches fetch actions for enabled ref types`, ({ enabledRefTypes, expectedActions }) => {
+ const query = 'hello';
+ state.enabledRefTypes = enabledRefTypes;
testAction(
actions.search,
query,
state,
[{ type: types.SET_QUERY, payload: query }],
- [{ type: 'searchBranches' }, { type: 'searchTags' }, { type: 'searchCommits' }],
+ expectedActions.map((type) => ({ type })),
);
});
});
diff --git a/spec/frontend/ref/stores/mutations_spec.js b/spec/frontend/ref/stores/mutations_spec.js
index cda13089766..11d4fe0e206 100644
--- a/spec/frontend/ref/stores/mutations_spec.js
+++ b/spec/frontend/ref/stores/mutations_spec.js
@@ -1,4 +1,4 @@
-import { X_TOTAL_HEADER } from '~/ref/constants';
+import { X_TOTAL_HEADER, ALL_REF_TYPES } from '~/ref/constants';
import * as types from '~/ref/stores/mutation_types';
import mutations from '~/ref/stores/mutations';
import createState from '~/ref/stores/state';
@@ -13,6 +13,7 @@ describe('Ref selector Vuex store mutations', () => {
describe('initial state', () => {
it('is created with the correct structure and initial values', () => {
expect(state).toEqual({
+ enabledRefTypes: [],
projectId: null,
query: '',
@@ -39,6 +40,14 @@ describe('Ref selector Vuex store mutations', () => {
});
});
+ describe(`${types.SET_ENABLED_REF_TYPES}`, () => {
+ it('sets the enabled ref types', () => {
+ mutations[types.SET_ENABLED_REF_TYPES](state, ALL_REF_TYPES);
+
+ expect(state.enabledRefTypes).toBe(ALL_REF_TYPES);
+ });
+ });
+
describe(`${types.SET_PROJECT_ID}`, () => {
it('updates the project ID', () => {
const newProjectId = '4';
diff --git a/spec/frontend/registry/explorer/components/delete_button_spec.js b/spec/frontend/registry/explorer/components/delete_button_spec.js
index a557d9afacc..4597c42add9 100644
--- a/spec/frontend/registry/explorer/components/delete_button_spec.js
+++ b/spec/frontend/registry/explorer/components/delete_button_spec.js
@@ -58,6 +58,7 @@ describe('delete_button', () => {
title: 'Foo title',
variant: 'danger',
disabled: 'true',
+ category: 'secondary',
});
});
diff --git a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js
index 3fa3a2ae1de..b50ed87a563 100644
--- a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js
+++ b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js
@@ -1,9 +1,9 @@
-import { GlSprintf, GlButton } from '@gitlab/ui';
+import { GlButton, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { useFakeDate } from 'helpers/fake_date';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import component from '~/registry/explorer/components/details_page/details_header.vue';
import {
- DETAILS_PAGE_TITLE,
UNSCHEDULED_STATUS,
SCHEDULED_STATUS,
ONGOING_STATUS,
@@ -13,6 +13,8 @@ import {
CLEANUP_SCHEDULED_TOOLTIP,
CLEANUP_ONGOING_TOOLTIP,
CLEANUP_UNFINISHED_TOOLTIP,
+ ROOT_IMAGE_TEXT,
+ ROOT_IMAGE_TOOLTIP,
} from '~/registry/explorer/constants';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
@@ -41,6 +43,7 @@ describe('Details Header', () => {
const findTagsCount = () => findByTestId('tags-count');
const findCleanup = () => findByTestId('cleanup');
const findDeleteButton = () => wrapper.find(GlButton);
+ const findInfoIcon = () => wrapper.find(GlIcon);
const waitForMetadataItems = async () => {
// Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available
@@ -51,8 +54,10 @@ describe('Details Header', () => {
const mountComponent = (propsData = { image: defaultImage }) => {
wrapper = shallowMount(component, {
propsData,
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
stubs: {
- GlSprintf,
TitleArea,
},
});
@@ -62,15 +67,41 @@ describe('Details Header', () => {
wrapper.destroy();
wrapper = null;
});
+ describe('image name', () => {
+ describe('missing image name', () => {
+ it('root image ', () => {
+ mountComponent({ image: { ...defaultImage, name: '' } });
- it('has the correct title ', () => {
- mountComponent({ image: { ...defaultImage, name: '' } });
- expect(findTitle().text()).toMatchInterpolatedText(DETAILS_PAGE_TITLE);
- });
+ expect(findTitle().text()).toBe(ROOT_IMAGE_TEXT);
+ });
- it('shows imageName in the title', () => {
- mountComponent();
- expect(findTitle().text()).toContain('foo');
+ it('has an icon', () => {
+ mountComponent({ image: { ...defaultImage, name: '' } });
+
+ expect(findInfoIcon().exists()).toBe(true);
+ expect(findInfoIcon().props('name')).toBe('information-o');
+ });
+
+ it('has a tooltip', () => {
+ mountComponent({ image: { ...defaultImage, name: '' } });
+
+ const tooltip = getBinding(findInfoIcon().element, 'gl-tooltip');
+ expect(tooltip.value).toBe(ROOT_IMAGE_TOOLTIP);
+ });
+ });
+
+ describe('with image name present', () => {
+ it('shows image.name ', () => {
+ mountComponent();
+ expect(findTitle().text()).toContain('foo');
+ });
+
+ it('has no icon', () => {
+ mountComponent();
+
+ expect(findInfoIcon().exists()).toBe(false);
+ });
+ });
});
describe('delete button', () => {
diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js
index d6ee871341b..6c897b983f7 100644
--- a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js
+++ b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js
@@ -12,6 +12,7 @@ import {
CLEANUP_TIMED_OUT_ERROR_MESSAGE,
IMAGE_DELETE_SCHEDULED_STATUS,
IMAGE_FAILED_DELETED_STATUS,
+ ROOT_IMAGE_TEXT,
} from '~/registry/explorer/constants';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
@@ -73,8 +74,8 @@ describe('Image List Row', () => {
mountComponent();
const link = findDetailsLink();
- expect(link.html()).toContain(item.path);
- expect(link.props('to')).toMatchObject({
+ expect(link.text()).toBe(item.path);
+ expect(findDetailsLink().props('to')).toMatchObject({
name: 'details',
params: {
id: getIdFromGraphQLId(item.id),
@@ -82,6 +83,12 @@ describe('Image List Row', () => {
});
});
+ it(`when the image has no name appends ${ROOT_IMAGE_TEXT} to the path`, () => {
+ mountComponent({ item: { ...item, name: '' } });
+
+ expect(findDetailsLink().text()).toBe(`${item.path}/ ${ROOT_IMAGE_TEXT}`);
+ });
+
it('contains a clipboard button', () => {
mountComponent();
const button = findClipboardButton();
diff --git a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js
index 07256d2bbf5..11a3acd9eb9 100644
--- a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js
+++ b/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js
@@ -4,7 +4,6 @@ import Component from '~/registry/explorer/components/list_page/registry_header.
import {
CONTAINER_REGISTRY_TITLE,
LIST_INTRO_TEXT,
- EXPIRATION_POLICY_DISABLED_MESSAGE,
EXPIRATION_POLICY_DISABLED_TEXT,
} from '~/registry/explorer/constants';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
@@ -132,41 +131,5 @@ describe('registry_header', () => {
]);
});
});
-
- describe('expiration policy info message', () => {
- describe('when there are images', () => {
- describe('when expiration policy is disabled', () => {
- beforeEach(() => {
- return mountComponent({
- expirationPolicy: { enabled: false },
- expirationPolicyHelpPagePath: 'foo',
- imagesCount: 1,
- });
- });
-
- it('the prop is correctly bound', () => {
- expect(findTitleArea().props('infoMessages')).toEqual([
- { text: LIST_INTRO_TEXT, link: '' },
- { text: EXPIRATION_POLICY_DISABLED_MESSAGE, link: 'foo' },
- ]);
- });
- });
-
- describe.each`
- desc | props
- ${'when there are no images'} | ${{ expirationPolicy: { enabled: false }, imagesCount: 0 }}
- ${'when expiration policy is enabled'} | ${{ expirationPolicy: { enabled: true }, imagesCount: 1 }}
- ${'when the expiration policy is completely disabled'} | ${{ expirationPolicy: { enabled: false }, imagesCount: 1, hideExpirationPolicyData: true }}
- `('$desc', ({ props }) => {
- it('message does not exist', () => {
- mountComponent(props);
-
- expect(findTitleArea().props('infoMessages')).toEqual([
- { text: LIST_INTRO_TEXT, link: '' },
- ]);
- });
- });
- });
- });
});
});
diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js
index 65c58bf9874..76baf4f72c9 100644
--- a/spec/frontend/registry/explorer/pages/details_spec.js
+++ b/spec/frontend/registry/explorer/pages/details_spec.js
@@ -17,6 +17,8 @@ import {
UNFINISHED_STATUS,
DELETE_SCHEDULED,
ALERT_DANGER_IMAGE,
+ MISSING_OR_DELETED_IMAGE_BREADCRUMB,
+ ROOT_IMAGE_TEXT,
} from '~/registry/explorer/constants';
import deleteContainerRepositoryTagsMutation from '~/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql';
import getContainerRepositoryDetailsQuery from '~/registry/explorer/graphql/queries/get_container_repository_details.query.graphql';
@@ -515,6 +517,26 @@ describe('Details Page', () => {
expect(breadCrumbState.updateName).toHaveBeenCalledWith(containerRepositoryMock.name);
});
+
+ it(`when the image is missing set the breadcrumb to ${MISSING_OR_DELETED_IMAGE_BREADCRUMB}`, async () => {
+ mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) });
+
+ await waitForApolloRequestRender();
+
+ expect(breadCrumbState.updateName).toHaveBeenCalledWith(MISSING_OR_DELETED_IMAGE_BREADCRUMB);
+ });
+
+ it(`when the image has no name set the breadcrumb to ${ROOT_IMAGE_TEXT}`, async () => {
+ mountComponent({
+ resolver: jest
+ .fn()
+ .mockResolvedValue(graphQLImageDetailsMock({ ...containerRepositoryMock, name: null })),
+ });
+
+ await waitForApolloRequestRender();
+
+ expect(breadCrumbState.updateName).toHaveBeenCalledWith(ROOT_IMAGE_TEXT);
+ });
});
describe('when the image has a status different from null', () => {
diff --git a/spec/frontend/registry/settings/components/expiration_toggle_spec.js b/spec/frontend/registry/settings/components/expiration_toggle_spec.js
index 961bdfdf2c5..7598f6adc89 100644
--- a/spec/frontend/registry/settings/components/expiration_toggle_spec.js
+++ b/spec/frontend/registry/settings/components/expiration_toggle_spec.js
@@ -32,7 +32,7 @@ describe('ExpirationToggle', () => {
it('has a toggle component', () => {
mountComponent();
- expect(findToggle().exists()).toBe(true);
+ expect(findToggle().props('label')).toBe(component.i18n.toggleLabel);
});
it('has a description', () => {
diff --git a/spec/frontend/related_issues/components/related_issuable_input_spec.js b/spec/frontend/related_issues/components/related_issuable_input_spec.js
new file mode 100644
index 00000000000..79b228454f4
--- /dev/null
+++ b/spec/frontend/related_issues/components/related_issuable_input_spec.js
@@ -0,0 +1,117 @@
+import { shallowMount } from '@vue/test-utils';
+import { TEST_HOST } from 'helpers/test_constants';
+import RelatedIssuableInput from '~/related_issues/components/related_issuable_input.vue';
+import { issuableTypesMap, PathIdSeparator } from '~/related_issues/constants';
+
+jest.mock('ee_else_ce/gfm_auto_complete', () => {
+ return function gfmAutoComplete() {
+ return {
+ constructor() {},
+ setup() {},
+ };
+ };
+});
+
+describe('RelatedIssuableInput', () => {
+ let propsData;
+
+ beforeEach(() => {
+ propsData = {
+ inputValue: '',
+ references: [],
+ pathIdSeparator: PathIdSeparator.Issue,
+ issuableType: issuableTypesMap.issue,
+ autoCompleteSources: {
+ issues: `${TEST_HOST}/h5bp/html5-boilerplate/-/autocomplete_sources/issues`,
+ },
+ };
+ });
+
+ describe('autocomplete', () => {
+ describe('with autoCompleteSources', () => {
+ it('shows placeholder text', () => {
+ const wrapper = shallowMount(RelatedIssuableInput, { propsData });
+
+ expect(wrapper.find({ ref: 'input' }).element.placeholder).toBe(
+ 'Paste issue link or <#issue id>',
+ );
+ });
+
+ it('has GfmAutoComplete', () => {
+ const wrapper = shallowMount(RelatedIssuableInput, { propsData });
+
+ expect(wrapper.vm.gfmAutoComplete).toBeDefined();
+ });
+ });
+
+ describe('with no autoCompleteSources', () => {
+ it('shows placeholder text', () => {
+ const wrapper = shallowMount(RelatedIssuableInput, {
+ propsData: {
+ ...propsData,
+ references: ['!1', '!2'],
+ },
+ });
+
+ expect(wrapper.find({ ref: 'input' }).element.value).toBe('');
+ });
+
+ it('does not have GfmAutoComplete', () => {
+ const wrapper = shallowMount(RelatedIssuableInput, {
+ propsData: {
+ ...propsData,
+ autoCompleteSources: {},
+ },
+ });
+
+ expect(wrapper.vm.gfmAutoComplete).not.toBeDefined();
+ });
+ });
+ });
+
+ describe('focus', () => {
+ it('when clicking anywhere on the input wrapper it should focus the input', async () => {
+ const wrapper = shallowMount(RelatedIssuableInput, {
+ propsData: {
+ ...propsData,
+ references: ['foo', 'bar'],
+ },
+ // We need to attach to document, so that `document.activeElement` is properly set in jsdom
+ attachTo: document.body,
+ });
+
+ wrapper.find('li').trigger('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(document.activeElement).toBe(wrapper.find({ ref: 'input' }).element);
+ });
+ });
+
+ describe('when filling in the input', () => {
+ it('emits addIssuableFormInput with data', () => {
+ const wrapper = shallowMount(RelatedIssuableInput, {
+ propsData,
+ });
+
+ wrapper.vm.$emit = jest.fn();
+
+ const newInputValue = 'filling in things';
+ const untouchedRawReferences = newInputValue.trim().split(/\s/);
+ const touchedReference = untouchedRawReferences.pop();
+ const input = wrapper.find({ ref: 'input' });
+
+ input.element.value = newInputValue;
+ input.element.selectionStart = newInputValue.length;
+ input.element.selectionEnd = newInputValue.length;
+ input.trigger('input');
+
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormInput', {
+ newValue: newInputValue,
+ caretPos: newInputValue.length,
+ untouchedRawReferences,
+ touchedReference,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/releases/components/tag_field_new_spec.js b/spec/frontend/releases/components/tag_field_new_spec.js
index d87718138b8..387217c2a8e 100644
--- a/spec/frontend/releases/components/tag_field_new_spec.js
+++ b/spec/frontend/releases/components/tag_field_new_spec.js
@@ -1,6 +1,6 @@
-import { GlFormInput } from '@gitlab/ui';
+import { GlDropdownItem } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
-import RefSelector from '~/ref/components/ref_selector.vue';
+import Vue from 'vue';
import TagFieldNew from '~/releases/components/tag_field_new.vue';
import createStore from '~/releases/stores';
import createDetailModule from '~/releases/stores/modules/detail';
@@ -8,6 +8,25 @@ import createDetailModule from '~/releases/stores/modules/detail';
const TEST_TAG_NAME = 'test-tag-name';
const TEST_PROJECT_ID = '1234';
const TEST_CREATE_FROM = 'test-create-from';
+const NONEXISTENT_TAG_NAME = 'nonexistent-tag';
+
+// A mock version of the RefSelector component that simulates
+// a scenario where the users has searched for "nonexistent-tag"
+// and the component has found no tags that match.
+const RefSelectorStub = Vue.component('RefSelectorStub', {
+ data() {
+ return {
+ footerSlotProps: {
+ isLoading: false,
+ matches: {
+ tags: { totalCount: 0 },
+ },
+ query: NONEXISTENT_TAG_NAME,
+ },
+ };
+ },
+ template: '<div><slot name="footer" v-bind="footerSlotProps"></slot></div>',
+});
describe('releases/components/tag_field_new', () => {
let store;
@@ -17,7 +36,7 @@ describe('releases/components/tag_field_new', () => {
wrapper = mountFn(TagFieldNew, {
store,
stubs: {
- RefSelector: true,
+ RefSelector: RefSelectorStub,
},
});
};
@@ -47,11 +66,12 @@ describe('releases/components/tag_field_new', () => {
});
const findTagNameFormGroup = () => wrapper.find('[data-testid="tag-name-field"]');
- const findTagNameGlInput = () => findTagNameFormGroup().find(GlFormInput);
- const findTagNameInput = () => findTagNameFormGroup().find('input');
+ const findTagNameDropdown = () => findTagNameFormGroup().find(RefSelectorStub);
const findCreateFromFormGroup = () => wrapper.find('[data-testid="create-from-field"]');
- const findCreateFromDropdown = () => findCreateFromFormGroup().find(RefSelector);
+ const findCreateFromDropdown = () => findCreateFromFormGroup().find(RefSelectorStub);
+
+ const findCreateNewTagOption = () => wrapper.find(GlDropdownItem);
describe('"Tag name" field', () => {
describe('rendering and behavior', () => {
@@ -61,14 +81,37 @@ describe('releases/components/tag_field_new', () => {
expect(findTagNameFormGroup().attributes().label).toBe('Tag name');
});
- describe('when the user updates the field', () => {
+ describe('when the user selects a new tag name', () => {
+ beforeEach(async () => {
+ findCreateNewTagOption().vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+ });
+
+ it("updates the store's release.tagName property", () => {
+ expect(store.state.detail.release.tagName).toBe(NONEXISTENT_TAG_NAME);
+ });
+
+ it('hides the "Create from" field', () => {
+ expect(findCreateFromFormGroup().exists()).toBe(true);
+ });
+ });
+
+ describe('when the user selects an existing tag name', () => {
+ const updatedTagName = 'updated-tag-name';
+
+ beforeEach(async () => {
+ findTagNameDropdown().vm.$emit('input', updatedTagName);
+
+ await wrapper.vm.$nextTick();
+ });
+
it("updates the store's release.tagName property", () => {
- const updatedTagName = 'updated-tag-name';
- findTagNameGlInput().vm.$emit('input', updatedTagName);
+ expect(store.state.detail.release.tagName).toBe(updatedTagName);
+ });
- return wrapper.vm.$nextTick().then(() => {
- expect(store.state.detail.release.tagName).toBe(updatedTagName);
- });
+ it('shows the "Create from" field', () => {
+ expect(findCreateFromFormGroup().exists()).toBe(false);
});
});
});
@@ -83,41 +126,39 @@ describe('releases/components/tag_field_new', () => {
* @param {'shown' | 'hidden'} state The expected state of the validation message.
* Should be passed either 'shown' or 'hidden'
*/
- const expectValidationMessageToBe = (state) => {
- return wrapper.vm.$nextTick().then(() => {
- expect(findTagNameFormGroup().element).toHaveClass(
- state === 'shown' ? 'is-invalid' : 'is-valid',
- );
- expect(findTagNameFormGroup().element).not.toHaveClass(
- state === 'shown' ? 'is-valid' : 'is-invalid',
- );
- });
+ const expectValidationMessageToBe = async (state) => {
+ await wrapper.vm.$nextTick();
+
+ expect(findTagNameFormGroup().element).toHaveClass(
+ state === 'shown' ? 'is-invalid' : 'is-valid',
+ );
+ expect(findTagNameFormGroup().element).not.toHaveClass(
+ state === 'shown' ? 'is-valid' : 'is-invalid',
+ );
};
describe('when the user has not yet interacted with the component', () => {
- it('does not display a validation error', () => {
- findTagNameInput().setValue('');
+ it('does not display a validation error', async () => {
+ findTagNameDropdown().vm.$emit('input', '');
- return expectValidationMessageToBe('hidden');
+ await expectValidationMessageToBe('hidden');
});
});
describe('when the user has interacted with the component and the value is not empty', () => {
- it('does not display validation error', () => {
- findTagNameInput().trigger('blur');
+ it('does not display validation error', async () => {
+ findTagNameDropdown().vm.$emit('hide');
- return expectValidationMessageToBe('hidden');
+ await expectValidationMessageToBe('hidden');
});
});
describe('when the user has interacted with the component and the value is empty', () => {
- it('displays a validation error', () => {
- const tagNameInput = findTagNameInput();
-
- tagNameInput.setValue('');
- tagNameInput.trigger('blur');
+ it('displays a validation error', async () => {
+ findTagNameDropdown().vm.$emit('input', '');
+ findTagNameDropdown().vm.$emit('hide');
- return expectValidationMessageToBe('shown');
+ await expectValidationMessageToBe('shown');
});
});
});
@@ -131,13 +172,13 @@ describe('releases/components/tag_field_new', () => {
});
describe('when the user selects a git ref', () => {
- it("updates the store's createFrom property", () => {
+ it("updates the store's createFrom property", async () => {
const updatedCreateFrom = 'update-create-from';
findCreateFromDropdown().vm.$emit('input', updatedCreateFrom);
- return wrapper.vm.$nextTick().then(() => {
- expect(store.state.detail.createFrom).toBe(updatedCreateFrom);
- });
+ await wrapper.vm.$nextTick();
+
+ expect(store.state.detail.createFrom).toBe(updatedCreateFrom);
});
});
});
diff --git a/spec/frontend/reports/components/summary_row_spec.js b/spec/frontend/reports/components/summary_row_spec.js
index bdd6de1e0be..04d9d10dcd2 100644
--- a/spec/frontend/reports/components/summary_row_spec.js
+++ b/spec/frontend/reports/components/summary_row_spec.js
@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import SummaryRow from '~/reports/components/summary_row.vue';
describe('Summary row', () => {
@@ -14,16 +15,19 @@ describe('Summary row', () => {
};
const createComponent = ({ propsData = {}, slots = {} } = {}) => {
- wrapper = mount(SummaryRow, {
- propsData: {
- ...props,
- ...propsData,
- },
- slots,
- });
+ wrapper = extendedWrapper(
+ mount(SummaryRow, {
+ propsData: {
+ ...props,
+ ...propsData,
+ },
+ slots,
+ }),
+ );
};
- const findSummary = () => wrapper.find('.report-block-list-issue-description-text');
+ const findSummary = () => wrapper.findByTestId('summary-row-description');
+ const findStatusIcon = () => wrapper.findByTestId('summary-row-icon');
afterEach(() => {
wrapper.destroy();
@@ -37,9 +41,7 @@ describe('Summary row', () => {
it('renders provided icon', () => {
createComponent();
- expect(wrapper.find('.report-block-list-icon span').classes()).toContain(
- 'js-ci-status-icon-warning',
- );
+ expect(findStatusIcon().classes()).toContain('js-ci-status-icon-warning');
});
describe('summary slot', () => {
diff --git a/spec/frontend/reports/components/test_issue_body_spec.js b/spec/frontend/reports/components/test_issue_body_spec.js
deleted file mode 100644
index 2843620a18d..00000000000
--- a/spec/frontend/reports/components/test_issue_body_spec.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import Vue from 'vue';
-import { trimText } from 'helpers/text_helper';
-import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
-import component from '~/reports/components/test_issue_body.vue';
-import createStore from '~/reports/store';
-import { issue } from '../mock_data/mock_data';
-
-describe('Test Issue body', () => {
- let vm;
- const Component = Vue.extend(component);
- const store = createStore();
-
- const commonProps = {
- issue,
- status: 'failed',
- };
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('on click', () => {
- it('calls openModal action', () => {
- vm = mountComponentWithStore(Component, {
- store,
- props: commonProps,
- });
-
- jest.spyOn(vm, 'openModal').mockImplementation(() => {});
-
- vm.$el.querySelector('button').click();
-
- expect(vm.openModal).toHaveBeenCalledWith({
- issue: commonProps.issue,
- });
- });
- });
-
- describe('is new', () => {
- beforeEach(() => {
- vm = mountComponentWithStore(Component, {
- store,
- props: { ...commonProps, isNew: true },
- });
- });
-
- it('renders issue name', () => {
- expect(vm.$el.textContent).toContain(commonProps.issue.name);
- });
-
- it('renders new badge', () => {
- expect(trimText(vm.$el.querySelector('.badge').textContent)).toEqual('New');
- });
- });
-
- describe('not new', () => {
- beforeEach(() => {
- vm = mountComponentWithStore(Component, {
- store,
- props: commonProps,
- });
- });
-
- it('renders issue name', () => {
- expect(vm.$el.textContent).toContain(commonProps.issue.name);
- });
-
- it('does not renders new badge', () => {
- expect(vm.$el.querySelector('.badge')).toEqual(null);
- });
- });
-});
diff --git a/spec/frontend/reports/components/modal_spec.js b/spec/frontend/reports/grouped_test_report/components/modal_spec.js
index d47bb964e8a..303009bab3a 100644
--- a/spec/frontend/reports/components/modal_spec.js
+++ b/spec/frontend/reports/grouped_test_report/components/modal_spec.js
@@ -2,8 +2,8 @@ import { GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import ReportsModal from '~/reports/components/modal.vue';
-import state from '~/reports/store/state';
+import ReportsModal from '~/reports/grouped_test_report/components/modal.vue';
+import state from '~/reports/grouped_test_report/store/state';
import CodeBlock from '~/vue_shared/components/code_block.vue';
const StubbedGlModal = { template: '<div><slot></slot></div>', name: 'GlModal', props: ['title'] };
diff --git a/spec/frontend/reports/grouped_test_report/components/test_issue_body_spec.js b/spec/frontend/reports/grouped_test_report/components/test_issue_body_spec.js
new file mode 100644
index 00000000000..e03a52aad8d
--- /dev/null
+++ b/spec/frontend/reports/grouped_test_report/components/test_issue_body_spec.js
@@ -0,0 +1,97 @@
+import { GlBadge, GlButton } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
+import TestIssueBody from '~/reports/grouped_test_report/components/test_issue_body.vue';
+import { failedIssue, successIssue } from '../../mock_data/mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('Test issue body', () => {
+ let wrapper;
+ let store;
+
+ const findDescription = () => wrapper.findByTestId('test-issue-body-description');
+ const findStatusIcon = () => wrapper.findComponent(IssueStatusIcon);
+ const findBadge = () => wrapper.findComponent(GlBadge);
+
+ const actionSpies = {
+ openModal: jest.fn(),
+ };
+
+ const createComponent = ({ issue = failedIssue } = {}) => {
+ store = new Vuex.Store({
+ actions: actionSpies,
+ });
+
+ wrapper = extendedWrapper(
+ shallowMount(TestIssueBody, {
+ store,
+ localVue,
+ propsData: {
+ issue,
+ },
+ stubs: {
+ GlBadge,
+ GlButton,
+ IssueStatusIcon,
+ },
+ }),
+ );
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when issue has failed status', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders issue name', () => {
+ expect(findDescription().text()).toBe(failedIssue.name);
+ });
+
+ it('renders failed status icon', () => {
+ expect(findStatusIcon().props('status')).toBe('failed');
+ });
+
+ describe('when issue has recent failures', () => {
+ it('renders recent failures badge', () => {
+ expect(findBadge().exists()).toBe(true);
+ });
+ });
+ });
+
+ describe('when issue has success status', () => {
+ beforeEach(() => {
+ createComponent({ issue: successIssue });
+ });
+
+ it('does not render recent failures', () => {
+ expect(findBadge().exists()).toBe(false);
+ });
+
+ it('renders issue name', () => {
+ expect(findDescription().text()).toBe(successIssue.name);
+ });
+
+ it('renders success status icon', () => {
+ expect(findStatusIcon().props('status')).toBe('success');
+ });
+ });
+
+ describe('when clicking on an issue', () => {
+ it('calls openModal action', () => {
+ createComponent();
+ wrapper.findComponent(GlButton).trigger('click');
+
+ expect(actionSpies.openModal).toHaveBeenCalledWith(expect.any(Object), {
+ issue: failedIssue,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/reports/components/grouped_test_reports_app_spec.js b/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js
index ed261ed12c0..49332157691 100644
--- a/spec/frontend/reports/components/grouped_test_reports_app_spec.js
+++ b/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js
@@ -1,8 +1,8 @@
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { mockTracking } from 'helpers/tracking_helper';
-import GroupedTestReportsApp from '~/reports/components/grouped_test_reports_app.vue';
-import { getStoreConfig } from '~/reports/store';
+import GroupedTestReportsApp from '~/reports/grouped_test_report/grouped_test_reports_app.vue';
+import { getStoreConfig } from '~/reports/grouped_test_report/store';
import { failedReport } from '../mock_data/mock_data';
import mixedResultsTestReports from '../mock_data/new_and_fixed_failures_report.json';
@@ -42,8 +42,12 @@ describe('Grouped test reports app', () => {
const findHeader = () => wrapper.find('[data-testid="report-section-code-text"]');
const findExpandButton = () => wrapper.find('[data-testid="report-section-expand-button"]');
const findFullTestReportLink = () => wrapper.find('[data-testid="group-test-reports-full-link"]');
- const findSummaryDescription = () => wrapper.find('[data-testid="test-summary-row-description"]');
+ const findSummaryDescription = () => wrapper.find('[data-testid="summary-row-description"]');
+ const findIssueListUnresolvedHeading = () => wrapper.find('[data-testid="unresolvedHeading"]');
+ const findIssueListResolvedHeading = () => wrapper.find('[data-testid="resolvedHeading"]');
const findIssueDescription = () => wrapper.find('[data-testid="test-issue-body-description"]');
+ const findIssueRecentFailures = () =>
+ wrapper.find('[data-testid="test-issue-body-recent-failures"]');
const findAllIssueDescriptions = () =>
wrapper.findAll('[data-testid="test-issue-body-description"]');
@@ -133,6 +137,10 @@ describe('Grouped test reports app', () => {
mountComponent();
});
+ it('renders New heading', () => {
+ expect(findIssueListUnresolvedHeading().text()).toBe('New');
+ });
+
it('renders failed summary text', () => {
expect(findHeader().text()).toBe('Test summary contained 2 failed out of 11 total tests');
});
@@ -144,7 +152,6 @@ describe('Grouped test reports app', () => {
});
it('renders failed issue in list', () => {
- expect(findIssueDescription().text()).toContain('New');
expect(findIssueDescription().text()).toContain(
'Test#sum when a is 1 and b is 2 returns summary',
);
@@ -157,6 +164,10 @@ describe('Grouped test reports app', () => {
mountComponent();
});
+ it('renders New heading', () => {
+ expect(findIssueListUnresolvedHeading().text()).toBe('New');
+ });
+
it('renders error summary text', () => {
expect(findHeader().text()).toBe('Test summary contained 2 errors out of 11 total tests');
});
@@ -168,7 +179,6 @@ describe('Grouped test reports app', () => {
});
it('renders error issue in list', () => {
- expect(findIssueDescription().text()).toContain('New');
expect(findIssueDescription().text()).toContain(
'Test#sum when a is 1 and b is 2 returns summary',
);
@@ -181,6 +191,11 @@ describe('Grouped test reports app', () => {
mountComponent();
});
+ it('renders New and Fixed headings', () => {
+ expect(findIssueListUnresolvedHeading().text()).toBe('New');
+ expect(findIssueListResolvedHeading().text()).toBe('Fixed');
+ });
+
it('renders summary text', () => {
expect(findHeader().text()).toBe(
'Test summary contained 2 failed and 2 fixed test results out of 11 total tests',
@@ -194,7 +209,6 @@ describe('Grouped test reports app', () => {
});
it('renders failed issue in list', () => {
- expect(findIssueDescription().text()).toContain('New');
expect(findIssueDescription().text()).toContain(
'Test#subtract when a is 2 and b is 1 returns correct result',
);
@@ -207,6 +221,10 @@ describe('Grouped test reports app', () => {
mountComponent();
});
+ it('renders Fixed heading', () => {
+ expect(findIssueListResolvedHeading().text()).toBe('Fixed');
+ });
+
it('renders summary text', () => {
expect(findHeader().text()).toBe(
'Test summary contained 4 fixed test results out of 11 total tests',
@@ -252,7 +270,7 @@ describe('Grouped test reports app', () => {
});
it('renders the recent failures count on the test case', () => {
- expect(findIssueDescription().text()).toContain(
+ expect(findIssueRecentFailures().text()).toBe(
'Failed 8 times in master in the last 14 days',
);
});
@@ -295,6 +313,27 @@ describe('Grouped test reports app', () => {
});
});
+ describe('with a report parsing errors', () => {
+ beforeEach(() => {
+ const reports = failedReport;
+ reports.suites[0].suite_errors = {
+ head: 'JUnit XML parsing failed: 2:24: FATAL: attributes construct error',
+ base: 'JUnit data parsing failed: string not matched',
+ };
+ setReports(reports);
+ mountComponent();
+ });
+
+ it('renders the error messages', () => {
+ expect(findSummaryDescription().text()).toContain(
+ 'JUnit XML parsing failed: 2:24: FATAL: attributes construct error',
+ );
+ expect(findSummaryDescription().text()).toContain(
+ 'JUnit data parsing failed: string not matched',
+ );
+ });
+ });
+
describe('with error', () => {
beforeEach(() => {
mockStore.state.isLoading = false;
diff --git a/spec/frontend/reports/store/actions_spec.js b/spec/frontend/reports/grouped_test_report/store/actions_spec.js
index 25c3105466f..28633f7ba16 100644
--- a/spec/frontend/reports/store/actions_spec.js
+++ b/spec/frontend/reports/grouped_test_report/store/actions_spec.js
@@ -12,9 +12,9 @@ import {
receiveReportsError,
openModal,
closeModal,
-} from '~/reports/store/actions';
-import * as types from '~/reports/store/mutation_types';
-import state from '~/reports/store/state';
+} from '~/reports/grouped_test_report/store/actions';
+import * as types from '~/reports/grouped_test_report/store/mutation_types';
+import state from '~/reports/grouped_test_report/store/state';
describe('Reports Store Actions', () => {
let mockedState;
diff --git a/spec/frontend/reports/store/mutations_spec.js b/spec/frontend/reports/grouped_test_report/store/mutations_spec.js
index 652b3b0ec45..60d5016a11b 100644
--- a/spec/frontend/reports/store/mutations_spec.js
+++ b/spec/frontend/reports/grouped_test_report/store/mutations_spec.js
@@ -1,7 +1,7 @@
-import * as types from '~/reports/store/mutation_types';
-import mutations from '~/reports/store/mutations';
-import state from '~/reports/store/state';
-import { issue } from '../mock_data/mock_data';
+import * as types from '~/reports/grouped_test_report/store/mutation_types';
+import mutations from '~/reports/grouped_test_report/store/mutations';
+import state from '~/reports/grouped_test_report/store/state';
+import { failedIssue } from '../../mock_data/mock_data';
describe('Reports Store Mutations', () => {
let stateCopy;
@@ -115,17 +115,17 @@ describe('Reports Store Mutations', () => {
describe('SET_ISSUE_MODAL_DATA', () => {
beforeEach(() => {
mutations[types.SET_ISSUE_MODAL_DATA](stateCopy, {
- issue,
+ issue: failedIssue,
});
});
it('should set modal title', () => {
- expect(stateCopy.modal.title).toEqual(issue.name);
+ expect(stateCopy.modal.title).toEqual(failedIssue.name);
});
it('should set modal data', () => {
- expect(stateCopy.modal.data.execution_time.value).toEqual(issue.execution_time);
- expect(stateCopy.modal.data.system_output.value).toEqual(issue.system_output);
+ expect(stateCopy.modal.data.execution_time.value).toEqual(failedIssue.execution_time);
+ expect(stateCopy.modal.data.system_output.value).toEqual(failedIssue.system_output);
});
it('should open modal', () => {
@@ -136,7 +136,7 @@ describe('Reports Store Mutations', () => {
describe('RESET_ISSUE_MODAL_DATA', () => {
beforeEach(() => {
mutations[types.SET_ISSUE_MODAL_DATA](stateCopy, {
- issue,
+ issue: failedIssue,
});
mutations[types.RESET_ISSUE_MODAL_DATA](stateCopy);
diff --git a/spec/frontend/reports/store/utils_spec.js b/spec/frontend/reports/grouped_test_report/store/utils_spec.js
index cbc87bbb5ec..63320744796 100644
--- a/spec/frontend/reports/store/utils_spec.js
+++ b/spec/frontend/reports/grouped_test_report/store/utils_spec.js
@@ -5,7 +5,7 @@ import {
ICON_SUCCESS,
ICON_NOTFOUND,
} from '~/reports/constants';
-import * as utils from '~/reports/store/utils';
+import * as utils from '~/reports/grouped_test_report/store/utils';
describe('Reports store utils', () => {
describe('summaryTextbuilder', () => {
diff --git a/spec/frontend/reports/mock_data/mock_data.js b/spec/frontend/reports/mock_data/mock_data.js
index 3caaab2fd79..68c7439df47 100644
--- a/spec/frontend/reports/mock_data/mock_data.js
+++ b/spec/frontend/reports/mock_data/mock_data.js
@@ -1,9 +1,23 @@
-export const issue = {
+export const failedIssue = {
result: 'failure',
name: 'Test#sum when a is 1 and b is 2 returns summary',
execution_time: 0.009411,
+ status: 'failed',
system_output:
"Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in \u003ctop (required)\u003e'",
+ recent_failures: {
+ count: 3,
+ base_branch: 'master',
+ },
+};
+
+export const successIssue = {
+ result: 'success',
+ name: 'Test#sum when a is 1 and b is 2 returns summary',
+ execution_time: 0.009411,
+ status: 'success',
+ system_output: null,
+ recent_failures: null,
};
export const failedReport = {
diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js
new file mode 100644
index 00000000000..935ed08f67a
--- /dev/null
+++ b/spec/frontend/repository/components/upload_blob_modal_spec.js
@@ -0,0 +1,203 @@
+import { GlModal, GlFormInput, GlFormTextarea, GlToggle, GlAlert } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+import httpStatusCodes from '~/lib/utils/http_status';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { trackFileUploadEvent } from '~/projects/upload_file_experiment_tracking';
+import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
+import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
+
+jest.mock('~/projects/upload_file_experiment_tracking');
+jest.mock('~/flash');
+jest.mock('~/lib/utils/url_utility', () => ({
+ visitUrl: jest.fn(),
+ joinPaths: () => '/new_upload',
+}));
+
+const initialProps = {
+ modalId: 'upload-blob',
+ commitMessage: 'Upload New File',
+ targetBranch: 'master',
+ originalBranch: 'master',
+ canPushCode: true,
+ path: 'new_upload',
+};
+
+describe('UploadBlobModal', () => {
+ let wrapper;
+ let mock;
+
+ const mockEvent = { preventDefault: jest.fn() };
+
+ const createComponent = (props) => {
+ wrapper = shallowMount(UploadBlobModal, {
+ propsData: {
+ ...initialProps,
+ ...props,
+ },
+ mocks: {
+ $route: {
+ params: {
+ path: '',
+ },
+ },
+ },
+ });
+ };
+
+ const findModal = () => wrapper.find(GlModal);
+ const findAlert = () => wrapper.find(GlAlert);
+ const findCommitMessage = () => wrapper.find(GlFormTextarea);
+ const findBranchName = () => wrapper.find(GlFormInput);
+ const findMrToggle = () => wrapper.find(GlToggle);
+ const findUploadDropzone = () => wrapper.find(UploadDropzone);
+ const actionButtonDisabledState = () => findModal().props('actionPrimary').attributes[0].disabled;
+ const cancelButtonDisabledState = () => findModal().props('actionCancel').attributes[0].disabled;
+ const actionButtonLoadingState = () => findModal().props('actionPrimary').attributes[0].loading;
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe.each`
+ canPushCode | displayBranchName | displayForkedBranchMessage
+ ${true} | ${true} | ${false}
+ ${false} | ${false} | ${true}
+ `(
+ 'canPushCode = $canPushCode',
+ ({ canPushCode, displayBranchName, displayForkedBranchMessage }) => {
+ beforeEach(() => {
+ createComponent({ canPushCode });
+ });
+
+ it('displays the modal', () => {
+ expect(findModal().exists()).toBe(true);
+ });
+
+ it('includes the upload dropzone', () => {
+ expect(findUploadDropzone().exists()).toBe(true);
+ });
+
+ it('includes the commit message', () => {
+ expect(findCommitMessage().exists()).toBe(true);
+ });
+
+ it('displays the disabled upload button', () => {
+ expect(actionButtonDisabledState()).toBe(true);
+ });
+
+ it('displays the enabled cancel button', () => {
+ expect(cancelButtonDisabledState()).toBe(false);
+ });
+
+ it('does not display the MR toggle', () => {
+ expect(findMrToggle().exists()).toBe(false);
+ });
+
+ it(`${
+ displayForkedBranchMessage ? 'displays' : 'does not display'
+ } the forked branch message`, () => {
+ expect(findAlert().exists()).toBe(displayForkedBranchMessage);
+ });
+
+ it(`${displayBranchName ? 'displays' : 'does not display'} the branch name`, () => {
+ expect(findBranchName().exists()).toBe(displayBranchName);
+ });
+
+ if (canPushCode) {
+ describe('when changing the branch name', () => {
+ it('displays the MR toggle', async () => {
+ wrapper.setData({ target: 'Not master' });
+
+ await wrapper.vm.$nextTick();
+
+ expect(findMrToggle().exists()).toBe(true);
+ });
+ });
+ }
+
+ describe('completed form', () => {
+ beforeEach(() => {
+ wrapper.setData({
+ file: { type: 'jpg' },
+ filePreviewURL: 'http://file.com?format=jpg',
+ });
+ });
+
+ it('enables the upload button when the form is completed', () => {
+ expect(actionButtonDisabledState()).toBe(false);
+ });
+
+ describe('form submission', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+
+ findModal().vm.$emit('primary', mockEvent);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('disables the upload button', () => {
+ expect(actionButtonDisabledState()).toBe(true);
+ });
+
+ it('sets the upload button to loading', () => {
+ expect(actionButtonLoadingState()).toBe(true);
+ });
+ });
+
+ describe('successful response', () => {
+ beforeEach(async () => {
+ mock = new MockAdapter(axios);
+ mock.onPost(initialProps.path).reply(httpStatusCodes.OK, { filePath: 'blah' });
+
+ findModal().vm.$emit('primary', mockEvent);
+
+ await waitForPromises();
+ });
+
+ it('tracks the click_upload_modal_trigger event when opening the modal', () => {
+ expect(trackFileUploadEvent).toHaveBeenCalledWith('click_upload_modal_form_submit');
+ });
+
+ it('redirects to the uploaded file', () => {
+ expect(visitUrl).toHaveBeenCalled();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+ });
+
+ describe('error response', () => {
+ beforeEach(async () => {
+ mock = new MockAdapter(axios);
+ mock.onPost(initialProps.path).timeout();
+
+ findModal().vm.$emit('primary', mockEvent);
+
+ await waitForPromises();
+ });
+
+ it('does not track an event', () => {
+ expect(trackFileUploadEvent).not.toHaveBeenCalled();
+ });
+
+ it('creates a flash error', () => {
+ expect(createFlash).toHaveBeenCalledWith('Error uploading file. Please try again.');
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+ });
+ });
+ },
+ );
+});
diff --git a/spec/frontend/right_sidebar_spec.js b/spec/frontend/right_sidebar_spec.js
index f3719b28baa..8699e1cf420 100644
--- a/spec/frontend/right_sidebar_spec.js
+++ b/spec/frontend/right_sidebar_spec.js
@@ -27,7 +27,6 @@ const assertSidebarState = (state) => {
describe('RightSidebar', () => {
describe('fixture tests', () => {
const fixtureName = 'issues/open-issue.html';
- preloadFixtures(fixtureName);
let mock;
beforeEach(() => {
diff --git a/spec/frontend/search/highlight_blob_search_result_spec.js b/spec/frontend/search/highlight_blob_search_result_spec.js
index c1b0c7d794b..6908bcbd283 100644
--- a/spec/frontend/search/highlight_blob_search_result_spec.js
+++ b/spec/frontend/search/highlight_blob_search_result_spec.js
@@ -4,8 +4,6 @@ const fixture = 'search/blob_search_result.html';
const searchKeyword = 'Send'; // spec/frontend/fixtures/search.rb#79
describe('search/highlight_blob_search_result', () => {
- preloadFixtures(fixture);
-
beforeEach(() => loadFixtures(fixture));
it('highlights lines with search term occurrence', () => {
diff --git a/spec/frontend/search_autocomplete_spec.js b/spec/frontend/search_autocomplete_spec.js
index a9fbe0fe552..5aca07d59e4 100644
--- a/spec/frontend/search_autocomplete_spec.js
+++ b/spec/frontend/search_autocomplete_spec.js
@@ -105,7 +105,6 @@ describe('Search autocomplete dropdown', () => {
expect(list.find(mrsIHaveCreatedLink).text()).toBe("Merge requests I've created");
};
- preloadFixtures('static/search_autocomplete.html');
beforeEach(() => {
loadFixtures('static/search_autocomplete.html');
diff --git a/spec/frontend/security_configuration/configuration_table_spec.js b/spec/frontend/security_configuration/configuration_table_spec.js
index 49f9a7a3ea8..b8a574dc4e0 100644
--- a/spec/frontend/security_configuration/configuration_table_spec.js
+++ b/spec/frontend/security_configuration/configuration_table_spec.js
@@ -1,15 +1,11 @@
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import ConfigurationTable from '~/security_configuration/components/configuration_table.vue';
-import { features, UPGRADE_CTA } from '~/security_configuration/components/features_constants';
+import { scanners, UPGRADE_CTA } from '~/security_configuration/components/scanners_constants';
import {
REPORT_TYPE_SAST,
- REPORT_TYPE_DAST,
- REPORT_TYPE_DEPENDENCY_SCANNING,
- REPORT_TYPE_CONTAINER_SCANNING,
- REPORT_TYPE_COVERAGE_FUZZING,
- REPORT_TYPE_LICENSE_COMPLIANCE,
+ REPORT_TYPE_SECRET_DETECTION,
} from '~/vue_shared/security_reports/constants';
describe('Configuration Table Component', () => {
@@ -19,6 +15,8 @@ describe('Configuration Table Component', () => {
wrapper = extendedWrapper(mount(ConfigurationTable, {}));
};
+ const findHelpLinks = () => wrapper.findAll('[data-testid="help-link"]');
+
afterEach(() => {
wrapper.destroy();
});
@@ -27,22 +25,20 @@ describe('Configuration Table Component', () => {
createComponent();
});
- it.each(features)('should match strings', (feature) => {
- expect(wrapper.text()).toContain(feature.name);
- expect(wrapper.text()).toContain(feature.description);
-
- if (feature.type === REPORT_TYPE_SAST) {
- expect(wrapper.findByTestId(feature.type).text()).toBe('Configure via Merge Request');
- } else if (
- [
- REPORT_TYPE_DAST,
- REPORT_TYPE_DEPENDENCY_SCANNING,
- REPORT_TYPE_CONTAINER_SCANNING,
- REPORT_TYPE_COVERAGE_FUZZING,
- REPORT_TYPE_LICENSE_COMPLIANCE,
- ].includes(feature.type)
- ) {
- expect(wrapper.findByTestId(feature.type).text()).toMatchInterpolatedText(UPGRADE_CTA);
- }
+ describe.each(scanners.map((scanner, i) => [scanner, i]))('given scanner %s', (scanner, i) => {
+ it('should match strings', () => {
+ expect(wrapper.text()).toContain(scanner.name);
+ expect(wrapper.text()).toContain(scanner.description);
+ if (scanner.type === REPORT_TYPE_SAST) {
+ expect(wrapper.findByTestId(scanner.type).text()).toBe('Configure via Merge Request');
+ } else if (scanner.type !== REPORT_TYPE_SECRET_DETECTION) {
+ expect(wrapper.findByTestId(scanner.type).text()).toMatchInterpolatedText(UPGRADE_CTA);
+ }
+ });
+
+ it('should show expected help link', () => {
+ const helpLink = findHelpLinks().at(i);
+ expect(helpLink.attributes('href')).toBe(scanner.helpPath);
+ });
});
});
diff --git a/spec/frontend/security_configuration/upgrade_spec.js b/spec/frontend/security_configuration/upgrade_spec.js
index 0ab1108b265..1f0cc795fc5 100644
--- a/spec/frontend/security_configuration/upgrade_spec.js
+++ b/spec/frontend/security_configuration/upgrade_spec.js
@@ -1,28 +1,29 @@
import { mount } from '@vue/test-utils';
-import { UPGRADE_CTA } from '~/security_configuration/components/features_constants';
+import { UPGRADE_CTA } from '~/security_configuration/components/scanners_constants';
import Upgrade from '~/security_configuration/components/upgrade.vue';
+const TEST_URL = 'http://www.example.test';
let wrapper;
-const createComponent = () => {
- wrapper = mount(Upgrade, {});
+const createComponent = (componentData = {}) => {
+ wrapper = mount(Upgrade, componentData);
};
-beforeEach(() => {
- createComponent();
-});
-
afterEach(() => {
wrapper.destroy();
});
describe('Upgrade component', () => {
+ beforeEach(() => {
+ createComponent({ provide: { upgradePath: TEST_URL } });
+ });
+
it('renders correct text in link', () => {
expect(wrapper.text()).toMatchInterpolatedText(UPGRADE_CTA);
});
- it('renders link with correct attributes', () => {
+ it('renders link with correct default attributes', () => {
expect(wrapper.find('a').attributes()).toMatchObject({
- href: 'https://about.gitlab.com/pricing/',
+ href: TEST_URL,
target: '_blank',
});
});
diff --git a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap
index bd05eb69080..226e580a8e8 100644
--- a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap
+++ b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap
@@ -45,13 +45,10 @@ exports[`self monitor component When the self monitor project has not been creat
Enabling this feature creates a project that can be used to monitor the health of your instance.
</p>
- <gl-form-group-stub
- label="Create Project"
- label-for="self-monitor-toggle"
- >
+ <gl-form-group-stub>
<gl-toggle-stub
+ label="Create Project"
labelposition="top"
- name="self-monitor-toggle"
/>
</gl-form-group-stub>
</form>
diff --git a/spec/frontend/self_monitor/components/self_monitor_form_spec.js b/spec/frontend/self_monitor/components/self_monitor_form_spec.js
index 5f5934305c6..e6962e4c453 100644
--- a/spec/frontend/self_monitor/components/self_monitor_form_spec.js
+++ b/spec/frontend/self_monitor/components/self_monitor_form_spec.js
@@ -1,4 +1,4 @@
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlToggle } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import SelfMonitor from '~/self_monitor/components/self_monitor_form.vue';
@@ -82,6 +82,14 @@ describe('self monitor component', () => {
wrapper.find({ ref: 'selfMonitoringFormText' }).find('a').attributes('href'),
).toEqual(`${TEST_HOST}/instance-administrators-random/gitlab-self-monitoring`);
});
+
+ it('renders toggle', () => {
+ wrapper = shallowMount(SelfMonitor, { store });
+
+ expect(wrapper.findComponent(GlToggle).props('label')).toBe(
+ SelfMonitor.formLabels.createProject,
+ );
+ });
});
});
});
diff --git a/spec/frontend/sentry/sentry_config_spec.js b/spec/frontend/sentry/sentry_config_spec.js
index f7102f9b2f9..1f5097ef2a8 100644
--- a/spec/frontend/sentry/sentry_config_spec.js
+++ b/spec/frontend/sentry/sentry_config_spec.js
@@ -1,5 +1,5 @@
+import * as Sentry from '@sentry/browser';
import SentryConfig from '~/sentry/sentry_config';
-import * as Sentry from '~/sentry/wrapper';
describe('SentryConfig', () => {
describe('IGNORE_ERRORS', () => {
diff --git a/spec/frontend/settings_panels_spec.js b/spec/frontend/settings_panels_spec.js
index 8666106d3c6..6b739617b97 100644
--- a/spec/frontend/settings_panels_spec.js
+++ b/spec/frontend/settings_panels_spec.js
@@ -2,8 +2,6 @@ import $ from 'jquery';
import initSettingsPanels, { isExpanded } from '~/settings_panels';
describe('Settings Panels', () => {
- preloadFixtures('groups/edit.html');
-
beforeEach(() => {
loadFixtures('groups/edit.html');
});
diff --git a/spec/frontend/shared/popover_spec.js b/spec/frontend/shared/popover_spec.js
deleted file mode 100644
index 59b0b3b006c..00000000000
--- a/spec/frontend/shared/popover_spec.js
+++ /dev/null
@@ -1,166 +0,0 @@
-import $ from 'jquery';
-import { togglePopover, mouseleave, mouseenter } from '~/shared/popover';
-
-describe('popover', () => {
- describe('togglePopover', () => {
- describe('togglePopover(true)', () => {
- it('returns true when popover is shown', () => {
- const context = {
- hasClass: () => false,
- popover: () => {},
- toggleClass: () => {},
- };
-
- expect(togglePopover.call(context, true)).toEqual(true);
- });
-
- it('returns false when popover is already shown', () => {
- const context = {
- hasClass: () => true,
- };
-
- expect(togglePopover.call(context, true)).toEqual(false);
- });
-
- it('shows popover', (done) => {
- const context = {
- hasClass: () => false,
- popover: () => {},
- toggleClass: () => {},
- };
-
- jest.spyOn(context, 'popover').mockImplementation((method) => {
- expect(method).toEqual('show');
- done();
- });
-
- togglePopover.call(context, true);
- });
-
- it('adds disable-animation and js-popover-show class', (done) => {
- const context = {
- hasClass: () => false,
- popover: () => {},
- toggleClass: () => {},
- };
-
- jest.spyOn(context, 'toggleClass').mockImplementation((classNames, show) => {
- expect(classNames).toEqual('disable-animation js-popover-show');
- expect(show).toEqual(true);
- done();
- });
-
- togglePopover.call(context, true);
- });
- });
-
- describe('togglePopover(false)', () => {
- it('returns true when popover is hidden', () => {
- const context = {
- hasClass: () => true,
- popover: () => {},
- toggleClass: () => {},
- };
-
- expect(togglePopover.call(context, false)).toEqual(true);
- });
-
- it('returns false when popover is already hidden', () => {
- const context = {
- hasClass: () => false,
- };
-
- expect(togglePopover.call(context, false)).toEqual(false);
- });
-
- it('hides popover', (done) => {
- const context = {
- hasClass: () => true,
- popover: () => {},
- toggleClass: () => {},
- };
-
- jest.spyOn(context, 'popover').mockImplementation((method) => {
- expect(method).toEqual('hide');
- done();
- });
-
- togglePopover.call(context, false);
- });
-
- it('removes disable-animation and js-popover-show class', (done) => {
- const context = {
- hasClass: () => true,
- popover: () => {},
- toggleClass: () => {},
- };
-
- jest.spyOn(context, 'toggleClass').mockImplementation((classNames, show) => {
- expect(classNames).toEqual('disable-animation js-popover-show');
- expect(show).toEqual(false);
- done();
- });
-
- togglePopover.call(context, false);
- });
- });
- });
-
- describe('mouseleave', () => {
- it('calls hide popover if .popover:hover is false', () => {
- const fakeJquery = {
- length: 0,
- };
-
- jest
- .spyOn($.fn, 'init')
- .mockImplementation((selector) => (selector === '.popover:hover' ? fakeJquery : $.fn));
- jest.spyOn(togglePopover, 'call').mockImplementation(() => {});
- mouseleave();
-
- expect(togglePopover.call).toHaveBeenCalledWith(expect.any(Object), false);
- });
-
- it('does not call hide popover if .popover:hover is true', () => {
- const fakeJquery = {
- length: 1,
- };
-
- jest
- .spyOn($.fn, 'init')
- .mockImplementation((selector) => (selector === '.popover:hover' ? fakeJquery : $.fn));
- jest.spyOn(togglePopover, 'call').mockImplementation(() => {});
- mouseleave();
-
- expect(togglePopover.call).not.toHaveBeenCalledWith(false);
- });
- });
-
- describe('mouseenter', () => {
- const context = {};
-
- it('shows popover', () => {
- jest.spyOn(togglePopover, 'call').mockReturnValue(false);
- mouseenter.call(context);
-
- expect(togglePopover.call).toHaveBeenCalledWith(expect.any(Object), true);
- });
-
- it('registers mouseleave event if popover is showed', (done) => {
- jest.spyOn(togglePopover, 'call').mockReturnValue(true);
- jest.spyOn($.fn, 'on').mockImplementation((eventName) => {
- expect(eventName).toEqual('mouseleave');
- done();
- });
- mouseenter.call(context);
- });
-
- it('does not register mouseleave event if popover is not showed', () => {
- jest.spyOn(togglePopover, 'call').mockReturnValue(false);
- const spy = jest.spyOn($.fn, 'on').mockImplementation(() => {});
- mouseenter.call(context);
-
- expect(spy).not.toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js
index 1650dd2c1ca..fc5eeee9687 100644
--- a/spec/frontend/shortcuts_spec.js
+++ b/spec/frontend/shortcuts_spec.js
@@ -20,8 +20,6 @@ describe('Shortcuts', () => {
target,
});
- preloadFixtures(fixtureName);
-
beforeEach(() => {
loadFixtures(fixtureName);
diff --git a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap
deleted file mode 100644
index 2367667544d..00000000000
--- a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap
+++ /dev/null
@@ -1,199 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = false 1`] = `
-<div
- class="block issuable-sidebar-item confidentiality"
- iid=""
->
- <div
- class="sidebar-collapsed-icon"
- title="Not confidential"
- >
- <gl-icon-stub
- name="eye"
- size="16"
- />
- </div>
-
- <div
- class="title hide-collapsed"
- >
-
- Confidentiality
-
- <!---->
- </div>
-
- <div
- class="value sidebar-item-value hide-collapsed"
- >
- <!---->
-
- <div
- class="no-value sidebar-item-value"
- data-testid="not-confidential"
- >
- <gl-icon-stub
- class="sidebar-item-icon inline"
- name="eye"
- size="16"
- />
-
- Not confidential
-
- </div>
- </div>
-</div>
-`;
-
-exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = true 1`] = `
-<div
- class="block issuable-sidebar-item confidentiality"
- iid=""
->
- <div
- class="sidebar-collapsed-icon"
- title="Not confidential"
- >
- <gl-icon-stub
- name="eye"
- size="16"
- />
- </div>
-
- <div
- class="title hide-collapsed"
- >
-
- Confidentiality
-
- <a
- class="float-right confidential-edit"
- data-track-event="click_edit_button"
- data-track-label="right_sidebar"
- data-track-property="confidentiality"
- href="#"
- >
- Edit
- </a>
- </div>
-
- <div
- class="value sidebar-item-value hide-collapsed"
- >
- <!---->
-
- <div
- class="no-value sidebar-item-value"
- data-testid="not-confidential"
- >
- <gl-icon-stub
- class="sidebar-item-icon inline"
- name="eye"
- size="16"
- />
-
- Not confidential
-
- </div>
- </div>
-</div>
-`;
-
-exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = false 1`] = `
-<div
- class="block issuable-sidebar-item confidentiality"
- iid=""
->
- <div
- class="sidebar-collapsed-icon"
- title="Confidential"
- >
- <gl-icon-stub
- name="eye-slash"
- size="16"
- />
- </div>
-
- <div
- class="title hide-collapsed"
- >
-
- Confidentiality
-
- <!---->
- </div>
-
- <div
- class="value sidebar-item-value hide-collapsed"
- >
- <!---->
-
- <div
- class="value sidebar-item-value hide-collapsed"
- >
- <gl-icon-stub
- class="sidebar-item-icon inline is-active"
- name="eye-slash"
- size="16"
- />
-
- This issue is confidential
-
- </div>
- </div>
-</div>
-`;
-
-exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = true 1`] = `
-<div
- class="block issuable-sidebar-item confidentiality"
- iid=""
->
- <div
- class="sidebar-collapsed-icon"
- title="Confidential"
- >
- <gl-icon-stub
- name="eye-slash"
- size="16"
- />
- </div>
-
- <div
- class="title hide-collapsed"
- >
-
- Confidentiality
-
- <a
- class="float-right confidential-edit"
- data-track-event="click_edit_button"
- data-track-label="right_sidebar"
- data-track-property="confidentiality"
- href="#"
- >
- Edit
- </a>
- </div>
-
- <div
- class="value sidebar-item-value hide-collapsed"
- >
- <!---->
-
- <div
- class="value sidebar-item-value hide-collapsed"
- >
- <gl-icon-stub
- class="sidebar-item-icon inline is-active"
- name="eye-slash"
- size="16"
- />
-
- This issue is confidential
-
- </div>
- </div>
-</div>
-`;
diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js
new file mode 100644
index 00000000000..8844e1626cd
--- /dev/null
+++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_content_spec.js
@@ -0,0 +1,71 @@
+import { GlIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import SidebarConfidentialityContent from '~/sidebar/components/confidential/sidebar_confidentiality_content.vue';
+
+describe('Sidebar Confidentiality Content', () => {
+ let wrapper;
+
+ const findIcon = () => wrapper.findComponent(GlIcon);
+ const findText = () => wrapper.find('[data-testid="confidential-text"]');
+ const findCollapsedIcon = () => wrapper.find('[data-testid="sidebar-collapsed-icon"]');
+
+ const createComponent = ({ confidential = false, issuableType = 'issue' } = {}) => {
+ wrapper = shallowMount(SidebarConfidentialityContent, {
+ propsData: {
+ confidential,
+ issuableType,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('emits `expandSidebar` event on collapsed icon click', () => {
+ createComponent();
+ findCollapsedIcon().trigger('click');
+
+ expect(wrapper.emitted('expandSidebar')).toHaveLength(1);
+ });
+
+ describe('when issue is non-confidential', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders a non-confidential icon', () => {
+ expect(findIcon().props('name')).toBe('eye');
+ });
+
+ it('does not add `is-active` class to the icon', () => {
+ expect(findIcon().classes()).not.toContain('is-active');
+ });
+
+ it('displays a non-confidential text', () => {
+ expect(findText().text()).toBe('Not confidential');
+ });
+ });
+
+ describe('when issue is confidential', () => {
+ it('renders a confidential icon', () => {
+ createComponent({ confidential: true });
+ expect(findIcon().props('name')).toBe('eye-slash');
+ });
+
+ it('adds `is-active` class to the icon', () => {
+ createComponent({ confidential: true });
+ expect(findIcon().classes()).toContain('is-active');
+ });
+
+ it('displays a correct confidential text for issue', () => {
+ createComponent({ confidential: true });
+ expect(findText().text()).toBe('This issue is confidential');
+ });
+
+ it('displays a correct confidential text for epic', () => {
+ createComponent({ confidential: true, issuableType: 'epic' });
+ expect(findText().text()).toBe('This epic is confidential');
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js
new file mode 100644
index 00000000000..d5e6310ed38
--- /dev/null
+++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js
@@ -0,0 +1,173 @@
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+import SidebarConfidentialityForm from '~/sidebar/components/confidential/sidebar_confidentiality_form.vue';
+import { confidentialityQueries } from '~/sidebar/constants';
+
+jest.mock('~/flash');
+
+describe('Sidebar Confidentiality Form', () => {
+ let wrapper;
+
+ const findWarningMessage = () => wrapper.find(`[data-testid="warning-message"]`);
+ const findConfidentialToggle = () => wrapper.find(`[data-testid="confidential-toggle"]`);
+ const findCancelButton = () => wrapper.find(`[data-testid="confidential-cancel"]`);
+
+ const createComponent = ({
+ props = {},
+ mutate = jest.fn().mockResolvedValue('Success'),
+ } = {}) => {
+ wrapper = shallowMount(SidebarConfidentialityForm, {
+ provide: {
+ fullPath: 'group/project',
+ iid: '1',
+ },
+ propsData: {
+ confidential: false,
+ issuableType: 'issue',
+ ...props,
+ },
+ mocks: {
+ $apollo: {
+ mutate,
+ },
+ },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('emits a `closeForm` event when Cancel button is clicked', () => {
+ createComponent();
+ findCancelButton().vm.$emit('click');
+
+ expect(wrapper.emitted().closeForm).toHaveLength(1);
+ });
+
+ it('renders a loading state after clicking on turn on/off button', async () => {
+ createComponent();
+ findConfidentialToggle().vm.$emit('click', new MouseEvent('click'));
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled();
+ await nextTick();
+ expect(findConfidentialToggle().props('loading')).toBe(true);
+ });
+
+ it('creates a flash if mutation is rejected', async () => {
+ createComponent({ mutate: jest.fn().mockRejectedValue('Error!') });
+ findConfidentialToggle().vm.$emit('click', new MouseEvent('click'));
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'Something went wrong while setting issue confidentiality.',
+ });
+ });
+
+ it('creates a flash if mutation contains errors', async () => {
+ createComponent({
+ mutate: jest.fn().mockResolvedValue({
+ data: { issuableSetConfidential: { errors: ['Houston, we have a problem!'] } },
+ }),
+ });
+ findConfidentialToggle().vm.$emit('click', new MouseEvent('click'));
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'Houston, we have a problem!',
+ });
+ });
+
+ describe('when issue is not confidential', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders a message about making an issue confidential', () => {
+ expect(findWarningMessage().text()).toBe(
+ 'You are going to turn on confidentiality. Only team members with at least Reporter access will be able to see and leave comments on the issue.',
+ );
+ });
+
+ it('has a `Turn on` button text', () => {
+ expect(findConfidentialToggle().text()).toBe('Turn on');
+ });
+
+ it('calls a mutation to set confidential to true on button click', () => {
+ findConfidentialToggle().vm.$emit('click', new MouseEvent('click'));
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: confidentialityQueries[wrapper.vm.issuableType].mutation,
+ variables: {
+ input: {
+ confidential: true,
+ iid: '1',
+ projectPath: 'group/project',
+ },
+ },
+ });
+ });
+ });
+
+ describe('when issue is confidential', () => {
+ beforeEach(() => {
+ createComponent({ props: { confidential: true } });
+ });
+
+ it('renders a message about making an issue non-confidential', () => {
+ expect(findWarningMessage().text()).toBe(
+ 'You are going to turn off the confidentiality. This means everyone will be able to see and leave a comment on this issue.',
+ );
+ });
+
+ it('has a `Turn off` button text', () => {
+ expect(findConfidentialToggle().text()).toBe('Turn off');
+ });
+
+ it('calls a mutation to set confidential to false on button click', () => {
+ findConfidentialToggle().vm.$emit('click', new MouseEvent('click'));
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: confidentialityQueries[wrapper.vm.issuableType].mutation,
+ variables: {
+ input: {
+ confidential: false,
+ iid: '1',
+ projectPath: 'group/project',
+ },
+ },
+ });
+ });
+ });
+
+ describe('when issuable type is `epic`', () => {
+ beforeEach(() => {
+ createComponent({ props: { confidential: true, issuableType: 'epic' } });
+ });
+
+ it('renders a message about making an epic non-confidential', () => {
+ expect(findWarningMessage().text()).toBe(
+ 'You are going to turn off the confidentiality. This means everyone will be able to see and leave a comment on this epic.',
+ );
+ });
+
+ it('calls a mutation to set epic confidentiality with correct parameters', () => {
+ findConfidentialToggle().vm.$emit('click', new MouseEvent('click'));
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: confidentialityQueries[wrapper.vm.issuableType].mutation,
+ variables: {
+ input: {
+ confidential: false,
+ iid: '1',
+ groupPath: 'group/project',
+ },
+ },
+ });
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js
new file mode 100644
index 00000000000..20a5be9b518
--- /dev/null
+++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_widget_spec.js
@@ -0,0 +1,159 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { nextTick } 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 SidebarConfidentialityContent from '~/sidebar/components/confidential/sidebar_confidentiality_content.vue';
+import SidebarConfidentialityForm from '~/sidebar/components/confidential/sidebar_confidentiality_form.vue';
+import SidebarConfidentialityWidget, {
+ confidentialWidget,
+} from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
+import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql';
+import { issueConfidentialityResponse } from '../../mock_data';
+
+jest.mock('~/flash');
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+describe('Sidebar Confidentiality Widget', () => {
+ let wrapper;
+ let fakeApollo;
+
+ const findEditableItem = () => wrapper.findComponent(SidebarEditableItem);
+ const findConfidentialityForm = () => wrapper.findComponent(SidebarConfidentialityForm);
+ const findConfidentialityContent = () => wrapper.findComponent(SidebarConfidentialityContent);
+
+ const createComponent = ({
+ confidentialQueryHandler = jest.fn().mockResolvedValue(issueConfidentialityResponse()),
+ } = {}) => {
+ fakeApollo = createMockApollo([[issueConfidentialQuery, confidentialQueryHandler]]);
+
+ wrapper = shallowMount(SidebarConfidentialityWidget, {
+ localVue,
+ apolloProvider: fakeApollo,
+ provide: {
+ fullPath: 'group/project',
+ iid: '1',
+ canUpdate: true,
+ },
+ propsData: {
+ issuableType: 'issue',
+ },
+ stubs: {
+ SidebarEditableItem,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ fakeApollo = null;
+ });
+
+ it('passes a `loading` prop as true to editable item when query is loading', () => {
+ createComponent();
+
+ expect(findEditableItem().props('loading')).toBe(true);
+ });
+
+ it('exposes a method via external observable', () => {
+ createComponent();
+
+ expect(confidentialWidget.setConfidentiality).toEqual(wrapper.vm.setConfidentiality);
+ });
+
+ describe('when issue is not confidential', () => {
+ beforeEach(async () => {
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('passes a `loading` prop as false to editable item', () => {
+ expect(findEditableItem().props('loading')).toBe(false);
+ });
+
+ it('passes false to `confidential` prop of child components', () => {
+ expect(findConfidentialityForm().props('confidential')).toBe(false);
+ expect(findConfidentialityContent().props('confidential')).toBe(false);
+ });
+
+ it('changes confidentiality to true after setConfidentiality is called', async () => {
+ confidentialWidget.setConfidentiality();
+ await nextTick();
+ expect(findConfidentialityForm().props('confidential')).toBe(true);
+ expect(findConfidentialityContent().props('confidential')).toBe(true);
+ });
+
+ it('emits `confidentialityUpdated` event with a `false` payload', () => {
+ expect(wrapper.emitted('confidentialityUpdated')).toEqual([[false]]);
+ });
+ });
+
+ describe('when issue is confidential', () => {
+ beforeEach(async () => {
+ createComponent({
+ confidentialQueryHandler: jest.fn().mockResolvedValue(issueConfidentialityResponse(true)),
+ });
+ await waitForPromises();
+ });
+
+ it('passes a `loading` prop as false to editable item', () => {
+ expect(findEditableItem().props('loading')).toBe(false);
+ });
+
+ it('passes false to `confidential` prop of child components', () => {
+ expect(findConfidentialityForm().props('confidential')).toBe(true);
+ expect(findConfidentialityContent().props('confidential')).toBe(true);
+ });
+
+ it('changes confidentiality to false after setConfidentiality is called', async () => {
+ confidentialWidget.setConfidentiality();
+ await nextTick();
+ expect(findConfidentialityForm().props('confidential')).toBe(false);
+ expect(findConfidentialityContent().props('confidential')).toBe(false);
+ });
+
+ it('emits `confidentialityUpdated` event with a `true` payload', () => {
+ expect(wrapper.emitted('confidentialityUpdated')).toEqual([[true]]);
+ });
+ });
+
+ it('displays a flash message when query is rejected', async () => {
+ createComponent({
+ confidentialQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'),
+ });
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalled();
+ });
+
+ it('closes the form and dispatches an event when `closeForm` is emitted', async () => {
+ createComponent();
+ const el = wrapper.vm.$el;
+ jest.spyOn(el, 'dispatchEvent');
+
+ await waitForPromises();
+ wrapper.vm.$refs.editable.expand();
+ await nextTick();
+
+ expect(findConfidentialityForm().isVisible()).toBe(true);
+
+ findConfidentialityForm().vm.$emit('closeForm');
+ await nextTick();
+ expect(findConfidentialityForm().isVisible()).toBe(false);
+
+ expect(el.dispatchEvent).toHaveBeenCalled();
+ expect(wrapper.emitted('closeForm')).toHaveLength(1);
+ });
+
+ it('emits `expandSidebar` event when it is emitted from child component', async () => {
+ createComponent();
+ await waitForPromises();
+ findConfidentialityContent().vm.$emit('expandSidebar');
+
+ expect(wrapper.emitted('expandSidebar')).toHaveLength(1);
+ });
+});
diff --git a/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js b/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js
new file mode 100644
index 00000000000..1dbb7702a15
--- /dev/null
+++ b/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js
@@ -0,0 +1,93 @@
+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 waitForPromises from 'helpers/wait_for_promises';
+import { IssuableType } from '~/issue_show/constants';
+import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue';
+import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
+import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { issueReferenceResponse } from '../../mock_data';
+
+describe('Sidebar Reference Widget', () => {
+ let wrapper;
+ let fakeApollo;
+ const referenceText = 'reference';
+
+ const createComponent = ({
+ issuableType,
+ referenceQuery = issueReferenceQuery,
+ referenceQueryHandler = jest.fn().mockResolvedValue(issueReferenceResponse(referenceText)),
+ } = {}) => {
+ Vue.use(VueApollo);
+
+ fakeApollo = createMockApollo([[referenceQuery, referenceQueryHandler]]);
+
+ wrapper = shallowMount(SidebarReferenceWidget, {
+ apolloProvider: fakeApollo,
+ provide: {
+ fullPath: 'group/project',
+ iid: '1',
+ },
+ propsData: {
+ issuableType,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe.each([
+ [IssuableType.Issue, issueReferenceQuery],
+ [IssuableType.MergeRequest, mergeRequestReferenceQuery],
+ ])('when issuableType is %s', (issuableType, referenceQuery) => {
+ it('displays the reference text', async () => {
+ createComponent({
+ issuableType,
+ referenceQuery,
+ });
+
+ await waitForPromises();
+
+ expect(wrapper.text()).toContain(referenceText);
+ });
+
+ it('displays loading icon while fetching and hides clipboard icon', async () => {
+ createComponent({
+ issuableType,
+ referenceQuery,
+ });
+
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ expect(wrapper.find(ClipboardButton).exists()).toBe(false);
+ });
+
+ it('calls createFlash with correct parameters', async () => {
+ const mockError = new Error('mayday');
+
+ createComponent({
+ issuableType,
+ referenceQuery,
+ referenceQueryHandler: jest.fn().mockRejectedValue(mockError),
+ });
+
+ await waitForPromises();
+
+ const [
+ [
+ {
+ message,
+ error: { networkError },
+ },
+ ],
+ ] = wrapper.emitted('fetch-error');
+ expect(message).toBe('An error occurred while fetching reference');
+ expect(networkError).toEqual(mockError);
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js
index 7c67149b517..9f6878db785 100644
--- a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js
+++ b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js
@@ -7,6 +7,8 @@ import userDataMock from '../../user_data_mock';
describe('UncollapsedReviewerList component', () => {
let wrapper;
+ const reviewerApprovalIcons = () => wrapper.findAll('[data-testid="re-approved"]');
+
function createComponent(props = {}) {
const propsData = {
users: [],
@@ -58,19 +60,29 @@ describe('UncollapsedReviewerList component', () => {
const user = userDataMock();
createComponent({
- users: [user, { ...user, id: 2, username: 'hello-world' }],
+ users: [user, { ...user, id: 2, username: 'hello-world', approved: true }],
});
});
- it('only has one user', () => {
+ it('has both users', () => {
expect(wrapper.findAll(ReviewerAvatarLink).length).toBe(2);
});
- it('shows one user with avatar, username and author name', () => {
+ it('shows both users with avatar, username and author name', () => {
expect(wrapper.text()).toContain(`@root`);
expect(wrapper.text()).toContain(`@hello-world`);
});
+ it('renders approval icon', () => {
+ expect(reviewerApprovalIcons().length).toBe(1);
+ });
+
+ it('shows that hello-world approved', () => {
+ const icon = reviewerApprovalIcons().at(0);
+
+ expect(icon.attributes('title')).toEqual('Approved by @hello-world');
+ });
+
it('renders re-request loading icon', async () => {
await wrapper.setData({ loadingStates: { 2: 'loading' } });
diff --git a/spec/frontend/sidebar/confidential/__snapshots__/edit_form_spec.js.snap b/spec/frontend/sidebar/confidential/__snapshots__/edit_form_spec.js.snap
deleted file mode 100644
index d33f6c7f389..00000000000
--- a/spec/frontend/sidebar/confidential/__snapshots__/edit_form_spec.js.snap
+++ /dev/null
@@ -1,50 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Edit Form Dropdown when confidential renders on or off text based on confidentiality 1`] = `
-<div
- class="dropdown show"
- toggleform="function () {}"
- updateconfidentialattribute="function () {}"
->
- <div
- class="dropdown-menu sidebar-item-warning-message"
- >
- <div>
- <p>
- <gl-sprintf-stub
- message="You are going to turn off the confidentiality. This means %{strongStart}everyone%{strongEnd} will be able to see and leave a comment on this %{issuableType}."
- />
- </p>
-
- <edit-form-buttons-stub
- confidential="true"
- fullpath=""
- />
- </div>
- </div>
-</div>
-`;
-
-exports[`Edit Form Dropdown when not confidential renders "You are going to turn on the confidentiality." in the 1`] = `
-<div
- class="dropdown show"
- toggleform="function () {}"
- updateconfidentialattribute="function () {}"
->
- <div
- class="dropdown-menu sidebar-item-warning-message"
- >
- <div>
- <p>
- <gl-sprintf-stub
- message="You are going to turn on the confidentiality. This means that only team members with %{strongStart}at least Reporter access%{strongEnd} are able to see and leave comments on the %{issuableType}."
- />
- </p>
-
- <edit-form-buttons-stub
- fullpath=""
- />
- </div>
- </div>
-</div>
-`;
diff --git a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js
deleted file mode 100644
index 427e3a89c29..00000000000
--- a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js
+++ /dev/null
@@ -1,146 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import waitForPromises from 'helpers/wait_for_promises';
-import { deprecatedCreateFlash as flash } from '~/flash';
-import createStore from '~/notes/stores';
-import EditFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue';
-import eventHub from '~/sidebar/event_hub';
-
-jest.mock('~/sidebar/event_hub', () => ({ $emit: jest.fn() }));
-jest.mock('~/flash');
-
-describe('Edit Form Buttons', () => {
- let wrapper;
- let store;
- const findConfidentialToggle = () => wrapper.find('[data-testid="confidential-toggle"]');
-
- const createComponent = ({ props = {}, data = {}, resolved = true }) => {
- store = createStore();
- if (resolved) {
- jest.spyOn(store, 'dispatch').mockResolvedValue();
- } else {
- jest.spyOn(store, 'dispatch').mockRejectedValue();
- }
-
- wrapper = shallowMount(EditFormButtons, {
- propsData: {
- fullPath: '',
- ...props,
- },
- data() {
- return {
- isLoading: true,
- ...data,
- };
- },
- store,
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- describe('when isLoading', () => {
- beforeEach(() => {
- createComponent({
- props: {
- confidential: false,
- },
- });
- });
-
- it('renders "Applying" in the toggle button', () => {
- expect(findConfidentialToggle().text()).toBe('Applying');
- });
-
- it('disables the toggle button', () => {
- expect(findConfidentialToggle().props('disabled')).toBe(true);
- });
-
- it('sets loading on the toggle button', () => {
- expect(findConfidentialToggle().props('loading')).toBe(true);
- });
- });
-
- describe('when not confidential', () => {
- it('renders Turn On in the toggle button', () => {
- createComponent({
- data: {
- isLoading: false,
- },
- props: {
- confidential: false,
- },
- });
-
- expect(findConfidentialToggle().text()).toBe('Turn On');
- });
- });
-
- describe('when confidential', () => {
- beforeEach(() => {
- createComponent({
- data: {
- isLoading: false,
- },
- props: {
- confidential: true,
- },
- });
- });
-
- it('renders on or off text based on confidentiality', () => {
- expect(findConfidentialToggle().text()).toBe('Turn Off');
- });
- });
-
- describe('when succeeds', () => {
- beforeEach(() => {
- createComponent({ data: { isLoading: false }, props: { confidential: true } });
- findConfidentialToggle().vm.$emit('click', new Event('click'));
- });
-
- it('dispatches the correct action', () => {
- expect(store.dispatch).toHaveBeenCalledWith('updateConfidentialityOnIssuable', {
- confidential: false,
- fullPath: '',
- });
- });
-
- it('resets loading on the toggle button', () => {
- return waitForPromises().then(() => {
- expect(findConfidentialToggle().props('loading')).toBe(false);
- });
- });
-
- it('emits close form', () => {
- return waitForPromises().then(() => {
- expect(eventHub.$emit).toHaveBeenCalledWith('closeConfidentialityForm');
- });
- });
-
- it('emits updateOnConfidentiality event', () => {
- return waitForPromises().then(() => {
- expect(eventHub.$emit).toHaveBeenCalledWith('updateIssuableConfidentiality', false);
- });
- });
- });
-
- describe('when fails', () => {
- beforeEach(() => {
- createComponent({
- data: { isLoading: false },
- props: { confidential: true },
- resolved: false,
- });
- findConfidentialToggle().vm.$emit('click', new Event('click'));
- });
-
- it('calls flash with the correct message', () => {
- expect(flash).toHaveBeenCalledWith(
- 'Something went wrong trying to change the confidentiality of this issue',
- );
- });
- });
-});
diff --git a/spec/frontend/sidebar/confidential/edit_form_spec.js b/spec/frontend/sidebar/confidential/edit_form_spec.js
deleted file mode 100644
index 6b571df10ae..00000000000
--- a/spec/frontend/sidebar/confidential/edit_form_spec.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import EditForm from '~/sidebar/components/confidential/edit_form.vue';
-
-describe('Edit Form Dropdown', () => {
- let wrapper;
- const toggleForm = () => {};
- const updateConfidentialAttribute = () => {};
-
- const createComponent = (props) => {
- wrapper = shallowMount(EditForm, {
- propsData: {
- ...props,
- isLoading: false,
- fullPath: '',
- issuableType: 'issue',
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- describe('when not confidential', () => {
- it('renders "You are going to turn on the confidentiality." in the ', () => {
- createComponent({
- confidential: false,
- toggleForm,
- updateConfidentialAttribute,
- });
-
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-
- describe('when confidential', () => {
- it('renders on or off text based on confidentiality', () => {
- createComponent({
- confidential: true,
- toggleForm,
- updateConfidentialAttribute,
- });
-
- expect(wrapper.element).toMatchSnapshot();
- });
- });
-});
diff --git a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js
deleted file mode 100644
index 93a6401b1fc..00000000000
--- a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js
+++ /dev/null
@@ -1,159 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
-import { mockTracking, triggerEvent } from 'helpers/tracking_helper';
-import createStore from '~/notes/stores';
-import * as types from '~/notes/stores/mutation_types';
-import ConfidentialIssueSidebar from '~/sidebar/components/confidential/confidential_issue_sidebar.vue';
-import EditForm from '~/sidebar/components/confidential/edit_form.vue';
-
-jest.mock('~/flash');
-jest.mock('~/sidebar/services/sidebar_service');
-
-describe('Confidential Issue Sidebar Block', () => {
- useMockLocationHelper();
-
- let wrapper;
- const mutate = jest
- .fn()
- .mockResolvedValue({ data: { issueSetConfidential: { issue: { confidential: true } } } });
-
- const createComponent = ({ propsData, data = {} }) => {
- const store = createStore();
- wrapper = shallowMount(ConfidentialIssueSidebar, {
- store,
- data() {
- return data;
- },
- propsData: {
- iid: '',
- fullPath: '',
- ...propsData,
- },
- mocks: {
- $apollo: {
- mutate,
- },
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it.each`
- confidential | isEditable
- ${false} | ${false}
- ${false} | ${true}
- ${true} | ${false}
- ${true} | ${true}
- `(
- 'renders for confidential = $confidential and isEditable = $isEditable',
- ({ confidential, isEditable }) => {
- createComponent({
- propsData: {
- isEditable,
- },
- });
- wrapper.vm.$store.state.noteableData.confidential = confidential;
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
- });
- },
- );
-
- describe('if editable', () => {
- beforeEach(() => {
- createComponent({
- propsData: {
- isEditable: true,
- },
- });
- wrapper.vm.$store.state.noteableData.confidential = true;
- });
-
- it('displays the edit form when editable', () => {
- wrapper.setData({ edit: false });
-
- return wrapper.vm
- .$nextTick()
- .then(() => {
- wrapper.find({ ref: 'editLink' }).trigger('click');
- return wrapper.vm.$nextTick();
- })
- .then(() => {
- expect(wrapper.find(EditForm).exists()).toBe(true);
- });
- });
-
- it('displays the edit form when opened from collapsed state', () => {
- wrapper.setData({ edit: false });
-
- return wrapper.vm
- .$nextTick()
- .then(() => {
- wrapper.find({ ref: 'collapseIcon' }).trigger('click');
- return wrapper.vm.$nextTick();
- })
- .then(() => {
- expect(wrapper.find(EditForm).exists()).toBe(true);
- });
- });
-
- it('tracks the event when "Edit" is clicked', () => {
- const spy = mockTracking('_category_', wrapper.element, jest.spyOn);
-
- const editLink = wrapper.find({ ref: 'editLink' });
- triggerEvent(editLink.element);
-
- expect(spy).toHaveBeenCalledWith('_category_', 'click_edit_button', {
- label: 'right_sidebar',
- property: 'confidentiality',
- });
- });
- });
- describe('computed confidential', () => {
- beforeEach(() => {
- createComponent({
- propsData: {
- isEditable: true,
- },
- });
- });
-
- it('returns false when noteableData is not present', () => {
- wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, null);
-
- expect(wrapper.vm.confidential).toBe(false);
- });
-
- it('returns true when noteableData has confidential attr as true', () => {
- wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, {});
- wrapper.vm.$store.commit(types.SET_ISSUE_CONFIDENTIAL, true);
-
- expect(wrapper.vm.confidential).toBe(true);
- });
-
- it('returns false when noteableData has confidential attr as false', () => {
- wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, {});
- wrapper.vm.$store.commit(types.SET_ISSUE_CONFIDENTIAL, false);
-
- expect(wrapper.vm.confidential).toBe(false);
- });
-
- it('returns true when confidential attr is true', () => {
- wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, {});
- wrapper.vm.$store.commit(types.SET_ISSUE_CONFIDENTIAL, true);
-
- expect(wrapper.vm.confidential).toBe(true);
- });
-
- it('returns false when confidential attr is false', () => {
- wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, {});
- wrapper.vm.$store.commit(types.SET_ISSUE_CONFIDENTIAL, false);
-
- expect(wrapper.vm.confidential).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js
index 3dde40260eb..e751f1239c8 100644
--- a/spec/frontend/sidebar/mock_data.js
+++ b/spec/frontend/sidebar/mock_data.js
@@ -220,4 +220,29 @@ const mockData = {
},
};
+export const issueConfidentialityResponse = (confidential = false) => ({
+ data: {
+ workspace: {
+ __typename: 'Project',
+ issuable: {
+ __typename: 'Issue',
+ id: 'gid://gitlab/Issue/4',
+ confidential,
+ },
+ },
+ },
+});
+
+export const issueReferenceResponse = (reference) => ({
+ data: {
+ workspace: {
+ __typename: 'Project',
+ issuable: {
+ __typename: 'Issue',
+ id: 'gid://gitlab/Issue/4',
+ reference,
+ },
+ },
+ },
+});
export default mockData;
diff --git a/spec/frontend/sidebar/subscriptions_spec.js b/spec/frontend/sidebar/subscriptions_spec.js
index e7ae59e26cf..6ab8e1e0ebc 100644
--- a/spec/frontend/sidebar/subscriptions_spec.js
+++ b/spec/frontend/sidebar/subscriptions_spec.js
@@ -84,6 +84,15 @@ describe('Subscriptions', () => {
spy.mockRestore();
});
+ it('has visually hidden label', () => {
+ wrapper = mountComponent();
+
+ expect(findToggleButton().props()).toMatchObject({
+ label: 'Notifications',
+ labelPosition: 'hidden',
+ });
+ });
+
describe('given project emails are disabled', () => {
const subscribeDisabledDescription = 'Notifications have been disabled';
diff --git a/spec/frontend/sidebar/user_data_mock.js b/spec/frontend/sidebar/user_data_mock.js
index 41d0331f34a..7c11551b0be 100644
--- a/spec/frontend/sidebar/user_data_mock.js
+++ b/spec/frontend/sidebar/user_data_mock.js
@@ -10,4 +10,5 @@ export default () => ({
can_merge: true,
can_update_merge_request: true,
reviewed: true,
+ approved: false,
});
diff --git a/spec/frontend/single_file_diff_spec.js b/spec/frontend/single_file_diff_spec.js
new file mode 100644
index 00000000000..8718152655f
--- /dev/null
+++ b/spec/frontend/single_file_diff_spec.js
@@ -0,0 +1,96 @@
+import MockAdapter from 'axios-mock-adapter';
+import $ from 'jquery';
+import { setHTMLFixture } from 'helpers/fixtures';
+import axios from '~/lib/utils/axios_utils';
+import SingleFileDiff from '~/single_file_diff';
+
+describe('SingleFileDiff', () => {
+ let mock = new MockAdapter(axios);
+ const blobDiffPath = '/mock-path';
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet(blobDiffPath).replyOnce(200, { html: `<div class="diff-content">MOCKED</div>` });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('loads diff via axios exactly once for collapsed diffs', async () => {
+ setHTMLFixture(`
+ <div class="diff-file">
+ <div class="js-file-title">
+ MOCK TITLE
+ </div>
+
+ <div class="diff-content">
+ <div class="diff-viewer" data-type="simple">
+ <div
+ class="nothing-here-block diff-collapsed"
+ data-diff-for-path="${blobDiffPath}"
+ >
+ MOCK CONTENT
+ </div>
+ </div>
+ </div>
+ </div>
+`);
+
+ // Collapsed is the default state
+ const diff = new SingleFileDiff(document.querySelector('.diff-file'));
+ expect(diff.isOpen).toBe(false);
+ expect(diff.content).toBeNull();
+ expect(diff.diffForPath).toEqual(blobDiffPath);
+
+ // Opening for the first time
+ await diff.toggleDiff($(document.querySelector('.js-file-title')));
+ expect(diff.isOpen).toBe(true);
+ expect(diff.content).not.toBeNull();
+
+ // Collapsing again
+ await diff.toggleDiff($(document.querySelector('.js-file-title')));
+ expect(diff.isOpen).toBe(false);
+ expect(diff.content).not.toBeNull();
+
+ mock.onGet(blobDiffPath).replyOnce(400, '');
+
+ // Opening again
+ await diff.toggleDiff($(document.querySelector('.js-file-title')));
+ expect(diff.isOpen).toBe(true);
+ expect(diff.content).not.toBeNull();
+
+ expect(mock.history.get.length).toBe(1);
+ });
+
+ it('does not load diffs via axios for already expanded diffs', async () => {
+ setHTMLFixture(`
+ <div class="diff-file">
+ <div class="js-file-title">
+ MOCK TITLE
+ </div>
+
+ <div class="diff-content">
+ EXPANDED MOCK CONTENT
+ </div>
+ </div>
+`);
+
+ // Opened is the default state
+ const diff = new SingleFileDiff(document.querySelector('.diff-file'));
+ expect(diff.isOpen).toBe(true);
+ expect(diff.content).not.toBeNull();
+ expect(diff.diffForPath).toEqual(undefined);
+
+ // Collapsing for the first time
+ await diff.toggleDiff($(document.querySelector('.js-file-title')));
+ expect(diff.isOpen).toBe(false);
+ expect(diff.content).not.toBeNull();
+
+ // Opening again
+ await diff.toggleDiff($(document.querySelector('.js-file-title')));
+ expect(diff.isOpen).toBe(true);
+
+ expect(mock.history.get.length).toBe(0);
+ });
+});
diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap
index 8446f0f50c4..95da67c2bbf 100644
--- a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap
+++ b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap
@@ -46,6 +46,8 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] =
<span
class="font-weight-bold ml-1 js-visibility-option"
+ data-qa-selector="visibility_content"
+ data-qa-visibility="Private"
>
Private
</span>
@@ -65,6 +67,8 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] =
<span
class="font-weight-bold ml-1 js-visibility-option"
+ data-qa-selector="visibility_content"
+ data-qa-visibility="Internal"
>
Internal
</span>
@@ -84,6 +88,8 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] =
<span
class="font-weight-bold ml-1 js-visibility-option"
+ data-qa-selector="visibility_content"
+ data-qa-visibility="Public"
>
Public
</span>
diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js
index b6b29faef79..9b95ed6b816 100644
--- a/spec/frontend/test_setup.js
+++ b/spec/frontend/test_setup.js
@@ -44,11 +44,6 @@ Object.assign(global, {
getJSONFixture,
loadFixtures: loadHTMLFixture,
setFixtures: setHTMLFixture,
-
- // The following functions fill the fixtures cache in Karma.
- // This is not necessary in Jest because we make no Ajax request.
- loadJSONFixtures() {},
- preloadFixtures() {},
});
// custom-jquery-matchers was written for an old Jest version, we need to make it compatible
diff --git a/spec/frontend/tooltips/components/tooltips_spec.js b/spec/frontend/tooltips/components/tooltips_spec.js
index e21626456e2..c44918ceaf3 100644
--- a/spec/frontend/tooltips/components/tooltips_spec.js
+++ b/spec/frontend/tooltips/components/tooltips_spec.js
@@ -217,4 +217,14 @@ describe('tooltips/components/tooltips.vue', () => {
wrapper.destroy();
expect(observersCount()).toBe(0);
});
+
+ it('exposes hidden event', async () => {
+ buildWrapper();
+ wrapper.vm.addTooltips([createTooltipTarget()]);
+
+ await wrapper.vm.$nextTick();
+
+ wrapper.findComponent(GlTooltip).vm.$emit('hidden');
+ expect(wrapper.emitted('hidden')).toHaveLength(1);
+ });
});
diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js
index a516a4a8269..6a22de3be5c 100644
--- a/spec/frontend/tracking_spec.js
+++ b/spec/frontend/tracking_spec.js
@@ -1,5 +1,9 @@
import { setHTMLFixture } from 'helpers/fixtures';
-import Tracking, { initUserTracking, initDefaultTrackers } from '~/tracking';
+import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
+import { getExperimentData } from '~/experimentation/utils';
+import Tracking, { initUserTracking, initDefaultTrackers, STANDARD_CONTEXT } from '~/tracking';
+
+jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() }));
describe('Tracking', () => {
let snowplowSpy;
@@ -7,6 +11,8 @@ describe('Tracking', () => {
let trackLoadEventsSpy;
beforeEach(() => {
+ getExperimentData.mockReturnValue(undefined);
+
window.snowplow = window.snowplow || (() => {});
window.snowplowOptions = {
namespace: '_namespace_',
@@ -45,7 +51,7 @@ describe('Tracking', () => {
it('should activate features based on what has been enabled', () => {
initDefaultTrackers();
expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', 30, 30);
- expect(snowplowSpy).toHaveBeenCalledWith('trackPageView');
+ expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [STANDARD_CONTEXT]);
expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking');
expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking');
@@ -78,6 +84,34 @@ describe('Tracking', () => {
navigator.msDoNotTrack = undefined;
});
+ describe('builds the standard context', () => {
+ let standardContext;
+
+ beforeAll(async () => {
+ window.gl = window.gl || {};
+ window.gl.snowplowStandardContext = {
+ schema: 'iglu:com.gitlab/gitlab_standard',
+ data: {
+ environment: 'testing',
+ source: 'unknown',
+ },
+ };
+
+ jest.resetModules();
+
+ ({ STANDARD_CONTEXT: standardContext } = await import('~/tracking'));
+ });
+
+ it('uses server data', () => {
+ expect(standardContext.schema).toBe('iglu:com.gitlab/gitlab_standard');
+ expect(standardContext.data.environment).toBe('testing');
+ });
+
+ it('overrides schema source', () => {
+ expect(standardContext.data.source).toBe('gitlab-javascript');
+ });
+ });
+
it('tracks to snowplow (our current tracking system)', () => {
Tracking.event('_category_', '_eventName_', { label: '_label_' });
@@ -88,7 +122,7 @@ describe('Tracking', () => {
'_label_',
undefined,
undefined,
- undefined,
+ [STANDARD_CONTEXT],
);
});
@@ -121,6 +155,27 @@ describe('Tracking', () => {
});
});
+ describe('.flushPendingEvents', () => {
+ it('flushes any pending events', () => {
+ Tracking.initialized = false;
+ Tracking.event('_category_', '_eventName_', { label: '_label_' });
+
+ expect(snowplowSpy).not.toHaveBeenCalled();
+
+ Tracking.flushPendingEvents();
+
+ expect(snowplowSpy).toHaveBeenCalledWith(
+ 'trackStructEvent',
+ '_category_',
+ '_eventName_',
+ '_label_',
+ undefined,
+ undefined,
+ [STANDARD_CONTEXT],
+ );
+ });
+ });
+
describe('tracking interface events', () => {
let eventSpy;
@@ -134,6 +189,7 @@ describe('Tracking', () => {
<input class="dropdown" data-track-event="toggle_dropdown"/>
<div data-track-event="nested_event"><span class="nested"></span></div>
<input data-track-eventbogus="click_bogusinput" data-track-label="_label_" value="_value_"/>
+ <input data-track-event="click_input3" data-track-experiment="example" value="_value_"/>
`);
});
@@ -193,6 +249,22 @@ describe('Tracking', () => {
expect(eventSpy).toHaveBeenCalledWith('_category_', 'nested_event', {});
});
+
+ it('brings in experiment data if linked to an experiment', () => {
+ const mockExperimentData = {
+ variant: 'candidate',
+ experiment: 'repo_integrations_link',
+ key: '2bff73f6bb8cc11156c50a8ba66b9b8b',
+ };
+ getExperimentData.mockReturnValue(mockExperimentData);
+
+ document.querySelector('[data-track-event="click_input3"]').click();
+
+ expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input3', {
+ value: '_value_',
+ context: { schema: TRACKING_CONTEXT_SCHEMA, data: mockExperimentData },
+ });
+ });
});
describe('tracking page loaded events', () => {
@@ -235,21 +307,21 @@ describe('Tracking', () => {
describe('tracking mixin', () => {
describe('trackingOptions', () => {
- it('return the options defined on initialisation', () => {
+ it('returns the options defined on initialisation', () => {
const mixin = Tracking.mixin({ foo: 'bar' });
expect(mixin.computed.trackingOptions()).toEqual({ foo: 'bar' });
});
- it('local tracking value override and extend options', () => {
+ it('lets local tracking value override and extend options', () => {
const mixin = Tracking.mixin({ foo: 'bar' });
- // the value of this in the vue lifecyle is different, but this serve the tests purposes
+ // The value of this in the Vue lifecyle is different, but this serves the test's purposes
mixin.computed.tracking = { foo: 'baz', baz: 'bar' };
expect(mixin.computed.trackingOptions()).toEqual({ foo: 'baz', baz: 'bar' });
});
});
describe('trackingCategory', () => {
- it('return the category set in the component properties first', () => {
+ it('returns the category set in the component properties first', () => {
const mixin = Tracking.mixin({ category: 'foo' });
mixin.computed.tracking = {
category: 'bar',
@@ -257,12 +329,12 @@ describe('Tracking', () => {
expect(mixin.computed.trackingCategory()).toBe('bar');
});
- it('return the category set in the options', () => {
+ it('returns the category set in the options', () => {
const mixin = Tracking.mixin({ category: 'foo' });
expect(mixin.computed.trackingCategory()).toBe('foo');
});
- it('if no category is selected returns undefined', () => {
+ it('returns undefined if no category is selected', () => {
const mixin = Tracking.mixin();
expect(mixin.computed.trackingCategory()).toBe(undefined);
});
@@ -297,7 +369,7 @@ describe('Tracking', () => {
expect(eventSpy).toHaveBeenCalledWith(undefined, 'foo', {});
});
- it('give precedence to data for category and options', () => {
+ it('gives precedence to data for category and options', () => {
mixin.trackingCategory = mixin.trackingCategory();
mixin.trackingOptions = mixin.trackingOptions();
const data = { category: 'foo', label: 'baz' };
diff --git a/spec/frontend/user_popovers_spec.js b/spec/frontend/user_popovers_spec.js
index 7c9c3d69efa..745b66fd700 100644
--- a/spec/frontend/user_popovers_spec.js
+++ b/spec/frontend/user_popovers_spec.js
@@ -3,9 +3,21 @@ import initUserPopovers from '~/user_popovers';
describe('User Popovers', () => {
const fixtureTemplate = 'merge_requests/merge_request_with_mentions.html';
- preloadFixtures(fixtureTemplate);
const selector = '.js-user-link, .gfm-project_member';
+ const findFixtureLinks = () => {
+ return Array.from(document.querySelectorAll(selector)).filter(
+ ({ dataset }) => dataset.user || dataset.userId,
+ );
+ };
+ const createUserLink = () => {
+ const link = document.createElement('a');
+
+ link.classList.add('js-user-link');
+ link.setAttribute('data-user', '1');
+
+ return link;
+ };
const dummyUser = { name: 'root' };
const dummyUserStatus = { message: 'active' };
@@ -37,13 +49,20 @@ describe('User Popovers', () => {
});
it('initializes a popover for each user link with a user id', () => {
- const linksWithUsers = Array.from(document.querySelectorAll(selector)).filter(
- ({ dataset }) => dataset.user || dataset.userId,
- );
+ const linksWithUsers = findFixtureLinks();
expect(linksWithUsers.length).toBe(popovers.length);
});
+ it('adds popovers to user links added to the DOM tree after the initial call', async () => {
+ document.body.appendChild(createUserLink());
+ document.body.appendChild(createUserLink());
+
+ const linksWithUsers = findFixtureLinks();
+
+ expect(linksWithUsers.length).toBe(popovers.length + 2);
+ });
+
it('does not initialize the user popovers twice for the same element', () => {
const newPopovers = initUserPopovers(document.querySelectorAll(selector));
const samePopovers = popovers.every((popover, index) => newPopovers[index] === popover);
diff --git a/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js b/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js
index b2cc7d9be6b..e2386bc7f2b 100644
--- a/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js
+++ b/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js
@@ -48,7 +48,7 @@ describe('Merge Requests Artifacts list app', () => {
};
const findButtons = () => wrapper.findAll('button');
- const findTitle = () => wrapper.find('.js-title');
+ const findTitle = () => wrapper.find('[data-testid="mr-collapsible-title"]');
const findErrorMessage = () => wrapper.find('.js-error-state');
const findTableRows = () => wrapper.findAll('tbody tr');
diff --git a/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js
index 94d4cccab5f..1aeb080aa04 100644
--- a/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js
@@ -1,4 +1,4 @@
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MrCollapsibleSection from '~/vue_merge_request_widget/components/mr_collapsible_extension.vue';
@@ -15,12 +15,14 @@ describe('Merge Request Collapsible Extension', () => {
},
slots: {
default: '<div class="js-slot">Foo</div>',
+ header: '<span data-testid="collapsed-header">hello there</span>',
},
});
};
- const findTitle = () => wrapper.find('.js-title');
+ const findTitle = () => wrapper.find('[data-testid="mr-collapsible-title"]');
const findErrorMessage = () => wrapper.find('.js-error-state');
+ const findIcon = () => wrapper.find(GlIcon);
afterEach(() => {
wrapper.destroy();
@@ -35,8 +37,12 @@ describe('Merge Request Collapsible Extension', () => {
expect(findTitle().text()).toBe(data.title);
});
+ it('renders the header slot', () => {
+ expect(wrapper.find('[data-testid="collapsed-header"]').text()).toBe('hello there');
+ });
+
it('renders angle-right icon', () => {
- expect(wrapper.vm.arrowIconName).toBe('angle-right');
+ expect(findIcon().props('name')).toBe('angle-right');
});
describe('onClick', () => {
@@ -54,7 +60,7 @@ describe('Merge Request Collapsible Extension', () => {
});
it('renders angle-down icon', () => {
- expect(wrapper.vm.arrowIconName).toBe('angle-down');
+ expect(findIcon().props('name')).toBe('angle-down');
});
});
});
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_merge_help_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_merge_help_spec.js
deleted file mode 100644
index 53a74bf7456..00000000000
--- a/spec/frontend/vue_mr_widget/components/mr_widget_merge_help_spec.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import MergeHelpComponent from '~/vue_merge_request_widget/components/mr_widget_merge_help.vue';
-
-describe('MRWidgetMergeHelp', () => {
- let wrapper;
-
- const createComponent = ({ props = {} } = {}) => {
- wrapper = shallowMount(MergeHelpComponent, {
- propsData: {
- missingBranch: 'this-is-not-the-branch-you-are-looking-for',
- ...props,
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- describe('with missing branch', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders missing branch information', () => {
- expect(wrapper.find('.mr-widget-help').text()).toContain(
- 'If the this-is-not-the-branch-you-are-looking-for branch exists in your local repository',
- );
- });
- });
-
- describe('without missing branch', () => {
- beforeEach(() => {
- createComponent({
- props: { missingBranch: '' },
- });
- });
-
- it('renders information about how to merge manually', () => {
- expect(wrapper.find('.mr-widget-help').text()).toContain(
- 'You can merge this merge request manually',
- );
- });
- });
-});
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js
index 3baade5161e..5ec719b17d6 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js
@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import ArtifactsApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue';
+import DeploymentList from '~/vue_merge_request_widget/components/deployment/deployment_list.vue';
import MrWidgetPipeline from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue';
import MrWidgetPipelineContainer from '~/vue_merge_request_widget/components/mr_widget_pipeline_container.vue';
import { mockStore } from '../mock_data';
@@ -28,6 +29,8 @@ describe('MrWidgetPipelineContainer', () => {
wrapper.destroy();
});
+ const findDeploymentList = () => wrapper.findComponent(DeploymentList);
+
describe('when pre merge', () => {
beforeEach(() => {
factory();
@@ -55,6 +58,9 @@ describe('MrWidgetPipelineContainer', () => {
const deployments = wrapper.findAll('.mr-widget-extension .js-pre-deployment');
+ expect(findDeploymentList().exists()).toBe(true);
+ expect(findDeploymentList().props('deployments')).toBe(mockStore.deployments);
+
expect(deployments.wrappers.map((x) => x.props())).toEqual(expectedProps);
});
});
@@ -100,6 +106,8 @@ describe('MrWidgetPipelineContainer', () => {
const deployments = wrapper.findAll('.mr-widget-extension .js-post-deployment');
+ expect(findDeploymentList().exists()).toBe(true);
+ expect(findDeploymentList().props('deployments')).toBe(mockStore.postMergeDeployments);
expect(deployments.wrappers.map((x) => x.props())).toEqual(expectedProps);
});
});
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js
index b93236d4628..28492018600 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js
@@ -1,7 +1,8 @@
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
import { trimText } from 'helpers/text_helper';
-import PipelineStage from '~/pipelines/components/pipelines_list/stage.vue';
+import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
+import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue';
import PipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue';
import { SUCCESS } from '~/vue_merge_request_widget/constants';
import mockData from '../mock_data';
@@ -25,7 +26,7 @@ describe('MRWidgetPipeline', () => {
const findPipelineID = () => wrapper.find('[data-testid="pipeline-id"]');
const findPipelineInfoContainer = () => wrapper.find('[data-testid="pipeline-info-container"]');
const findCommitLink = () => wrapper.find('[data-testid="commit-link"]');
- const findPipelineGraph = () => wrapper.find('[data-testid="widget-mini-pipeline-graph"]');
+ const findPipelineMiniGraph = () => wrapper.find(PipelineMiniGraph);
const findAllPipelineStages = () => wrapper.findAll(PipelineStage);
const findPipelineCoverage = () => wrapper.find('[data-testid="pipeline-coverage"]');
const findPipelineCoverageDelta = () => wrapper.find('[data-testid="pipeline-coverage-delta"]');
@@ -35,7 +36,7 @@ describe('MRWidgetPipeline', () => {
wrapper.find('[data-testid="monitoring-pipeline-message"]');
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
- const createWrapper = (props, mountFn = shallowMount) => {
+ const createWrapper = (props = {}, mountFn = shallowMount) => {
wrapper = mountFn(PipelineComponent, {
propsData: {
...defaultProps,
@@ -65,10 +66,13 @@ describe('MRWidgetPipeline', () => {
describe('with a pipeline', () => {
beforeEach(() => {
- createWrapper({
- pipelineCoverageDelta: mockData.pipelineCoverageDelta,
- buildsWithCoverage: mockData.buildsWithCoverage,
- });
+ createWrapper(
+ {
+ pipelineCoverageDelta: mockData.pipelineCoverageDelta,
+ buildsWithCoverage: mockData.buildsWithCoverage,
+ },
+ mount,
+ );
});
it('should render pipeline ID', () => {
@@ -84,8 +88,8 @@ describe('MRWidgetPipeline', () => {
});
it('should render pipeline graph', () => {
- expect(findPipelineGraph().exists()).toBe(true);
- expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length);
+ expect(findPipelineMiniGraph().exists()).toBe(true);
+ expect(findAllPipelineStages()).toHaveLength(mockData.pipeline.details.stages.length);
});
describe('should render pipeline coverage information', () => {
@@ -136,7 +140,7 @@ describe('MRWidgetPipeline', () => {
const mockCopy = JSON.parse(JSON.stringify(mockData));
delete mockCopy.pipeline.commit;
- createWrapper({});
+ createWrapper({}, mount);
});
it('should render pipeline ID', () => {
@@ -147,9 +151,15 @@ describe('MRWidgetPipeline', () => {
expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label);
});
- it('should render pipeline graph', () => {
- expect(findPipelineGraph().exists()).toBe(true);
- expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length);
+ it('should render pipeline graph with correct styles', () => {
+ const stagesCount = mockData.pipeline.details.stages.length;
+
+ expect(findPipelineMiniGraph().exists()).toBe(true);
+ expect(findPipelineMiniGraph().findAll('.mr-widget-pipeline-stages')).toHaveLength(
+ stagesCount,
+ );
+
+ expect(findAllPipelineStages()).toHaveLength(stagesCount);
});
it('should render coverage information', () => {
@@ -181,7 +191,7 @@ describe('MRWidgetPipeline', () => {
});
it('should not render a pipeline graph', () => {
- expect(findPipelineGraph().exists()).toBe(false);
+ expect(findPipelineMiniGraph().exists()).toBe(false);
});
});
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js
index a33401c5ba9..a879b06e858 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_related_links_spec.js
@@ -1,85 +1,88 @@
-import Vue from 'vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import relatedLinksComponent from '~/vue_merge_request_widget/components/mr_widget_related_links.vue';
+import { shallowMount } from '@vue/test-utils';
+import RelatedLinks from '~/vue_merge_request_widget/components/mr_widget_related_links.vue';
describe('MRWidgetRelatedLinks', () => {
- let vm;
+ let wrapper;
- const createComponent = (data) => {
- const Component = Vue.extend(relatedLinksComponent);
-
- return mountComponent(Component, data);
+ const createComponent = (propsData = {}) => {
+ wrapper = shallowMount(RelatedLinks, { propsData });
};
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
describe('computed', () => {
describe('closesText', () => {
it('returns Closes text for open merge request', () => {
- vm = createComponent({ state: 'open', relatedLinks: {} });
+ createComponent({ state: 'open', relatedLinks: {} });
- expect(vm.closesText).toEqual('Closes');
+ expect(wrapper.vm.closesText).toBe('Closes');
});
it('returns correct text for closed merge request', () => {
- vm = createComponent({ state: 'closed', relatedLinks: {} });
+ createComponent({ state: 'closed', relatedLinks: {} });
- expect(vm.closesText).toEqual('Did not close');
+ expect(wrapper.vm.closesText).toBe('Did not close');
});
it('returns correct tense for merged request', () => {
- vm = createComponent({ state: 'merged', relatedLinks: {} });
+ createComponent({ state: 'merged', relatedLinks: {} });
- expect(vm.closesText).toEqual('Closed');
+ expect(wrapper.vm.closesText).toBe('Closed');
});
});
});
it('should have only have closing issues text', () => {
- vm = createComponent({
+ createComponent({
relatedLinks: {
closing: '<a href="#">#23</a> and <a>#42</a>',
},
});
- const content = vm.$el.textContent.replace(/\n(\s)+/g, ' ').trim();
+ const content = wrapper
+ .text()
+ .replace(/\n(\s)+/g, ' ')
+ .trim();
expect(content).toContain('Closes #23 and #42');
expect(content).not.toContain('Mentions');
});
it('should have only have mentioned issues text', () => {
- vm = createComponent({
+ createComponent({
relatedLinks: {
mentioned: '<a href="#">#7</a>',
},
});
- expect(vm.$el.innerText).toContain('Mentions #7');
- expect(vm.$el.innerText).not.toContain('Closes');
+ expect(wrapper.text().trim()).toContain('Mentions #7');
+ expect(wrapper.text().trim()).not.toContain('Closes');
});
it('should have closing and mentioned issues at the same time', () => {
- vm = createComponent({
+ createComponent({
relatedLinks: {
closing: '<a href="#">#7</a>',
mentioned: '<a href="#">#23</a> and <a>#42</a>',
},
});
- const content = vm.$el.textContent.replace(/\n(\s)+/g, ' ').trim();
+ const content = wrapper
+ .text()
+ .replace(/\n(\s)+/g, ' ')
+ .trim();
expect(content).toContain('Closes #7');
expect(content).toContain('Mentions #23 and #42');
});
it('should have assing issues link', () => {
- vm = createComponent({
+ createComponent({
relatedLinks: {
assignToMe: '<a href="#">Assign yourself to these issues</a>',
},
});
- expect(vm.$el.innerText).toContain('Assign yourself to these issues');
+ expect(wrapper.text().trim()).toContain('Assign yourself to these issues');
});
});
diff --git a/spec/frontend/vue_mr_widget/components/review_app_link_spec.js b/spec/frontend/vue_mr_widget/components/review_app_link_spec.js
index 81a52890db7..e393b56034d 100644
--- a/spec/frontend/vue_mr_widget/components/review_app_link_spec.js
+++ b/spec/frontend/vue_mr_widget/components/review_app_link_spec.js
@@ -1,10 +1,8 @@
-import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
import { mockTracking, triggerEvent } from 'helpers/tracking_helper';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import component from '~/vue_merge_request_widget/components/review_app_link.vue';
+import ReviewAppLink from '~/vue_merge_request_widget/components/review_app_link.vue';
describe('review app link', () => {
- const Component = Vue.extend(component);
const props = {
link: '/review',
cssClass: 'js-link',
@@ -13,37 +11,35 @@ describe('review app link', () => {
tooltip: '',
},
};
- let vm;
- let el;
+ let wrapper;
beforeEach(() => {
- vm = mountComponent(Component, props);
- el = vm.$el;
+ wrapper = shallowMount(ReviewAppLink, { propsData: props });
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
it('renders provided link as href attribute', () => {
- expect(el.getAttribute('href')).toEqual(props.link);
+ expect(wrapper.attributes('href')).toBe(props.link);
});
it('renders provided cssClass as class attribute', () => {
- expect(el.getAttribute('class')).toContain(props.cssClass);
+ expect(wrapper.classes('js-link')).toBe(true);
});
it('renders View app text', () => {
- expect(el.textContent.trim()).toEqual('View app');
+ expect(wrapper.text().trim()).toBe('View app');
});
it('renders svg icon', () => {
- expect(el.querySelector('svg')).not.toBeNull();
+ expect(wrapper.find('svg')).not.toBeNull();
});
it('tracks an event when clicked', () => {
- const spy = mockTracking('_category_', el, jest.spyOn);
- triggerEvent(el);
+ const spy = mockTracking('_category_', wrapper.element, jest.spyOn);
+ triggerEvent(wrapper.element);
expect(spy).toHaveBeenCalledWith('_category_', 'open_review_app', {
label: 'review_app',
diff --git a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap
index c425a3a86a9..e5862df5dda 100644
--- a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap
+++ b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap
@@ -16,6 +16,7 @@ exports[`MRWidgetAutoMergeEnabled when graphql is disabled template should have
>
<span
class="gl-mr-3"
+ data-qa-selector="merge_request_status_content"
>
<span
class="js-status-text-before-author"
@@ -107,6 +108,7 @@ exports[`MRWidgetAutoMergeEnabled when graphql is enabled template should have c
>
<span
class="gl-mr-3"
+ data-qa-selector="merge_request_status_content"
>
<span
class="js-status-text-before-author"
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js
index d3fc1e0e05b..dc2f227b29c 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js
@@ -1,36 +1,41 @@
-import { createLocalVue, shallowMount } from '@vue/test-utils';
-import $ from 'jquery';
+import { GlPopover } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import { removeBreakLine } from 'helpers/text_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import ConflictsComponent from '~/vue_merge_request_widget/components/states/mr_widget_conflicts.vue';
describe('MRWidgetConflicts', () => {
- let vm;
+ let wrapper;
let mergeRequestWidgetGraphql = null;
const path = '/conflicts';
- function createComponent(propsData = {}) {
- const localVue = createLocalVue();
+ const findPopover = () => wrapper.find(GlPopover);
+ const findResolveButton = () => wrapper.findByTestId('resolve-conflicts-button');
+ const findMergeLocalButton = () => wrapper.findByTestId('merge-locally-button');
- vm = shallowMount(localVue.extend(ConflictsComponent), {
- propsData,
- provide: {
- glFeatures: {
- mergeRequestWidgetGraphql,
+ function createComponent(propsData = {}) {
+ wrapper = extendedWrapper(
+ shallowMount(ConflictsComponent, {
+ propsData,
+ provide: {
+ glFeatures: {
+ mergeRequestWidgetGraphql,
+ },
},
- },
- mocks: {
- $apollo: {
- queries: {
- userPermissions: { loading: false },
- stateData: { loading: false },
+ mocks: {
+ $apollo: {
+ queries: {
+ userPermissions: { loading: false },
+ stateData: { loading: false },
+ },
},
},
- },
- });
+ }),
+ );
if (mergeRequestWidgetGraphql) {
- vm.setData({
+ wrapper.setData({
userPermissions: {
canMerge: propsData.mr.canMerge,
pushToSourceBranch: propsData.mr.canPushToSourceBranch,
@@ -42,16 +47,12 @@ describe('MRWidgetConflicts', () => {
});
}
- return vm.vm.$nextTick();
+ return wrapper.vm.$nextTick();
}
- beforeEach(() => {
- jest.spyOn($.fn, 'popover');
- });
-
afterEach(() => {
mergeRequestWidgetGraphql = null;
- vm.destroy();
+ wrapper.destroy();
});
[false, true].forEach((featureEnabled) => {
@@ -82,18 +83,16 @@ describe('MRWidgetConflicts', () => {
});
it('should tell you about conflicts without bothering other people', () => {
- expect(vm.text()).toContain('There are merge conflicts');
- expect(vm.text()).not.toContain('ask someone with write access');
+ expect(wrapper.text()).toContain('There are merge conflicts');
+ expect(wrapper.text()).not.toContain('ask someone with write access');
});
it('should not allow you to resolve the conflicts', () => {
- expect(vm.text()).not.toContain('Resolve conflicts');
+ expect(wrapper.text()).not.toContain('Resolve conflicts');
});
it('should have merge buttons', () => {
- const mergeLocallyButton = vm.find('.js-merge-locally-button');
-
- expect(mergeLocallyButton.text()).toContain('Merge locally');
+ expect(findMergeLocalButton().text()).toContain('Merge locally');
});
});
@@ -110,19 +109,17 @@ describe('MRWidgetConflicts', () => {
});
it('should tell you about conflicts', () => {
- expect(vm.text()).toContain('There are merge conflicts');
- expect(vm.text()).toContain('ask someone with write access');
+ expect(wrapper.text()).toContain('There are merge conflicts');
+ expect(wrapper.text()).toContain('ask someone with write access');
});
it('should allow you to resolve the conflicts', () => {
- const resolveButton = vm.find('.js-resolve-conflicts-button');
-
- expect(resolveButton.text()).toContain('Resolve conflicts');
- expect(resolveButton.attributes('href')).toEqual(path);
+ expect(findResolveButton().text()).toContain('Resolve conflicts');
+ expect(findResolveButton().attributes('href')).toEqual(path);
});
it('should not have merge buttons', () => {
- expect(vm.text()).not.toContain('Merge locally');
+ expect(wrapper.text()).not.toContain('Merge locally');
});
});
@@ -139,21 +136,17 @@ describe('MRWidgetConflicts', () => {
});
it('should tell you about conflicts without bothering other people', () => {
- expect(vm.text()).toContain('There are merge conflicts');
- expect(vm.text()).not.toContain('ask someone with write access');
+ expect(wrapper.text()).toContain('There are merge conflicts');
+ expect(wrapper.text()).not.toContain('ask someone with write access');
});
it('should allow you to resolve the conflicts', () => {
- const resolveButton = vm.find('.js-resolve-conflicts-button');
-
- expect(resolveButton.text()).toContain('Resolve conflicts');
- expect(resolveButton.attributes('href')).toEqual(path);
+ expect(findResolveButton().text()).toContain('Resolve conflicts');
+ expect(findResolveButton().attributes('href')).toEqual(path);
});
it('should have merge buttons', () => {
- const mergeLocallyButton = vm.find('.js-merge-locally-button');
-
- expect(mergeLocallyButton.text()).toContain('Merge locally');
+ expect(findMergeLocalButton().text()).toContain('Merge locally');
});
});
@@ -167,7 +160,7 @@ describe('MRWidgetConflicts', () => {
},
});
- expect(vm.text().trim().replace(/\s\s+/g, ' ')).toContain(
+ expect(wrapper.text().trim().replace(/\s\s+/g, ' ')).toContain(
'ask someone with write access',
);
});
@@ -181,8 +174,8 @@ describe('MRWidgetConflicts', () => {
},
});
- expect(vm.find('.js-resolve-conflicts-button').exists()).toBe(false);
- expect(vm.find('.js-merge-locally-button').exists()).toBe(false);
+ expect(findResolveButton().exists()).toBe(false);
+ expect(findMergeLocalButton().exists()).toBe(false);
});
it('should not have resolve button when no conflict resolution path', async () => {
@@ -194,7 +187,7 @@ describe('MRWidgetConflicts', () => {
},
});
- expect(vm.find('.js-resolve-conflicts-button').exists()).toBe(false);
+ expect(findResolveButton().exists()).toBe(false);
});
});
@@ -207,7 +200,7 @@ describe('MRWidgetConflicts', () => {
},
});
- expect(removeBreakLine(vm.text()).trim()).toContain(
+ expect(removeBreakLine(wrapper.text()).trim()).toContain(
'Fast-forward merge is not possible. To merge this request, first rebase locally.',
);
});
@@ -227,11 +220,11 @@ describe('MRWidgetConflicts', () => {
});
it('sets resolve button as disabled', () => {
- expect(vm.find('.js-resolve-conflicts-button').attributes('disabled')).toBe('true');
+ expect(findResolveButton().attributes('disabled')).toBe('true');
});
- it('renders popover', () => {
- expect($.fn.popover).toHaveBeenCalled();
+ it('shows the popover', () => {
+ expect(findPopover().exists()).toBe(true);
});
});
@@ -249,11 +242,11 @@ describe('MRWidgetConflicts', () => {
});
it('sets resolve button as disabled', () => {
- expect(vm.find('.js-resolve-conflicts-button').attributes('disabled')).toBe(undefined);
+ expect(findResolveButton().attributes('disabled')).toBe(undefined);
});
- it('renders popover', () => {
- expect($.fn.popover).not.toHaveBeenCalled();
+ it('does not show the popover', () => {
+ expect(findPopover().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js
index 222cb74cc66..b16fb5171e7 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_merging_spec.js
@@ -1,29 +1,30 @@
-import Vue from 'vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import mergingComponent from '~/vue_merge_request_widget/components/states/mr_widget_merging.vue';
+import { shallowMount } from '@vue/test-utils';
+import MrWidgetMerging from '~/vue_merge_request_widget/components/states/mr_widget_merging.vue';
describe('MRWidgetMerging', () => {
- let vm;
- beforeEach(() => {
- const Component = Vue.extend(mergingComponent);
+ let wrapper;
- vm = mountComponent(Component, {
- mr: {
- targetBranchPath: '/branch-path',
- targetBranch: 'branch',
+ beforeEach(() => {
+ wrapper = shallowMount(MrWidgetMerging, {
+ propsData: {
+ mr: {
+ targetBranchPath: '/branch-path',
+ targetBranch: 'branch',
+ },
},
});
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
it('renders information about merge request being merged', () => {
expect(
- vm.$el
- .querySelector('.media-body')
- .textContent.trim()
+ wrapper
+ .find('.media-body')
+ .text()
+ .trim()
.replace(/\s\s+/g, ' ')
.replace(/[\r\n]+/g, ' '),
).toContain('This merge request is in the process of being merged');
@@ -31,13 +32,14 @@ describe('MRWidgetMerging', () => {
it('renders branch information', () => {
expect(
- vm.$el
- .querySelector('.mr-info-list')
- .textContent.trim()
+ wrapper
+ .find('.mr-info-list')
+ .text()
+ .trim()
.replace(/\s\s+/g, ' ')
.replace(/[\r\n]+/g, ' '),
).toEqual('The changes will be merged into branch');
- expect(vm.$el.querySelector('a').getAttribute('href')).toEqual('/branch-path');
+ expect(wrapper.find('a').attributes('href')).toBe('/branch-path');
});
});
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js
index bd0bd36ebc2..2c04905d3a9 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js
@@ -14,20 +14,14 @@ describe('NothingToMerge', () => {
it('should have correct elements', () => {
expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy();
- expect(vm.$el.querySelector('a').href).toContain(newBlobPath);
- expect(vm.$el.innerText).toContain(
- "Currently there are no changes in this merge request's source branch",
- );
-
- expect(vm.$el.innerText.replace(/\s\s+/g, ' ')).toContain(
- 'Please push new commits or use a different branch.',
- );
+ expect(vm.$el.querySelector('[data-testid="createFileButton"]').href).toContain(newBlobPath);
+ expect(vm.$el.innerText).toContain('Use merge requests to propose changes to your project');
});
it('should not show new blob link if there is no link available', () => {
vm.mr.newBlobPath = null;
Vue.nextTick(() => {
- expect(vm.$el.querySelector('a')).toEqual(null);
+ expect(vm.$el.querySelector('[data-testid="createFileButton"]')).toEqual(null);
});
});
});
diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js
new file mode 100644
index 00000000000..dd0c483b28a
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/deployment/deployment_list_spec.js
@@ -0,0 +1,101 @@
+import { mount } from '@vue/test-utils';
+import { zip } from 'lodash';
+import { trimText } from 'helpers/text_helper';
+import Deployment from '~/vue_merge_request_widget/components/deployment/deployment.vue';
+import DeploymentList from '~/vue_merge_request_widget/components/deployment/deployment_list.vue';
+import MrCollapsibleExtension from '~/vue_merge_request_widget/components/mr_collapsible_extension.vue';
+import { mockStore } from '../mock_data';
+
+const DEFAULT_PROPS = {
+ showVisualReviewAppLink: false,
+ hasDeploymentMetrics: false,
+ deploymentClass: 'js-pre-deployment',
+};
+
+describe('~/vue_merge_request_widget/components/deployment/deployment_list.vue', () => {
+ let wrapper;
+ let propsData;
+
+ const factory = (props = {}) => {
+ propsData = {
+ ...DEFAULT_PROPS,
+ deployments: mockStore.deployments,
+ ...props,
+ };
+ wrapper = mount(DeploymentList, {
+ propsData,
+ });
+ };
+
+ afterEach(() => {
+ wrapper?.destroy?.();
+ wrapper = null;
+ });
+
+ describe('with few deployments', () => {
+ beforeEach(() => {
+ factory();
+ });
+
+ it('shows all deployments', () => {
+ const deploymentWrappers = wrapper.findAllComponents(Deployment);
+ expect(wrapper.findComponent(MrCollapsibleExtension).exists()).toBe(false);
+ expect(deploymentWrappers).toHaveLength(propsData.deployments.length);
+
+ zip(deploymentWrappers.wrappers, propsData.deployments).forEach(
+ ([deploymentWrapper, deployment]) => {
+ expect(deploymentWrapper.props('deployment')).toEqual(deployment);
+ expect(deploymentWrapper.props()).toMatchObject({
+ showVisualReviewApp: DEFAULT_PROPS.showVisualReviewAppLink,
+ showMetrics: DEFAULT_PROPS.hasDeploymentMetrics,
+ });
+ expect(deploymentWrapper.classes(DEFAULT_PROPS.deploymentClass)).toBe(true);
+ expect(deploymentWrapper.text()).toEqual(expect.any(String));
+ expect(deploymentWrapper.text()).not.toBe('');
+ },
+ );
+ });
+ });
+ describe('with many deployments', () => {
+ let deployments;
+ let collapsibleExtension;
+
+ beforeEach(() => {
+ deployments = [
+ ...mockStore.deployments,
+ ...mockStore.deployments.map((deployment) => ({
+ ...deployment,
+ id: deployment.id + mockStore.deployments.length,
+ })),
+ ];
+ factory({ deployments });
+
+ collapsibleExtension = wrapper.findComponent(MrCollapsibleExtension);
+ });
+
+ it('shows collapsed deployments', () => {
+ expect(collapsibleExtension.exists()).toBe(true);
+ expect(trimText(collapsibleExtension.text())).toBe(
+ `${deployments.length} environments impacted. View all environments.`,
+ );
+ });
+ it('shows all deployments on click', async () => {
+ await collapsibleExtension.find('button').trigger('click');
+ const deploymentWrappers = wrapper.findAllComponents(Deployment);
+ expect(deploymentWrappers).toHaveLength(deployments.length);
+
+ zip(deploymentWrappers.wrappers, propsData.deployments).forEach(
+ ([deploymentWrapper, deployment]) => {
+ expect(deploymentWrapper.props('deployment')).toEqual(deployment);
+ expect(deploymentWrapper.props()).toMatchObject({
+ showVisualReviewApp: DEFAULT_PROPS.showVisualReviewAppLink,
+ showMetrics: DEFAULT_PROPS.hasDeploymentMetrics,
+ });
+ expect(deploymentWrapper.classes(DEFAULT_PROPS.deploymentClass)).toBe(true);
+ expect(deploymentWrapper.text()).toEqual(expect.any(String));
+ expect(deploymentWrapper.text()).not.toBe('');
+ },
+ );
+ });
+ });
+});
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 7b020813bd5..c4962b608e1 100644
--- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
+++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
@@ -91,18 +91,6 @@ describe('MrWidgetOptions', () => {
});
});
- describe('shouldRenderMergeHelp', () => {
- it('should return false for the initial merged state', () => {
- expect(wrapper.vm.shouldRenderMergeHelp).toBeFalsy();
- });
-
- it('should return true for a state which requires help widget', () => {
- wrapper.vm.mr.state = 'conflicts';
-
- expect(wrapper.vm.shouldRenderMergeHelp).toBeTruthy();
- });
- });
-
describe('shouldRenderPipelines', () => {
it('should return true when hasCI is true', () => {
wrapper.vm.mr.hasCI = 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 ce410a8b3e7..68bcf1dc491 100644
--- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js
+++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js
@@ -89,7 +89,7 @@ describe('AlertDetails', () => {
const findIncidentCreationAlert = () => wrapper.findByTestId('incidentCreationError');
const findEnvironmentName = () => wrapper.findByTestId('environmentName');
const findEnvironmentPath = () => wrapper.findByTestId('environmentPath');
- const findDetailsTable = () => wrapper.find(AlertDetailsTable);
+ const findDetailsTable = () => wrapper.findComponent(AlertDetailsTable);
const findMetricsTab = () => wrapper.findByTestId('metrics');
describe('Alert details', () => {
@@ -188,27 +188,39 @@ describe('AlertDetails', () => {
});
expect(findMetricsTab().exists()).toBe(false);
});
+
+ it('should display "View incident" button that links the issues page when incident exists', () => {
+ const iid = '3';
+ mountComponent({
+ data: { alert: { ...mockAlert, issue: { iid } }, sidebarStatus: false },
+ provide: { isThreatMonitoringPage: true },
+ });
+
+ expect(findViewIncidentBtn().exists()).toBe(true);
+ expect(findViewIncidentBtn().attributes('href')).toBe(joinPaths(projectIssuesPath, iid));
+ expect(findCreateIncidentBtn().exists()).toBe(false);
+ });
});
describe('Create incident from alert', () => {
it('should display "View incident" button that links the incident page when incident exists', () => {
- const issueIid = '3';
+ const iid = '3';
mountComponent({
- data: { alert: { ...mockAlert, issueIid }, sidebarStatus: false },
+ data: { alert: { ...mockAlert, issue: { iid } }, sidebarStatus: false },
});
expect(findViewIncidentBtn().exists()).toBe(true);
expect(findViewIncidentBtn().attributes('href')).toBe(
- joinPaths(projectIssuesPath, issueIid),
+ joinPaths(projectIssuesPath, 'incident', iid),
);
expect(findCreateIncidentBtn().exists()).toBe(false);
});
it('should display "Create incident" button when incident doesn\'t exist yet', () => {
- const issueIid = null;
+ const issue = null;
mountComponent({
mountMethod: mount,
- data: { alert: { ...mockAlert, issueIid } },
+ data: { alert: { ...mockAlert, issue } },
});
return wrapper.vm.$nextTick().then(() => {
diff --git a/spec/frontend/vue_shared/alert_details/mocks/alerts.json b/spec/frontend/vue_shared/alert_details/mocks/alerts.json
index 5267a4fe50d..007557e234a 100644
--- a/spec/frontend/vue_shared/alert_details/mocks/alerts.json
+++ b/spec/frontend/vue_shared/alert_details/mocks/alerts.json
@@ -21,7 +21,7 @@
"endedAt": "2020-04-17T23:18:14.996Z",
"status": "ACKNOWLEDGED",
"assignees": { "nodes": [{ "username": "root", "avatarUrl": "/url", "name": "root" }] },
- "issueIid": "1",
+ "issue": { "state" : "closed", "iid": "1", "title": "My test issue" },
"notes": {
"nodes": [
{
diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js
index f34a2db0851..99bf0d84d0c 100644
--- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js
+++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js
@@ -88,7 +88,7 @@ describe('RelatedIssuableItem', () => {
const stateTitle = tokenState().attributes('title');
const formattedCreateDate = formatDate(props.createdAt);
- expect(stateTitle).toContain('<span class="bold">Opened</span>');
+ expect(stateTitle).toContain('<span class="bold">Created</span>');
expect(stateTitle).toContain(`<span class="text-tertiary">${formattedCreateDate}</span>`);
});
diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
index bf65adc866d..5364e2d5f52 100644
--- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
@@ -1,5 +1,6 @@
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import ApplySuggestion from '~/vue_shared/components/markdown/apply_suggestion.vue';
import SuggestionDiffHeader from '~/vue_shared/components/markdown/suggestion_diff_header.vue';
const DEFAULT_PROPS = {
@@ -38,7 +39,7 @@ describe('Suggestion Diff component', () => {
wrapper.destroy();
});
- const findApplyButton = () => wrapper.find('.js-apply-btn');
+ const findApplyButton = () => wrapper.find(ApplySuggestion);
const findApplyBatchButton = () => wrapper.find('.js-apply-batch-btn');
const findAddToBatchButton = () => wrapper.find('.js-add-to-batch-btn');
const findRemoveFromBatchButton = () => wrapper.find('.js-remove-from-batch-btn');
@@ -88,7 +89,7 @@ describe('Suggestion Diff component', () => {
beforeEach(() => {
createComponent();
- findApplyButton().vm.$emit('click');
+ findApplyButton().vm.$emit('apply');
});
it('emits apply', () => {
diff --git a/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js b/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js
index 99671f1ffb7..566ca1817f2 100644
--- a/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js
+++ b/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js
@@ -1,3 +1,4 @@
+import { GlDropdown } from '@gitlab/ui';
import { getByText } from '@testing-library/dom';
import { shallowMount } from '@vue/test-utils';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
@@ -25,6 +26,9 @@ describe('MultiSelectDropdown Component', () => {
slots: {
search: '<p>Search</p>',
},
+ stubs: {
+ GlDropdown,
+ },
});
expect(getByText(wrapper.element, 'Search')).toBeDefined();
});
diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
index da49778f216..30b7f0c2d28 100644
--- a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
+++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
@@ -2,20 +2,26 @@
exports[`Package code instruction multiline to match the snapshot 1`] = `
<div>
- <pre
- class="gl-font-monospace"
- data-testid="multiline-instruction"
+ <label
+ for="instruction-input_3"
>
- this is some
+ foo_label
+ </label>
+
+ <div>
+ <pre
+ class="gl-font-monospace"
+ data-testid="multiline-instruction"
+ >
+ this is some
multiline text
- </pre>
+ </pre>
+ </div>
</div>
`;
exports[`Package code instruction single line to match the default snapshot 1`] = `
-<div
- class="gl-mb-3"
->
+<div>
<label
for="instruction-input_2"
>
@@ -23,42 +29,46 @@ exports[`Package code instruction single line to match the default snapshot 1`]
</label>
<div
- class="input-group gl-mb-3"
+ class="gl-mb-3"
>
- <input
- class="form-control gl-font-monospace"
- data-testid="instruction-input"
- id="instruction-input_2"
- readonly="readonly"
- type="text"
- />
-
- <span
- class="input-group-append"
- data-testid="instruction-button"
+ <div
+ class="input-group gl-mb-3"
>
- <button
- aria-label="Copy this value"
- class="btn input-group-text btn-default btn-md gl-button btn-default-secondary btn-icon"
- data-clipboard-text="npm i @my-package"
- title="Copy npm install command"
- type="button"
+ <input
+ class="form-control gl-font-monospace"
+ data-testid="instruction-input"
+ id="instruction-input_2"
+ readonly="readonly"
+ type="text"
+ />
+
+ <span
+ class="input-group-append"
+ data-testid="instruction-button"
>
- <!---->
-
- <svg
- aria-hidden="true"
- class="gl-button-icon gl-icon s16"
- data-testid="copy-to-clipboard-icon"
+ <button
+ aria-label="Copy this value"
+ class="btn input-group-text btn-default btn-md gl-button btn-default-secondary btn-icon"
+ data-clipboard-text="npm i @my-package"
+ title="Copy npm install command"
+ type="button"
>
- <use
- href="#copy-to-clipboard"
- />
- </svg>
-
- <!---->
- </button>
- </span>
+ <!---->
+
+ <svg
+ aria-hidden="true"
+ class="gl-button-icon gl-icon s16"
+ data-testid="copy-to-clipboard-icon"
+ >
+ <use
+ href="#copy-to-clipboard"
+ />
+ </svg>
+
+ <!---->
+ </button>
+ </span>
+ </div>
</div>
</div>
`;
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
new file mode 100644
index 00000000000..c65ded000d3
--- /dev/null
+++ b/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js
@@ -0,0 +1,122 @@
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import component from '~/vue_shared/components/registry/persisted_dropdown_selection.vue';
+
+describe('Persisted dropdown selection', () => {
+ let wrapper;
+
+ const defaultProps = {
+ storageKey: 'foo_bar',
+ options: [
+ { value: 'maven', label: 'Maven' },
+ { value: 'gradle', label: 'Gradle' },
+ ],
+ };
+
+ function createComponent({ props = {}, data = {} } = {}) {
+ wrapper = shallowMount(component, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ data() {
+ return data;
+ },
+ });
+ }
+
+ const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('local storage sync', () => {
+ it('uses the local storage sync component', () => {
+ createComponent();
+
+ expect(findLocalStorageSync().exists()).toBe(true);
+ });
+
+ it('passes the right props', () => {
+ createComponent({ data: { selected: 'foo' } });
+
+ expect(findLocalStorageSync().props()).toMatchObject({
+ storageKey: defaultProps.storageKey,
+ value: 'foo',
+ });
+ });
+
+ it('on input event updates the model and emits event', async () => {
+ const inputPayload = 'bar';
+ createComponent();
+ findLocalStorageSync().vm.$emit('input', inputPayload);
+
+ await nextTick();
+
+ expect(wrapper.emitted('change')).toStrictEqual([[inputPayload]]);
+ expect(findLocalStorageSync().props('value')).toBe(inputPayload);
+ });
+ });
+
+ describe('dropdown', () => {
+ it('has a dropdown component', () => {
+ createComponent();
+
+ expect(findDropdown().exists()).toBe(true);
+ });
+
+ describe('dropdown text', () => {
+ it('when no selection shows the first', () => {
+ createComponent();
+
+ expect(findDropdown().props('text')).toBe('Maven');
+ });
+
+ it('when an option is selected, shows that option label', () => {
+ createComponent({ data: { selected: defaultProps.options[1].value } });
+
+ expect(findDropdown().props('text')).toBe('Gradle');
+ });
+ });
+
+ describe('dropdown items', () => {
+ it('has one item for each option', () => {
+ createComponent();
+
+ expect(findDropdownItems()).toHaveLength(defaultProps.options.length);
+ });
+
+ it('binds the correct props', () => {
+ createComponent({ data: { selected: defaultProps.options[0].value } });
+
+ expect(findDropdownItems().at(0).props()).toMatchObject({
+ isChecked: true,
+ isCheckItem: true,
+ });
+
+ expect(findDropdownItems().at(1).props()).toMatchObject({
+ isChecked: false,
+ isCheckItem: true,
+ });
+ });
+
+ it('on click updates the data and emits event', async () => {
+ createComponent({ data: { selected: defaultProps.options[0].value } });
+ expect(findDropdownItems().at(0).props('isChecked')).toBe(true);
+
+ findDropdownItems().at(1).vm.$emit('click');
+
+ await nextTick();
+
+ expect(wrapper.emitted('change')).toStrictEqual([['gradle']]);
+ expect(findDropdownItems().at(0).props('isChecked')).toBe(false);
+ expect(findDropdownItems().at(1).props('isChecked')).toBe(true);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap b/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap
index 51b8aa162bc..ed085fb66dc 100644
--- a/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap
+++ b/spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap
@@ -2,7 +2,7 @@
exports[`Settings Block renders the correct markup 1`] = `
<section
- class="settings no-animate"
+ class="settings"
>
<div
class="settings-header"
diff --git a/spec/frontend/vue_shared/components/settings/settings_block_spec.js b/spec/frontend/vue_shared/components/settings/settings_block_spec.js
index 2db0b001b5b..be5a15631eb 100644
--- a/spec/frontend/vue_shared/components/settings/settings_block_spec.js
+++ b/spec/frontend/vue_shared/components/settings/settings_block_spec.js
@@ -50,6 +50,27 @@ describe('Settings Block', () => {
expect(findDescriptionSlot().exists()).toBe(true);
});
+ describe('slide animation behaviour', () => {
+ it('is animated by default', () => {
+ mountComponent();
+
+ expect(wrapper.classes('no-animate')).toBe(false);
+ });
+
+ it.each`
+ slideAnimated | noAnimatedClass
+ ${true} | ${false}
+ ${false} | ${true}
+ `(
+ 'sets the correct state when slideAnimated is $slideAnimated',
+ ({ slideAnimated, noAnimatedClass }) => {
+ mountComponent({ slideAnimated });
+
+ expect(wrapper.classes('no-animate')).toBe(noAnimatedClass);
+ },
+ );
+ });
+
describe('expanded behaviour', () => {
it('is collapsed by default', () => {
mountComponent();
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js
index 0d1d6ebcfe5..c90e63313b2 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js
@@ -11,32 +11,31 @@ import { mockConfig, mockRegularLabel, mockScopedLabel } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
-const createComponent = (initialState = mockConfig, slots = {}) => {
- const store = new Vuex.Store(labelsSelectModule());
-
- store.dispatch('setInitialState', initialState);
-
- return shallowMount(DropdownValue, {
- localVue,
- store,
- slots,
- });
-};
-
describe('DropdownValue', () => {
let wrapper;
- beforeEach(() => {
- wrapper = createComponent();
- });
+ const createComponent = (initialState = {}, slots = {}) => {
+ const store = new Vuex.Store(labelsSelectModule());
+
+ store.dispatch('setInitialState', { ...mockConfig, ...initialState });
+
+ wrapper = shallowMount(DropdownValue, {
+ localVue,
+ store,
+ slots,
+ });
+ };
afterEach(() => {
wrapper.destroy();
+ wrapper = null;
});
describe('methods', () => {
describe('labelFilterUrl', () => {
it('returns a label filter URL based on provided label param', () => {
+ createComponent();
+
expect(wrapper.vm.labelFilterUrl(mockRegularLabel)).toBe(
'/gitlab-org/my-project/issues?label_name[]=Foo%20Label',
);
@@ -44,6 +43,10 @@ describe('DropdownValue', () => {
});
describe('scopedLabel', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
it('returns `true` when provided label param is a scoped label', () => {
expect(wrapper.vm.scopedLabel(mockScopedLabel)).toBe(true);
});
@@ -56,28 +59,29 @@ describe('DropdownValue', () => {
describe('template', () => {
it('renders class `has-labels` on component container element when `selectedLabels` is not empty', () => {
+ createComponent();
+
expect(wrapper.attributes('class')).toContain('has-labels');
});
it('renders element containing `None` when `selectedLabels` is empty', () => {
- const wrapperNoLabels = createComponent(
+ createComponent(
{
- ...mockConfig,
selectedLabels: [],
},
{
default: 'None',
},
);
- const noneEl = wrapperNoLabels.find('span.text-secondary');
+ const noneEl = wrapper.find('span.text-secondary');
expect(noneEl.exists()).toBe(true);
expect(noneEl.text()).toBe('None');
-
- wrapperNoLabels.destroy();
});
it('renders labels when `selectedLabels` is not empty', () => {
+ createComponent();
+
expect(wrapper.findAll(GlLabel).length).toBe(2);
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
index 85a14226585..f293b8422e7 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
@@ -47,6 +47,7 @@ export const mockConfig = {
labelsFetchPath: '/gitlab-org/my-project/-/labels.json',
labelsManagePath: '/gitlab-org/my-project/-/labels',
labelsFilterBasePath: '/gitlab-org/my-project/issues',
+ labelsFilterParam: 'label_name',
};
export const mockSuggestedColors = {
diff --git a/spec/frontend/vue_shared/components/tabs/tab_spec.js b/spec/frontend/vue_shared/components/tabs/tab_spec.js
deleted file mode 100644
index ee0c983c764..00000000000
--- a/spec/frontend/vue_shared/components/tabs/tab_spec.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import Vue from 'vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import Tab from '~/vue_shared/components/tabs/tab.vue';
-
-describe('Tab component', () => {
- const Component = Vue.extend(Tab);
- let vm;
-
- beforeEach(() => {
- vm = mountComponent(Component);
- });
-
- it('sets localActive to equal active', (done) => {
- vm.active = true;
-
- vm.$nextTick(() => {
- expect(vm.localActive).toBe(true);
-
- done();
- });
- });
-
- it('sets active class', (done) => {
- vm.active = true;
-
- vm.$nextTick(() => {
- expect(vm.$el.classList).toContain('active');
-
- done();
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/tabs/tabs_spec.js b/spec/frontend/vue_shared/components/tabs/tabs_spec.js
deleted file mode 100644
index fe7be5be899..00000000000
--- a/spec/frontend/vue_shared/components/tabs/tabs_spec.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import Vue from 'vue';
-import Tab from '~/vue_shared/components/tabs/tab.vue';
-import Tabs from '~/vue_shared/components/tabs/tabs';
-
-describe('Tabs component', () => {
- let vm;
-
- beforeEach(() => {
- vm = new Vue({
- components: {
- Tabs,
- Tab,
- },
- render(h) {
- return h('div', [
- h('tabs', [
- h('tab', { attrs: { title: 'Testing', active: true } }, 'First tab'),
- h('tab', [h('template', { slot: 'title' }, 'Test slot'), 'Second tab']),
- ]),
- ]);
- },
- }).$mount();
-
- return vm.$nextTick();
- });
-
- describe('tab links', () => {
- it('renders links for tabs', () => {
- expect(vm.$el.querySelectorAll('a').length).toBe(2);
- });
-
- it('renders link titles from props', () => {
- expect(vm.$el.querySelector('a').textContent).toContain('Testing');
- });
-
- it('renders link titles from slot', () => {
- expect(vm.$el.querySelectorAll('a')[1].textContent).toContain('Test slot');
- });
-
- it('renders active class', () => {
- expect(vm.$el.querySelector('a').classList).toContain('active');
- });
-
- it('updates active class on click', () => {
- vm.$el.querySelectorAll('a')[1].click();
-
- return vm.$nextTick(() => {
- expect(vm.$el.querySelector('a').classList).not.toContain('active');
- expect(vm.$el.querySelectorAll('a')[1].classList).toContain('active');
- });
- });
- });
-
- describe('content', () => {
- it('renders content panes', () => {
- expect(vm.$el.querySelectorAll('.tab-pane').length).toBe(2);
- expect(vm.$el.querySelectorAll('.tab-pane')[0].textContent).toContain('First tab');
- expect(vm.$el.querySelectorAll('.tab-pane')[1].textContent).toContain('Second tab');
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js
index 27c9b099306..380b7231acd 100644
--- a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js
+++ b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js
@@ -11,6 +11,15 @@ jest.mock('~/lib/utils/dom_utils', () => ({
throw new Error('this needs to be mocked');
}),
}));
+jest.mock('@gitlab/ui', () => ({
+ GlTooltipDirective: {
+ bind(el, binding) {
+ el.classList.add('gl-tooltip');
+ el.setAttribute('data-original-title', el.title);
+ el.dataset.placement = binding.value.placement;
+ },
+ },
+}));
describe('TooltipOnTruncate component', () => {
let wrapper;
@@ -52,7 +61,7 @@ describe('TooltipOnTruncate component', () => {
wrapper = parent.find(TooltipOnTruncate);
};
- const hasTooltip = () => wrapper.classes('js-show-tooltip');
+ const hasTooltip = () => wrapper.classes('gl-tooltip');
afterEach(() => {
wrapper.destroy();
diff --git a/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap
index d2fe3cd76cb..af4fa462cbf 100644
--- a/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap
+++ b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap
@@ -19,6 +19,7 @@ exports[`Upload dropzone component correctly overrides description and drop mess
<p
class="gl-mb-0"
+ data-testid="upload-text"
>
<span>
Test %{linkStart}description%{linkEnd} message.
@@ -98,10 +99,15 @@ exports[`Upload dropzone component when dragging renders correct template when d
<p
class="gl-mb-0"
+ data-testid="upload-text"
>
- <gl-sprintf-stub
- message="Drop or %{linkStart}upload%{linkEnd} files to attach"
- />
+ Drop or
+ <gl-link-stub>
+
+ upload
+
+ </gl-link-stub>
+ files to attach
</p>
</div>
</button>
@@ -178,10 +184,15 @@ exports[`Upload dropzone component when dragging renders correct template when d
<p
class="gl-mb-0"
+ data-testid="upload-text"
>
- <gl-sprintf-stub
- message="Drop or %{linkStart}upload%{linkEnd} files to attach"
- />
+ Drop or
+ <gl-link-stub>
+
+ upload
+
+ </gl-link-stub>
+ files to attach
</p>
</div>
</button>
@@ -258,10 +269,15 @@ exports[`Upload dropzone component when dragging renders correct template when d
<p
class="gl-mb-0"
+ data-testid="upload-text"
>
- <gl-sprintf-stub
- message="Drop or %{linkStart}upload%{linkEnd} files to attach"
- />
+ Drop or
+ <gl-link-stub>
+
+ upload
+
+ </gl-link-stub>
+ files to attach
</p>
</div>
</button>
@@ -337,10 +353,15 @@ exports[`Upload dropzone component when dragging renders correct template when d
<p
class="gl-mb-0"
+ data-testid="upload-text"
>
- <gl-sprintf-stub
- message="Drop or %{linkStart}upload%{linkEnd} files to attach"
- />
+ Drop or
+ <gl-link-stub>
+
+ upload
+
+ </gl-link-stub>
+ files to attach
</p>
</div>
</button>
@@ -416,10 +437,15 @@ exports[`Upload dropzone component when dragging renders correct template when d
<p
class="gl-mb-0"
+ data-testid="upload-text"
>
- <gl-sprintf-stub
- message="Drop or %{linkStart}upload%{linkEnd} files to attach"
- />
+ Drop or
+ <gl-link-stub>
+
+ upload
+
+ </gl-link-stub>
+ files to attach
</p>
</div>
</button>
@@ -495,10 +521,15 @@ exports[`Upload dropzone component when no slot provided renders default dropzon
<p
class="gl-mb-0"
+ data-testid="upload-text"
>
- <gl-sprintf-stub
- message="Drop or %{linkStart}upload%{linkEnd} files to attach"
- />
+ Drop or
+ <gl-link-stub>
+
+ upload
+
+ </gl-link-stub>
+ files to attach
</p>
</div>
</button>
diff --git a/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js
index ace486b1f32..b3cdbccb271 100644
--- a/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js
+++ b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js
@@ -1,4 +1,4 @@
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
@@ -14,6 +14,7 @@ describe('Upload dropzone component', () => {
const findDropzoneCard = () => wrapper.find('.upload-dropzone-card');
const findDropzoneArea = () => wrapper.find('[data-testid="dropzone-area"]');
const findIcon = () => wrapper.find(GlIcon);
+ const findUploadText = () => wrapper.find('[data-testid="upload-text"]').text();
function createComponent({ slots = {}, data = {}, props = {} } = {}) {
wrapper = shallowMount(UploadDropzone, {
@@ -22,6 +23,9 @@ describe('Upload dropzone component', () => {
displayAsCard: true,
...props,
},
+ stubs: {
+ GlSprintf,
+ },
data() {
return data;
},
@@ -30,6 +34,7 @@ describe('Upload dropzone component', () => {
afterEach(() => {
wrapper.destroy();
+ wrapper = null;
});
describe('when slot provided', () => {
@@ -60,6 +65,18 @@ describe('Upload dropzone component', () => {
});
});
+ describe('upload text', () => {
+ it.each`
+ collection | description | props | expected
+ ${'multiple'} | ${'by default'} | ${null} | ${'files to attach'}
+ ${'singular'} | ${'when singleFileSelection'} | ${{ singleFileSelection: true }} | ${'file to attach'}
+ `('displays $collection version $description', ({ props, expected }) => {
+ createComponent({ props });
+
+ expect(findUploadText()).toContain(expected);
+ });
+ });
+
describe('when dragging', () => {
it.each`
description | eventPayload
@@ -141,6 +158,21 @@ describe('Upload dropzone component', () => {
wrapper.vm.ondrop(mockEvent);
expect(wrapper.emitted()).not.toHaveProperty('error');
});
+
+ describe('singleFileSelection = true', () => {
+ it('emits a single file on drop', () => {
+ createComponent({
+ data: mockData,
+ props: { singleFileSelection: true },
+ });
+
+ const mockFile = { type: 'image/jpg' };
+ const mockEvent = mockDragEvent({ files: [mockFile] });
+
+ wrapper.vm.ondrop(mockEvent);
+ expect(wrapper.emitted().change[0]).toEqual([mockFile]);
+ });
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/user_access_role_badge_spec.js b/spec/frontend/vue_shared/components/user_access_role_badge_spec.js
new file mode 100644
index 00000000000..7f25f7c08e7
--- /dev/null
+++ b/spec/frontend/vue_shared/components/user_access_role_badge_spec.js
@@ -0,0 +1,26 @@
+import { GlBadge } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
+
+describe('UserAccessRoleBadge', () => {
+ let wrapper;
+
+ const createComponent = ({ slots } = {}) => {
+ wrapper = shallowMount(UserAccessRoleBadge, {
+ slots,
+ });
+ };
+
+ it('renders slot content inside GlBadge', () => {
+ createComponent({
+ slots: {
+ default: 'test slot content',
+ },
+ });
+
+ const badge = wrapper.find(GlBadge);
+
+ expect(badge.exists()).toBe(true);
+ expect(badge.html()).toContain('test slot content');
+ });
+});
diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
index a6c5e23ae14..184a1e458b5 100644
--- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
+++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
@@ -9,6 +9,7 @@ const DEFAULT_PROPS = {
username: 'root',
name: 'Administrator',
location: 'Vienna',
+ bot: false,
bio: null,
workInformation: null,
status: null,
@@ -18,14 +19,10 @@ const DEFAULT_PROPS = {
describe('User Popover Component', () => {
const fixtureTemplate = 'merge_requests/diff_comment.html';
- preloadFixtures(fixtureTemplate);
let wrapper;
beforeEach(() => {
- window.gon.features = {
- securityAutoFix: true,
- };
loadFixtures(fixtureTemplate);
});
@@ -37,6 +34,7 @@ describe('User Popover Component', () => {
const findUserStatus = () => wrapper.find('.js-user-status');
const findTarget = () => document.querySelector('.js-user-link');
const findUserName = () => wrapper.find(UserNameWithStatus);
+ const findSecurityBotDocsLink = () => findByTestId('user-popover-bot-docs-link');
const createWrapper = (props = {}, options = {}) => {
wrapper = shallowMount(UserPopover, {
@@ -86,6 +84,12 @@ describe('User Popover Component', () => {
expect(iconEl.props('name')).toEqual('location');
});
+
+ it("should not show a link to bot's documentation", () => {
+ createWrapper();
+ const securityBotDocsLink = findSecurityBotDocsLink();
+ expect(securityBotDocsLink.exists()).toBe(false);
+ });
});
describe('job data', () => {
@@ -230,14 +234,14 @@ describe('User Popover Component', () => {
});
});
- describe('security bot', () => {
+ describe('bot user', () => {
const SECURITY_BOT_USER = {
...DEFAULT_PROPS.user,
name: 'GitLab Security Bot',
username: 'GitLab-Security-Bot',
websiteUrl: '/security/bot/docs',
+ bot: true,
};
- const findSecurityBotDocsLink = () => findByTestId('user-popover-bot-docs-link');
it("shows a link to the bot's documentation", () => {
createWrapper({ user: SECURITY_BOT_USER });
@@ -245,14 +249,5 @@ describe('User Popover Component', () => {
expect(securityBotDocsLink.exists()).toBe(true);
expect(securityBotDocsLink.attributes('href')).toBe(SECURITY_BOT_USER.websiteUrl);
});
-
- it('does not show the link if the feature flag is disabled', () => {
- window.gon.features = {
- securityAutoFix: false,
- };
- createWrapper({ user: SECURITY_BOT_USER });
-
- expect(findSecurityBotDocsLink().exists()).toBe(false);
- });
});
});
diff --git a/spec/frontend/vue_shared/directives/tooltip_spec.js b/spec/frontend/vue_shared/directives/tooltip_spec.js
deleted file mode 100644
index 99e8b5b552b..00000000000
--- a/spec/frontend/vue_shared/directives/tooltip_spec.js
+++ /dev/null
@@ -1,157 +0,0 @@
-import { mount } from '@vue/test-utils';
-import $ from 'jquery';
-import { escape } from 'lodash';
-import tooltip from '~/vue_shared/directives/tooltip';
-
-const DEFAULT_TOOLTIP_TEMPLATE = '<div v-tooltip :title="tooltip"></div>';
-const HTML_TOOLTIP_TEMPLATE = '<div v-tooltip data-html="true" :title="tooltip"></div>';
-
-describe('Tooltip directive', () => {
- let wrapper;
-
- function createTooltipContainer({
- template = DEFAULT_TOOLTIP_TEMPLATE,
- text = 'some text',
- } = {}) {
- wrapper = mount(
- {
- directives: { tooltip },
- data: () => ({ tooltip: text }),
- template,
- },
- { attachTo: document.body },
- );
- }
-
- async function showTooltip() {
- $(wrapper.vm.$el).tooltip('show');
- jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
- }
-
- function findTooltipInnerHtml() {
- return document.querySelector('.tooltip-inner').innerHTML;
- }
-
- function findTooltipHtml() {
- return document.querySelector('.tooltip').innerHTML;
- }
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- describe('with a single tooltip', () => {
- it('should have tooltip plugin applied', () => {
- createTooltipContainer();
-
- expect($(wrapper.vm.$el).data('bs.tooltip')).toBeDefined();
- });
-
- it('displays the title as tooltip', () => {
- createTooltipContainer();
-
- $(wrapper.vm.$el).tooltip('show');
-
- jest.runOnlyPendingTimers();
-
- const tooltipElement = document.querySelector('.tooltip-inner');
-
- expect(tooltipElement.textContent).toContain('some text');
- });
-
- it.each`
- condition | template | sanitize
- ${'does not contain any html'} | ${DEFAULT_TOOLTIP_TEMPLATE} | ${false}
- ${'contains html'} | ${HTML_TOOLTIP_TEMPLATE} | ${true}
- `('passes sanitize=$sanitize if the tooltip $condition', ({ template, sanitize }) => {
- createTooltipContainer({ template });
-
- expect($(wrapper.vm.$el).data('bs.tooltip').config.sanitize).toEqual(sanitize);
- });
-
- it('updates a visible tooltip', async () => {
- createTooltipContainer();
-
- $(wrapper.vm.$el).tooltip('show');
- jest.runOnlyPendingTimers();
-
- const tooltipElement = document.querySelector('.tooltip-inner');
-
- wrapper.vm.tooltip = 'other text';
-
- jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
-
- expect(tooltipElement.textContent).toContain('other text');
- });
-
- describe('tooltip sanitization', () => {
- it('reads tooltip content as text if data-html is not passed', async () => {
- createTooltipContainer({ text: 'sample text<script>alert("XSS!!")</script>' });
-
- await showTooltip();
-
- const result = findTooltipInnerHtml();
- expect(result).toEqual('sample text&lt;script&gt;alert("XSS!!")&lt;/script&gt;');
- });
-
- it('sanitizes tooltip if data-html is passed', async () => {
- createTooltipContainer({
- template: HTML_TOOLTIP_TEMPLATE,
- text: 'sample text<script>alert("XSS!!")</script>',
- });
-
- await showTooltip();
-
- const result = findTooltipInnerHtml();
- expect(result).toEqual('sample text');
- expect(result).not.toContain('XSS!!');
- });
-
- it('sanitizes tooltip if data-template is passed', async () => {
- const tooltipTemplate = escape(
- '<div class="tooltip" role="tooltip"><div onclick="alert(\'XSS!\')" class="arrow"></div><div class="tooltip-inner"></div></div>',
- );
-
- createTooltipContainer({
- template: `<div v-tooltip :title="tooltip" data-html="false" data-template="${tooltipTemplate}"></div>`,
- });
-
- await showTooltip();
-
- const result = findTooltipHtml();
- expect(result).toEqual(
- // objectionable element is removed
- '<div class="arrow"></div><div class="tooltip-inner">some text</div>',
- );
- expect(result).not.toContain('XSS!!');
- });
- });
- });
-
- describe('with multiple tooltips', () => {
- beforeEach(() => {
- createTooltipContainer({
- template: `
- <div>
- <div
- v-tooltip
- class="js-look-for-tooltip"
- title="foo">
- </div>
- <div
- v-tooltip
- title="bar">
- </div>
- </div>
- `,
- });
- });
-
- it('should have tooltip plugin applied to all instances', () => {
- expect($(wrapper.vm.$el).find('.js-look-for-tooltip').data('bs.tooltip')).toBeDefined();
- });
- });
-});
diff --git a/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js b/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js
index 6ecc330b5af..3fb60c254c9 100644
--- a/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js
+++ b/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js
@@ -11,6 +11,10 @@ describe('GitLab Feature Flags Plugin', () => {
aFeature: true,
bFeature: false,
},
+ licensed_features: {
+ cFeature: true,
+ dFeature: false,
+ },
};
localVue.use(GlFeatureFlags);
@@ -25,6 +29,8 @@ describe('GitLab Feature Flags Plugin', () => {
expect(wrapper.vm.glFeatures).toEqual({
aFeature: true,
bFeature: false,
+ cFeature: true,
+ dFeature: false,
});
});
@@ -37,6 +43,8 @@ describe('GitLab Feature Flags Plugin', () => {
expect(wrapper.vm.glFeatures).toEqual({
aFeature: true,
bFeature: false,
+ cFeature: true,
+ dFeature: false,
});
});
});
diff --git a/spec/frontend/zen_mode_spec.js b/spec/frontend/zen_mode_spec.js
index 5cc1d2200d3..bf4b57d8afb 100644
--- a/spec/frontend/zen_mode_spec.js
+++ b/spec/frontend/zen_mode_spec.js
@@ -13,8 +13,6 @@ describe('ZenMode', () => {
let dropzoneForElementSpy;
const fixtureName = 'snippets/show.html';
- preloadFixtures(fixtureName);
-
function enterZen() {
$('.notes-form .js-zen-enter').click();
}
diff --git a/spec/frontend_integration/ide/helpers/ide_helper.js b/spec/frontend_integration/ide/helpers/ide_helper.js
index 9e6bafc1297..6c09b44d891 100644
--- a/spec/frontend_integration/ide/helpers/ide_helper.js
+++ b/spec/frontend_integration/ide/helpers/ide_helper.js
@@ -25,6 +25,9 @@ export const getStatusBar = () => document.querySelector('.ide-status-bar');
export const waitForMonacoEditor = () =>
new Promise((resolve) => window.monaco.editor.onDidCreateEditor(resolve));
+export const waitForEditorModelChange = (instance) =>
+ new Promise((resolve) => instance.onDidChangeModel(resolve));
+
export const findMonacoEditor = () =>
screen.findAllByLabelText(/Editor content;/).then(([x]) => x.closest('.monaco-editor'));
diff --git a/spec/frontend_integration/ide/helpers/mock_data.js b/spec/frontend_integration/ide/helpers/mock_data.js
index f70739e5ac0..8c9ec74541f 100644
--- a/spec/frontend_integration/ide/helpers/mock_data.js
+++ b/spec/frontend_integration/ide/helpers/mock_data.js
@@ -4,7 +4,6 @@ export const IDE_DATASET = {
committedStateSvgPath: '/test/committed_state.svg',
pipelinesEmptyStateSvgPath: '/test/pipelines_empty_state.svg',
promotionSvgPath: '/test/promotion.svg',
- ciHelpPagePath: '/test/ci_help_page',
webIDEHelpPagePath: '/test/web_ide_help_page',
clientsidePreviewEnabled: 'true',
renderWhitespaceInCode: 'false',
diff --git a/spec/frontend_integration/ide/helpers/start.js b/spec/frontend_integration/ide/helpers/start.js
index 173a9610c84..cc6abd9e01f 100644
--- a/spec/frontend_integration/ide/helpers/start.js
+++ b/spec/frontend_integration/ide/helpers/start.js
@@ -1,6 +1,7 @@
+/* global monaco */
+
import { TEST_HOST } from 'helpers/test_constants';
import { initIde } from '~/ide';
-import Editor from '~/ide/lib/editor';
import extendStore from '~/ide/stores/extend';
import { IDE_DATASET } from './mock_data';
@@ -18,13 +19,7 @@ export default (container, { isRepoEmpty = false, path = '', mrId = '' } = {}) =
const vm = initIde(el, { extendStore });
// We need to dispose of editor Singleton things or tests will bump into eachother
- vm.$on('destroy', () => {
- if (Editor.editorInstance) {
- Editor.editorInstance.modelManager.dispose();
- Editor.editorInstance.dispose();
- Editor.editorInstance = null;
- }
- });
+ vm.$on('destroy', () => monaco.editor.getModels().forEach((model) => model.dispose()));
return vm;
};
diff --git a/spec/frontend_integration/ide/ide_integration_spec.js b/spec/frontend_integration/ide/ide_integration_spec.js
index 3ce88de11fe..5f1a5b0d048 100644
--- a/spec/frontend_integration/ide/ide_integration_spec.js
+++ b/spec/frontend_integration/ide/ide_integration_spec.js
@@ -96,16 +96,6 @@ describe('WebIDE', () => {
let statusBar;
let editor;
- const waitForEditor = async () => {
- editor = await ideHelper.waitForMonacoEditor();
- };
-
- const changeEditorPosition = async (lineNumber, column) => {
- editor.setPosition({ lineNumber, column });
-
- await vm.$nextTick();
- };
-
beforeEach(async () => {
vm = startWebIDE(container);
@@ -134,16 +124,17 @@ describe('WebIDE', () => {
// Need to wait for monaco editor to load so it doesn't through errors on dispose
await ideHelper.openFile('.gitignore');
- await ideHelper.waitForMonacoEditor();
+ await ideHelper.waitForEditorModelChange(editor);
await ideHelper.openFile('README.md');
- await ideHelper.waitForMonacoEditor();
+ await ideHelper.waitForEditorModelChange(editor);
expect(el).toHaveText(markdownPreview);
});
describe('when editor position changes', () => {
beforeEach(async () => {
- await changeEditorPosition(4, 10);
+ editor.setPosition({ lineNumber: 4, column: 10 });
+ await vm.$nextTick();
});
it('shows new line position', () => {
@@ -153,7 +144,8 @@ describe('WebIDE', () => {
it('updates after rename', async () => {
await ideHelper.renameFile('README.md', 'READMEZ.txt');
- await waitForEditor();
+ await ideHelper.waitForEditorModelChange(editor);
+ await vm.$nextTick();
expect(statusBar).toHaveText('1:1');
expect(statusBar).toHaveText('plaintext');
@@ -161,10 +153,10 @@ describe('WebIDE', () => {
it('persists position after opening then rename', async () => {
await ideHelper.openFile('files/js/application.js');
- await waitForEditor();
+ await ideHelper.waitForEditorModelChange(editor);
await ideHelper.renameFile('README.md', 'READING_RAINBOW.md');
await ideHelper.openFile('READING_RAINBOW.md');
- await waitForEditor();
+ await ideHelper.waitForEditorModelChange(editor);
expect(statusBar).toHaveText('4:10');
expect(statusBar).toHaveText('markdown');
@@ -173,7 +165,8 @@ describe('WebIDE', () => {
it('persists position after closing', async () => {
await ideHelper.closeFile('README.md');
await ideHelper.openFile('README.md');
- await waitForEditor();
+ await ideHelper.waitForMonacoEditor();
+ await vm.$nextTick();
expect(statusBar).toHaveText('4:10');
expect(statusBar).toHaveText('markdown');
diff --git a/spec/frontend_integration/ide/user_opens_mr_spec.js b/spec/frontend_integration/ide/user_opens_mr_spec.js
index 9cf0ff5da56..3ffc5169351 100644
--- a/spec/frontend_integration/ide/user_opens_mr_spec.js
+++ b/spec/frontend_integration/ide/user_opens_mr_spec.js
@@ -24,11 +24,11 @@ describe('IDE: User opens Merge Request', () => {
vm = startWebIDE(container, { mrId });
- await ideHelper.waitForTabToOpen(basename(changes[0].new_path));
- await ideHelper.waitForMonacoEditor();
+ const editor = await ideHelper.waitForMonacoEditor();
+ await ideHelper.waitForEditorModelChange(editor);
});
- afterEach(async () => {
+ afterEach(() => {
vm.$destroy();
vm = null;
});
diff --git a/spec/frontend_integration/test_helpers/mock_server/graphql.js b/spec/frontend_integration/test_helpers/mock_server/graphql.js
index 654c373e5a6..e2658852599 100644
--- a/spec/frontend_integration/test_helpers/mock_server/graphql.js
+++ b/spec/frontend_integration/test_helpers/mock_server/graphql.js
@@ -1,5 +1,11 @@
import { buildSchema, graphql } from 'graphql';
-import gitlabSchemaStr from '../../../../doc/api/graphql/reference/gitlab_schema.graphql';
+
+/* eslint-disable import/no-unresolved */
+// This rule is disabled for the following line.
+// The graphql schema is dynamically generated in CI
+// during the `graphql-schema-dump` job.
+import gitlabSchemaStr from '../../../../tmp/tests/graphql/gitlab_schema.graphql';
+/* eslint-enable import/no-unresolved */
const graphqlSchema = buildSchema(gitlabSchemaStr.loc.source.body);
const graphqlResolvers = {
diff --git a/spec/generator_helper.rb b/spec/generator_helper.rb
new file mode 100644
index 00000000000..d35eaac45bd
--- /dev/null
+++ b/spec/generator_helper.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.configure do |config|
+ # Redirect stdout so specs don't have so much noise
+ config.before(:all) do
+ $stdout = StringIO.new
+ end
+
+ # Reset stdout
+ config.after(:all) do
+ $stdout = STDOUT
+ end
+end
diff --git a/spec/graphql/features/authorization_spec.rb b/spec/graphql/features/authorization_spec.rb
index ec67ed16fe9..33b11e1ca09 100644
--- a/spec/graphql/features/authorization_spec.rb
+++ b/spec/graphql/features/authorization_spec.rb
@@ -2,17 +2,22 @@
require 'spec_helper'
-RSpec.describe 'Gitlab::Graphql::Authorization' do
+RSpec.describe 'Gitlab::Graphql::Authorize' do
include GraphqlHelpers
+ include Graphql::ResolverFactories
let_it_be(:user) { create(:user) }
let(:permission_single) { :foo }
let(:permission_collection) { [:foo, :bar] }
let(:test_object) { double(name: 'My name') }
let(:query_string) { '{ item { name } }' }
- let(:result) { execute_query(query_type)['data'] }
+ let(:result) do
+ schema = empty_schema
+ schema.use(Gitlab::Graphql::Authorize)
+ execute_query(query_type, schema: schema)
+ end
- subject { result['item'] }
+ subject { result.dig('data', 'item') }
shared_examples 'authorization with a single permission' do
it 'returns the protected field when user has permission' do
@@ -55,7 +60,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
describe 'with a single permission' do
let(:query_type) do
query_factory do |query|
- query.field :item, type, null: true, resolver: simple_resolver(test_object), authorize: permission_single
+ query.field :item, type, null: true, resolver: new_resolver(test_object), authorize: permission_single
end
end
@@ -66,7 +71,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
let(:query_type) do
permissions = permission_collection
query_factory do |qt|
- qt.field :item, type, null: true, resolver: simple_resolver(test_object) do
+ qt.field :item, type, null: true, resolver: new_resolver(test_object) do
authorize permissions
end
end
@@ -79,7 +84,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
describe 'Field authorizations when field is a built in type' do
let(:query_type) do
query_factory do |query|
- query.field :item, type, null: true, resolver: simple_resolver(test_object)
+ query.field :item, type, null: true, resolver: new_resolver(test_object)
end
end
@@ -132,7 +137,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
describe 'Type authorizations' do
let(:query_type) do
query_factory do |query|
- query.field :item, type, null: true, resolver: simple_resolver(test_object)
+ query.field :item, type, null: true, resolver: new_resolver(test_object)
end
end
@@ -169,7 +174,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
let(:query_type) do
query_factory do |query|
- query.field :item, type, null: true, resolver: simple_resolver(test_object), authorize: permission_2
+ query.field :item, type, null: true, resolver: new_resolver(test_object), authorize: permission_2
end
end
@@ -188,11 +193,11 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
let(:query_type) do
query_factory do |query|
- query.field :item, type.connection_type, null: true, resolver: simple_resolver([test_object, second_test_object])
+ query.field :item, type.connection_type, null: true, resolver: new_resolver([test_object, second_test_object])
end
end
- subject { result.dig('item', 'edges') }
+ subject { result.dig('data', 'item', 'edges') }
it 'returns only the elements visible to the user' do
permit(permission_single)
@@ -208,7 +213,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
describe 'limiting connections with multiple objects' do
let(:query_type) do
query_factory do |query|
- query.field :item, type.connection_type, null: true, resolver: simple_resolver([test_object, second_test_object])
+ query.field :item, type.connection_type, null: true, resolver: new_resolver([test_object, second_test_object])
end
end
@@ -232,11 +237,11 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
let(:query_type) do
query_factory do |query|
- query.field :item, [type], null: true, resolver: simple_resolver([test_object])
+ query.field :item, [type], null: true, resolver: new_resolver([test_object])
end
end
- subject { result['item'].first }
+ subject { result.dig('data', 'item', 0) }
include_examples 'authorization with a single permission'
end
@@ -260,13 +265,13 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
type_factory do |type|
type.graphql_name 'FakeProjectType'
type.field :test_issues, issue_type.connection_type, null: false,
- resolver: simple_resolver(Issue.where(project: [visible_project, other_project]).order(id: :asc))
+ resolver: new_resolver(Issue.where(project: [visible_project, other_project]).order(id: :asc))
end
end
let(:query_type) do
query_factory do |query|
- query.field :test_project, project_type, null: false, resolver: simple_resolver(visible_project)
+ query.field :test_project, project_type, null: false, resolver: new_resolver(visible_project)
end
end
@@ -281,7 +286,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
end
it 'renders the issues the user has access to' do
- issue_edges = result['testProject']['testIssues']['edges']
+ issue_edges = result.dig('data', 'testProject', 'testIssues', 'edges')
issue_ids = issue_edges.map { |issue_edge| issue_edge['node']&.fetch('id') }
expect(issue_edges.size).to eq(visible_issues.size)
diff --git a/spec/graphql/features/feature_flag_spec.rb b/spec/graphql/features/feature_flag_spec.rb
index 77810f78257..30238cf9cb3 100644
--- a/spec/graphql/features/feature_flag_spec.rb
+++ b/spec/graphql/features/feature_flag_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'Graphql Field feature flags' do
include GraphqlHelpers
+ include Graphql::ResolverFactories
let_it_be(:user) { create(:user) }
@@ -23,7 +24,7 @@ RSpec.describe 'Graphql Field feature flags' do
let(:query_type) do
query_factory do |query|
- query.field :item, type, null: true, feature_flag: feature_flag, resolver: simple_resolver(test_object)
+ query.field :item, type, null: true, feature_flag: feature_flag, resolver: new_resolver(test_object)
end
end
diff --git a/spec/graphql/gitlab_schema_spec.rb b/spec/graphql/gitlab_schema_spec.rb
index 4db12643069..cb2bb25b098 100644
--- a/spec/graphql/gitlab_schema_spec.rb
+++ b/spec/graphql/gitlab_schema_spec.rb
@@ -18,14 +18,6 @@ RSpec.describe GitlabSchema do
expect(field_instrumenters).to include(instance_of(::Gitlab::Graphql::Authorize::Instrumentation))
end
- it 'enables using presenters' do
- expect(field_instrumenters).to include(instance_of(::Gitlab::Graphql::Present::Instrumentation))
- end
-
- it 'enables using gitaly call checker' do
- expect(field_instrumenters).to include(instance_of(::Gitlab::Graphql::CallsGitaly::Instrumentation))
- end
-
it 'has the base mutation' do
expect(described_class.mutation).to eq(::Types::MutationType)
end
@@ -47,7 +39,7 @@ RSpec.describe GitlabSchema do
end
describe '.execute' do
- context 'for different types of users' do
+ context 'with different types of users' do
context 'when no context' do
it 'returns DEFAULT_MAX_COMPLEXITY' do
expect(GraphQL::Schema)
@@ -78,13 +70,15 @@ RSpec.describe GitlabSchema do
context 'when a logged in user' do
it 'returns AUTHENTICATED_COMPLEXITY' do
- expect(GraphQL::Schema).to receive(:execute).with('query', hash_including(max_complexity: GitlabSchema::AUTHENTICATED_COMPLEXITY))
+ expect(GraphQL::Schema).to receive(:execute)
+ .with('query', hash_including(max_complexity: GitlabSchema::AUTHENTICATED_COMPLEXITY))
described_class.execute('query', context: { current_user: user })
end
it 'returns AUTHENTICATED_MAX_DEPTH' do
- expect(GraphQL::Schema).to receive(:execute).with('query', hash_including(max_depth: GitlabSchema::AUTHENTICATED_MAX_DEPTH))
+ expect(GraphQL::Schema).to receive(:execute)
+ .with('query', hash_including(max_depth: GitlabSchema::AUTHENTICATED_MAX_DEPTH))
described_class.execute('query', context: { current_user: user })
end
@@ -94,7 +88,8 @@ RSpec.describe GitlabSchema do
it 'returns ADMIN_COMPLEXITY' do
user = build :user, :admin
- expect(GraphQL::Schema).to receive(:execute).with('query', hash_including(max_complexity: GitlabSchema::ADMIN_COMPLEXITY))
+ expect(GraphQL::Schema).to receive(:execute)
+ .with('query', hash_including(max_complexity: GitlabSchema::ADMIN_COMPLEXITY))
described_class.execute('query', context: { current_user: user })
end
@@ -130,7 +125,7 @@ RSpec.describe GitlabSchema do
end
describe '.object_from_id' do
- context 'for subclasses of `ApplicationRecord`' do
+ context 'with subclasses of `ApplicationRecord`' do
let_it_be(:user) { create(:user) }
it 'returns the correct record' do
@@ -162,7 +157,7 @@ RSpec.describe GitlabSchema do
end
end
- context 'for classes that are not ActiveRecord subclasses and have implemented .lazy_find' do
+ context 'with classes that are not ActiveRecord subclasses and have implemented .lazy_find' do
it 'returns the correct record' do
note = create(:discussion_note_on_merge_request)
@@ -182,7 +177,7 @@ RSpec.describe GitlabSchema do
end
end
- context 'for other classes' do
+ context 'with other classes' do
# We cannot use an anonymous class here as `GlobalID` expects `.name` not
# to return `nil`
before do
diff --git a/spec/graphql/mutations/boards/update_spec.rb b/spec/graphql/mutations/boards/update_spec.rb
new file mode 100644
index 00000000000..da3dfeecd4d
--- /dev/null
+++ b/spec/graphql/mutations/boards/update_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Boards::Update do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:board) { create(:board, project: project) }
+
+ let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
+ let(:mutated_board) { subject[:board] }
+
+ let(:mutation_params) do
+ {
+ id: board.to_global_id,
+ hide_backlog_list: true,
+ hide_closed_list: false
+ }
+ end
+
+ subject { mutation.resolve(**mutation_params) }
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_issue_board) }
+
+ describe '#resolve' do
+ context 'when the user cannot admin the board' do
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'with invalid params' do
+ it 'raises an error' do
+ mutation_params[:id] = project.to_global_id
+
+ expect { subject }.to raise_error(::GraphQL::CoercionError)
+ end
+ end
+
+ context 'when user can update board' do
+ before do
+ board.resource_parent.add_reporter(user)
+ end
+
+ it 'updates board with correct values' do
+ expected_attributes = {
+ hide_backlog_list: true,
+ hide_closed_list: false
+ }
+
+ subject
+
+ expect(board.reload).to have_attributes(expected_attributes)
+ end
+ end
+ end
+end
diff --git a/spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb b/spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb
index ee8db7a1f31..8d1fce406fa 100644
--- a/spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb
+++ b/spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb
@@ -30,7 +30,7 @@ RSpec.describe Mutations::CanMutateSpammable do
end
it 'merges in spam action fields from spammable' do
- result = subject.send(:with_spam_action_fields, spammable) do
+ result = subject.send(:with_spam_action_response_fields, spammable) do
{ other_field: true }
end
expect(result)
diff --git a/spec/graphql/mutations/custom_emoji/create_spec.rb b/spec/graphql/mutations/custom_emoji/create_spec.rb
new file mode 100644
index 00000000000..118c5d67188
--- /dev/null
+++ b/spec/graphql/mutations/custom_emoji/create_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::CustomEmoji::Create do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:user) { create(:user) }
+ let(:args) { { group_path: group.full_path, name: 'tanuki', url: 'https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png' } }
+
+ before do
+ group.add_developer(user)
+ end
+
+ describe '#resolve' do
+ subject(:resolve) { described_class.new(object: nil, context: { current_user: user }, field: nil).resolve(**args) }
+
+ it 'creates the custom emoji' do
+ expect { resolve }.to change(CustomEmoji, :count).by(1)
+ end
+
+ it 'sets the creator to be the user who added the emoji' do
+ resolve
+
+ expect(CustomEmoji.last.creator).to eq(user)
+ end
+ end
+end
diff --git a/spec/graphql/mutations/merge_requests/accept_spec.rb b/spec/graphql/mutations/merge_requests/accept_spec.rb
new file mode 100644
index 00000000000..db75c64a447
--- /dev/null
+++ b/spec/graphql/mutations/merge_requests/accept_spec.rb
@@ -0,0 +1,171 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::MergeRequests::Accept do
+ include AfterNextHelpers
+
+ let_it_be(:user) { create(:user) }
+ let(:project) { create(:project, :public, :repository) }
+
+ subject(:mutation) { described_class.new(context: context, object: nil, field: nil) }
+
+ let_it_be(:context) do
+ GraphQL::Query::Context.new(
+ query: OpenStruct.new(schema: GitlabSchema),
+ values: { current_user: user },
+ object: nil
+ )
+ end
+
+ before do
+ project.repository.expire_all_method_caches
+ end
+
+ describe '#resolve' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ def common_args(merge_request)
+ {
+ project_path: project.full_path,
+ iid: merge_request.iid.to_s,
+ sha: merge_request.diff_head_sha,
+ squash: false # default value
+ }
+ end
+
+ it 'merges the merge request' do
+ merge_request = create(:merge_request, source_project: project)
+
+ result = mutation.resolve(**common_args(merge_request))
+
+ expect(result).to include(errors: be_empty, merge_request: be_merged)
+ end
+
+ it 'rejects the mutation if the SHA is a mismatch' do
+ merge_request = create(:merge_request, source_project: project)
+ args = common_args(merge_request).merge(sha: 'not a good sha')
+
+ result = mutation.resolve(**args)
+
+ expect(result).not_to include(merge_request: be_merged)
+ expect(result).to include(errors: [described_class::SHA_MISMATCH])
+ end
+
+ it 'respects the merge commit message' do
+ merge_request = create(:merge_request, source_project: project)
+ args = common_args(merge_request).merge(commit_message: 'my super custom message')
+
+ result = mutation.resolve(**args)
+
+ expect(result).to include(merge_request: be_merged)
+ expect(project.repository.commit(merge_request.target_branch)).to have_attributes(
+ message: args[:commit_message]
+ )
+ end
+
+ it 'respects the squash flag' do
+ merge_request = create(:merge_request, source_project: project)
+ args = common_args(merge_request).merge(squash: true)
+
+ result = mutation.resolve(**args)
+
+ expect(result).to include(merge_request: be_merged)
+ expect(result[:merge_request].squash_commit_sha).to be_present
+ end
+
+ it 'respects the squash_commit_message argument' do
+ merge_request = create(:merge_request, source_project: project)
+ args = common_args(merge_request).merge(squash: true, squash_commit_message: 'squish')
+
+ result = mutation.resolve(**args)
+ sha = result[:merge_request].squash_commit_sha
+
+ expect(result).to include(merge_request: be_merged)
+ expect(project.repository.commit(sha)).to have_attributes(message: "squish\n")
+ end
+
+ it 'respects the should_remove_source_branch argument when true' do
+ b = project.repository.add_branch(user, generate(:branch), 'master')
+ merge_request = create(:merge_request, source_branch: b.name, source_project: project)
+ args = common_args(merge_request).merge(should_remove_source_branch: true)
+
+ expect(::MergeRequests::DeleteSourceBranchWorker).to receive(:perform_async)
+
+ result = mutation.resolve(**args)
+
+ expect(result).to include(merge_request: be_merged)
+ end
+
+ it 'respects the should_remove_source_branch argument when false' do
+ b = project.repository.add_branch(user, generate(:branch), 'master')
+ merge_request = create(:merge_request, source_branch: b.name, source_project: project)
+ args = common_args(merge_request).merge(should_remove_source_branch: false)
+
+ expect(::MergeRequests::DeleteSourceBranchWorker).not_to receive(:perform_async)
+
+ result = mutation.resolve(**args)
+
+ expect(result).to include(merge_request: be_merged)
+ end
+
+ it 'rejects unmergeable MRs' do
+ merge_request = create(:merge_request, :closed, source_project: project)
+ args = common_args(merge_request)
+
+ result = mutation.resolve(**args)
+
+ expect(result).not_to include(merge_request: be_merged)
+ expect(result).to include(errors: [described_class::NOT_MERGEABLE])
+ end
+
+ it 'rejects merges when we cannot validate the hooks' do
+ merge_request = create(:merge_request, source_project: project)
+ args = common_args(merge_request)
+ expect_next(::MergeRequests::MergeService)
+ .to receive(:hooks_validation_pass?).with(merge_request).and_return(false)
+
+ result = mutation.resolve(**args)
+
+ expect(result).not_to include(merge_request: be_merged)
+ expect(result).to include(errors: [described_class::HOOKS_VALIDATION_ERROR])
+ end
+
+ it 'rejects merges when the merge service returns an error' do
+ merge_request = create(:merge_request, source_project: project)
+ args = common_args(merge_request)
+ expect_next(::MergeRequests::MergeService)
+ .to receive(:execute).with(merge_request).and_return(:failed)
+
+ result = mutation.resolve(**args)
+
+ expect(result).not_to include(merge_request: be_merged)
+ expect(result).to include(errors: [described_class::MERGE_FAILED])
+ end
+
+ it 'rejects merges when the merge service raises merge error' do
+ merge_request = create(:merge_request, source_project: project)
+ args = common_args(merge_request)
+ expect_next(::MergeRequests::MergeService)
+ .to receive(:execute).and_raise(::MergeRequests::MergeBaseService::MergeError, 'boom')
+
+ result = mutation.resolve(**args)
+
+ expect(result).not_to include(merge_request: be_merged)
+ expect(result).to include(errors: ['boom'])
+ end
+
+ it "can use the MERGE_WHEN_PIPELINE_SUCCEEDS strategy" do
+ enum = ::Types::MergeStrategyEnum.values['MERGE_WHEN_PIPELINE_SUCCEEDS']
+ merge_request = create(:merge_request, :with_head_pipeline, source_project: project)
+ args = common_args(merge_request).merge(auto_merge_strategy: enum.value)
+
+ result = mutation.resolve(**args)
+
+ expect(result).not_to include(merge_request: be_merged)
+ expect(result).to include(errors: be_empty, merge_request: be_auto_merge_enabled)
+ end
+ end
+end
diff --git a/spec/graphql/mutations/release_asset_links/create_spec.rb b/spec/graphql/mutations/release_asset_links/create_spec.rb
new file mode 100644
index 00000000000..089bc3d3276
--- /dev/null
+++ b/spec/graphql/mutations/release_asset_links/create_spec.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::ReleaseAssetLinks::Create do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project, :private, :repository) }
+ let_it_be(:release) { create(:release, project: project, tag: 'v13.10') }
+ let_it_be(:reporter) { create(:user).tap { |u| project.add_reporter(u) } }
+ let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
+
+ let(:current_user) { developer }
+ let(:context) { { current_user: current_user } }
+ let(:project_path) { project.full_path }
+ let(:tag) { release.tag }
+ let(:name) { 'awesome-app.dmg' }
+ let(:url) { 'https://example.com/download/awesome-app.dmg' }
+ let(:filepath) { '/binaries/awesome-app.dmg' }
+
+ let(:args) do
+ {
+ project_path: project_path,
+ tag_name: tag,
+ name: name,
+ direct_asset_path: filepath,
+ url: url
+ }
+ end
+
+ let(:last_release_link) { release.links.last }
+
+ describe '#resolve' do
+ subject do
+ resolve(described_class, obj: project, args: args, ctx: context)
+ end
+
+ context 'when the user has access and no validation errors occur' do
+ it 'creates a new release asset link', :aggregate_failures do
+ expect(subject).to eq({
+ link: release.reload.links.first,
+ errors: []
+ })
+
+ expect(release.links.length).to be(1)
+
+ expect(last_release_link.name).to eq(name)
+ expect(last_release_link.url).to eq(url)
+ expect(last_release_link.filepath).to eq(filepath)
+ end
+ end
+
+ context "when the user doesn't have access to the project" do
+ let(:current_user) { reporter }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context "when the project doesn't exist" do
+ let(:project_path) { 'project/that/does/not/exist' }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context "when a validation errors occur" do
+ shared_examples 'returns errors-as-data' do |expected_messages|
+ it { expect(subject[:errors]).to eq(expected_messages) }
+ end
+
+ context "when the release doesn't exist" do
+ let(:tag) { "nonexistent-tag" }
+
+ it_behaves_like 'returns errors-as-data', ['Release with tag "nonexistent-tag" was not found']
+ end
+
+ context 'when the URL is badly formatted' do
+ let(:url) { 'badly-formatted-url' }
+
+ it_behaves_like 'returns errors-as-data', ["Url is blocked: Only allowed schemes are http, https, ftp"]
+ end
+
+ context 'when the name is not provided' do
+ let(:name) { '' }
+
+ it_behaves_like 'returns errors-as-data', ["Name can't be blank"]
+ end
+
+ context 'when the link already exists' do
+ let!(:existing_release_link) do
+ create(:release_link, release: release, name: name, url: url, filepath: filepath)
+ end
+
+ it_behaves_like 'returns errors-as-data', [
+ "Url has already been taken",
+ "Name has already been taken",
+ "Filepath has already been taken"
+ ]
+ end
+ end
+ end
+end
diff --git a/spec/graphql/mutations/release_asset_links/update_spec.rb b/spec/graphql/mutations/release_asset_links/update_spec.rb
new file mode 100644
index 00000000000..065089066f1
--- /dev/null
+++ b/spec/graphql/mutations/release_asset_links/update_spec.rb
@@ -0,0 +1,184 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::ReleaseAssetLinks::Update do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project, :private, :repository) }
+ let_it_be(:release) { create(:release, project: project, tag: 'v13.10') }
+ let_it_be(:reporter) { create(:user).tap { |u| project.add_reporter(u) } }
+ let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
+
+ let_it_be(:name) { 'link name' }
+ let_it_be(:url) { 'https://example.com/url' }
+ let_it_be(:filepath) { '/permanent/path' }
+ let_it_be(:link_type) { 'package' }
+
+ let_it_be(:release_link) do
+ create(:release_link,
+ release: release,
+ name: name,
+ url: url,
+ filepath: filepath,
+ link_type: link_type)
+ end
+
+ let(:current_user) { developer }
+ let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
+
+ let(:mutation_arguments) do
+ {
+ id: release_link.to_global_id
+ }
+ end
+
+ shared_examples 'no changes to the link except for the' do |except_for|
+ it 'does not change other link properties' do
+ expect(updated_link.name).to eq(name) unless except_for == :name
+ expect(updated_link.url).to eq(url) unless except_for == :url
+ expect(updated_link.filepath).to eq(filepath) unless except_for == :filepath
+ expect(updated_link.link_type).to eq(link_type) unless except_for == :link_type
+ end
+ end
+
+ shared_examples 'validation error with messages' do |messages|
+ it 'returns the updated link as nil' do
+ expect(updated_link).to be_nil
+ end
+
+ it 'returns a validation error' do
+ expect(subject[:errors]).to match_array(messages)
+ end
+ end
+
+ describe '#ready?' do
+ let(:current_user) { developer }
+
+ subject(:ready) do
+ mutation.ready?(**mutation_arguments)
+ end
+
+ context 'when link_type is included as an argument but is passed nil' do
+ let(:mutation_arguments) { super().merge(link_type: nil) }
+
+ it 'raises a validation error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'if the linkType argument is provided, it cannot be null')
+ end
+ end
+ end
+
+ describe '#resolve' do
+ subject(:resolve) do
+ mutation.resolve(**mutation_arguments)
+ end
+
+ let(:updated_link) { subject[:link] }
+
+ context 'when the current user has access to update the link' do
+ context 'name' do
+ let(:mutation_arguments) { super().merge(name: updated_name) }
+
+ context 'when a new name is provided' do
+ let(:updated_name) { 'Updated name' }
+
+ it 'updates the name' do
+ expect(updated_link.name).to eq(updated_name)
+ end
+
+ it_behaves_like 'no changes to the link except for the', :name
+ end
+
+ context 'when nil is provided' do
+ let(:updated_name) { nil }
+
+ it_behaves_like 'validation error with messages', ["Name can't be blank"]
+ end
+ end
+
+ context 'url' do
+ let(:mutation_arguments) { super().merge(url: updated_url) }
+
+ context 'when a new URL is provided' do
+ let(:updated_url) { 'https://example.com/updated/link' }
+
+ it 'updates the url' do
+ expect(updated_link.url).to eq(updated_url)
+ end
+
+ it_behaves_like 'no changes to the link except for the', :url
+ end
+
+ context 'when nil is provided' do
+ let(:updated_url) { nil }
+
+ it_behaves_like 'validation error with messages', ["Url can't be blank", "Url must be a valid URL"]
+ end
+ end
+
+ context 'filepath' do
+ let(:mutation_arguments) { super().merge(filepath: updated_filepath) }
+
+ context 'when a new filepath is provided' do
+ let(:updated_filepath) { '/updated/filepath' }
+
+ it 'updates the filepath' do
+ expect(updated_link.filepath).to eq(updated_filepath)
+ end
+
+ it_behaves_like 'no changes to the link except for the', :filepath
+ end
+
+ context 'when nil is provided' do
+ let(:updated_filepath) { nil }
+
+ it 'updates the filepath to nil' do
+ expect(updated_link.filepath).to be_nil
+ end
+ end
+ end
+
+ context 'link_type' do
+ let(:mutation_arguments) { super().merge(link_type: updated_link_type) }
+
+ context 'when a new link type is provided' do
+ let(:updated_link_type) { 'image' }
+
+ it 'updates the link type' do
+ expect(updated_link.link_type).to eq(updated_link_type)
+ end
+
+ it_behaves_like 'no changes to the link except for the', :link_type
+ end
+
+ # Test cases not included:
+ # - when nil is provided, because this validated by #ready?
+ # - when an invalid type is provided, because this is validated by the GraphQL schema
+ end
+ end
+
+ context 'when the current user does not have access to update the link' do
+ let(:current_user) { reporter }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context "when the link doesn't exist" do
+ let(:mutation_arguments) { super().merge(id: 'gid://gitlab/Releases::Link/999999') }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context "when the provided ID is invalid" do
+ let(:mutation_arguments) { super().merge(id: 'not-a-valid-gid') }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(::GraphQL::CoercionError)
+ end
+ end
+ end
+end
diff --git a/spec/graphql/mutations/user_callouts/create_spec.rb b/spec/graphql/mutations/user_callouts/create_spec.rb
new file mode 100644
index 00000000000..93f227d8b82
--- /dev/null
+++ b/spec/graphql/mutations/user_callouts/create_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::UserCallouts::Create do
+ let(:current_user) { create(:user) }
+ let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
+
+ describe '#resolve' do
+ subject(:resolve) { mutation.resolve(feature_name: feature_name) }
+
+ context 'when feature name is not supported' do
+ let(:feature_name) { 'not_supported' }
+
+ it 'does not create a user callout' do
+ expect { resolve }.not_to change(UserCallout, :count).from(0)
+ end
+
+ it 'returns error about feature name not being supported' do
+ expect(resolve[:errors]).to include("Feature name is not included in the list")
+ end
+ end
+
+ context 'when feature name is supported' do
+ let(:feature_name) { UserCallout.feature_names.each_key.first.to_s }
+
+ it 'creates a user callout' do
+ expect { resolve }.to change(UserCallout, :count).from(0).to(1)
+ end
+
+ it 'sets dismissed_at for the user callout' do
+ freeze_time do
+ expect(resolve[:user_callout].dismissed_at).to eq(Time.current)
+ end
+ end
+
+ it 'has no errors' do
+ expect(resolve[:errors]).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb b/spec/graphql/resolvers/admin/analytics/usage_trends/measurements_resolver_spec.rb
index 578d679ade4..269a1fb1758 100644
--- a/spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb
+++ b/spec/graphql/resolvers/admin/analytics/usage_trends/measurements_resolver_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Resolvers::Admin::Analytics::InstanceStatistics::MeasurementsResolver do
+RSpec.describe Resolvers::Admin::Analytics::UsageTrends::MeasurementsResolver do
include GraphqlHelpers
let_it_be(:admin_user) { create(:user, :admin) }
@@ -11,8 +11,8 @@ RSpec.describe Resolvers::Admin::Analytics::InstanceStatistics::MeasurementsReso
describe '#resolve' do
let_it_be(:user) { create(:user) }
- let_it_be(:project_measurement_new) { create(:instance_statistics_measurement, :project_count, recorded_at: 2.days.ago) }
- let_it_be(:project_measurement_old) { create(:instance_statistics_measurement, :project_count, recorded_at: 10.days.ago) }
+ let_it_be(:project_measurement_new) { create(:usage_trends_measurement, :project_count, recorded_at: 2.days.ago) }
+ let_it_be(:project_measurement_old) { create(:usage_trends_measurement, :project_count, recorded_at: 10.days.ago) }
let(:arguments) { { identifier: 'projects' } }
@@ -63,8 +63,8 @@ RSpec.describe Resolvers::Admin::Analytics::InstanceStatistics::MeasurementsReso
end
context 'when requesting pipeline counts by pipeline status' do
- let_it_be(:pipelines_succeeded_measurement) { create(:instance_statistics_measurement, :pipelines_succeeded_count, recorded_at: 2.days.ago) }
- let_it_be(:pipelines_skipped_measurement) { create(:instance_statistics_measurement, :pipelines_skipped_count, recorded_at: 2.days.ago) }
+ let_it_be(:pipelines_succeeded_measurement) { create(:usage_trends_measurement, :pipelines_succeeded_count, recorded_at: 2.days.ago) }
+ let_it_be(:pipelines_skipped_measurement) { create(:usage_trends_measurement, :pipelines_skipped_count, recorded_at: 2.days.ago) }
subject { resolve_measurements({ identifier: identifier }, { current_user: current_user }) }
diff --git a/spec/graphql/resolvers/alert_management/http_integrations_resolver_spec.rb b/spec/graphql/resolvers/alert_management/http_integrations_resolver_spec.rb
new file mode 100644
index 00000000000..2cd61dd7bcf
--- /dev/null
+++ b/spec/graphql/resolvers/alert_management/http_integrations_resolver_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::AlertManagement::HttpIntegrationsResolver do
+ include GraphqlHelpers
+
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:prometheus_integration) { create(:prometheus_service, project: project) }
+ let_it_be(:active_http_integration) { create(:alert_management_http_integration, project: project) }
+ let_it_be(:inactive_http_integration) { create(:alert_management_http_integration, :inactive, project: project) }
+ let_it_be(:other_proj_integration) { create(:alert_management_http_integration) }
+
+ subject { sync(resolve_http_integrations) }
+
+ before do
+ project.add_developer(developer)
+ project.add_maintainer(maintainer)
+ end
+
+ specify do
+ expect(described_class).to have_nullable_graphql_type(Types::AlertManagement::HttpIntegrationType.connection_type)
+ end
+
+ context 'user does not have permission' do
+ let(:current_user) { guest }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'user has developer permission' do
+ let(:current_user) { developer }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'user has maintainer permission' do
+ let(:current_user) { maintainer }
+
+ it { is_expected.to contain_exactly(active_http_integration) }
+ end
+
+ private
+
+ def resolve_http_integrations(args = {}, context = { current_user: current_user })
+ resolve(described_class, obj: project, ctx: context)
+ end
+end
diff --git a/spec/graphql/resolvers/board_resolver_spec.rb b/spec/graphql/resolvers/board_resolver_spec.rb
index c70c696005f..e9c51a536ee 100644
--- a/spec/graphql/resolvers/board_resolver_spec.rb
+++ b/spec/graphql/resolvers/board_resolver_spec.rb
@@ -14,8 +14,8 @@ RSpec.describe Resolvers::BoardResolver do
expect(resolve_board(id: dummy_gid)).to eq nil
end
- it 'calls Boards::ListService' do
- expect_next_instance_of(Boards::ListService) do |service|
+ it 'calls Boards::BoardsFinder' do
+ expect_next_instance_of(Boards::BoardsFinder) do |service|
expect(service).to receive(:execute).and_return([])
end
diff --git a/spec/graphql/resolvers/boards_resolver_spec.rb b/spec/graphql/resolvers/boards_resolver_spec.rb
index f121e8a4083..cb3bcb002ec 100644
--- a/spec/graphql/resolvers/boards_resolver_spec.rb
+++ b/spec/graphql/resolvers/boards_resolver_spec.rb
@@ -12,8 +12,8 @@ RSpec.describe Resolvers::BoardsResolver do
expect(resolve_boards).to eq []
end
- it 'calls Boards::ListService' do
- expect_next_instance_of(Boards::ListService) do |service|
+ it 'calls Boards::BoardsFinder' do
+ expect_next_instance_of(Boards::BoardsFinder) do |service|
expect(service).to receive(:execute)
end
diff --git a/spec/graphql/resolvers/branch_commit_resolver_spec.rb b/spec/graphql/resolvers/branch_commit_resolver_spec.rb
index 78d4959c3f9..346c9e01088 100644
--- a/spec/graphql/resolvers/branch_commit_resolver_spec.rb
+++ b/spec/graphql/resolvers/branch_commit_resolver_spec.rb
@@ -12,7 +12,11 @@ RSpec.describe Resolvers::BranchCommitResolver do
describe '#resolve' do
it 'resolves commit' do
- is_expected.to eq(repository.commits('master', limit: 1).last)
+ expect(sync(commit)).to eq(repository.commits('master', limit: 1).last)
+ end
+
+ it 'sets project container' do
+ expect(sync(commit).container).to eq(repository.project)
end
context 'when branch does not exist' do
@@ -22,5 +26,19 @@ RSpec.describe Resolvers::BranchCommitResolver do
is_expected.to be_nil
end
end
+
+ it 'is N+1 safe' do
+ commit_a = repository.commits('master', limit: 1).last
+ commit_b = repository.commits('spooky-stuff', limit: 1).last
+
+ commits = batch_sync(max_queries: 1) do
+ [
+ resolve(described_class, obj: branch),
+ resolve(described_class, obj: repository.find_branch('spooky-stuff'))
+ ]
+ end
+
+ expect(commits).to contain_exactly(commit_a, commit_b)
+ end
end
end
diff --git a/spec/graphql/resolvers/concerns/caching_array_resolver_spec.rb b/spec/graphql/resolvers/concerns/caching_array_resolver_spec.rb
index 5370f7a7433..e9e7fff6e6e 100644
--- a/spec/graphql/resolvers/concerns/caching_array_resolver_spec.rb
+++ b/spec/graphql/resolvers/concerns/caching_array_resolver_spec.rb
@@ -9,7 +9,6 @@ RSpec.describe ::CachingArrayResolver do
let_it_be(:admins) { create_list(:user, 4, admin: true) }
let(:query_context) { { current_user: admins.first } }
let(:max_page_size) { 10 }
- let(:field) { double('Field', max_page_size: max_page_size) }
let(:schema) do
Class.new(GitlabSchema) do
default_max_page_size 3
@@ -210,6 +209,6 @@ RSpec.describe ::CachingArrayResolver do
args = { is_admin: admin }
opts = resolver.field_options
allow(resolver).to receive(:field_options).and_return(opts.merge(max_page_size: max_page_size))
- resolve(resolver, args: args, ctx: query_context, schema: schema, field: field)
+ resolve(resolver, args: args, ctx: query_context, schema: schema)
end
end
diff --git a/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb b/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb
index 170a602fb0d..68badb8e333 100644
--- a/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb
+++ b/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorsResolver do
end
describe '#resolve' do
- context 'insufficient user permission' do
+ context 'with insufficient user permission' do
let(:user) { create(:user) }
it 'returns nil' do
@@ -29,7 +29,7 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorsResolver do
end
end
- context 'user with permission' do
+ context 'with sufficient permission' do
before do
project.add_developer(current_user)
@@ -93,7 +93,7 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorsResolver do
end
it 'returns an externally paginated array' do
- expect(resolve_errors).to be_a Gitlab::Graphql::ExternallyPaginatedArray
+ expect(resolve_errors).to be_a Gitlab::Graphql::Pagination::ExternallyPaginatedArrayConnection
end
end
end
diff --git a/spec/graphql/resolvers/group_labels_resolver_spec.rb b/spec/graphql/resolvers/group_labels_resolver_spec.rb
index ed94f12502a..3f4ad8760c0 100644
--- a/spec/graphql/resolvers/group_labels_resolver_spec.rb
+++ b/spec/graphql/resolvers/group_labels_resolver_spec.rb
@@ -42,7 +42,7 @@ RSpec.describe Resolvers::GroupLabelsResolver do
context 'without parent' do
it 'returns no labels' do
- expect(resolve_labels(nil)).to eq(Label.none)
+ expect(resolve_labels(nil)).to be_empty
end
end
diff --git a/spec/graphql/resolvers/group_packages_resolver_spec.rb b/spec/graphql/resolvers/group_packages_resolver_spec.rb
new file mode 100644
index 00000000000..59438b8d5ad
--- /dev/null
+++ b/spec/graphql/resolvers/group_packages_resolver_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::GroupPackagesResolver do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group, :public) }
+ let_it_be(:project) { create(:project, :public, group: group) }
+ let_it_be(:package) { create(:package, project: project) }
+
+ describe '#resolve' do
+ subject(:packages) { resolve(described_class, ctx: { current_user: user }, obj: group) }
+
+ it { is_expected.to contain_exactly(package) }
+ end
+end
diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb
index 8980f4aa19d..6e802bf7d25 100644
--- a/spec/graphql/resolvers/issues_resolver_spec.rb
+++ b/spec/graphql/resolvers/issues_resolver_spec.rb
@@ -264,7 +264,7 @@ RSpec.describe Resolvers::IssuesResolver do
end
it 'finds a specific issue with iid', :request_store do
- result = batch_sync(max_queries: 4) { resolve_issues(iid: issue1.iid) }
+ result = batch_sync(max_queries: 4) { resolve_issues(iid: issue1.iid).to_a }
expect(result).to contain_exactly(issue1)
end
@@ -281,7 +281,7 @@ RSpec.describe Resolvers::IssuesResolver do
it 'finds a specific issue with iids', :request_store do
result = batch_sync(max_queries: 4) do
- resolve_issues(iids: [issue1.iid])
+ resolve_issues(iids: [issue1.iid]).to_a
end
expect(result).to contain_exactly(issue1)
@@ -290,7 +290,7 @@ RSpec.describe Resolvers::IssuesResolver do
it 'finds multiple issues with iids' do
create(:issue, project: project, author: current_user)
- expect(batch_sync { resolve_issues(iids: [issue1.iid, issue2.iid]) })
+ expect(batch_sync { resolve_issues(iids: [issue1.iid, issue2.iid]).to_a })
.to contain_exactly(issue1, issue2)
end
@@ -302,7 +302,7 @@ RSpec.describe Resolvers::IssuesResolver do
create(:issue, project: another_project, iid: iid)
end
- expect(batch_sync { resolve_issues(iids: iids) }).to contain_exactly(issue1, issue2)
+ expect(batch_sync { resolve_issues(iids: iids).to_a }).to contain_exactly(issue1, issue2)
end
end
end
diff --git a/spec/graphql/resolvers/labels_resolver_spec.rb b/spec/graphql/resolvers/labels_resolver_spec.rb
index 3d027a6c8d5..be6229553d7 100644
--- a/spec/graphql/resolvers/labels_resolver_spec.rb
+++ b/spec/graphql/resolvers/labels_resolver_spec.rb
@@ -42,50 +42,36 @@ RSpec.describe Resolvers::LabelsResolver do
context 'without parent' do
it 'returns no labels' do
- expect(resolve_labels(nil)).to eq(Label.none)
+ expect(resolve_labels(nil)).to be_empty
end
end
- context 'at project level' do
+ context 'with a parent project' do
before_all do
group.add_developer(current_user)
end
- # because :include_ancestor_groups, :include_descendant_groups, :only_group_labels default to false
- # the `nil` value would be equivalent to passing in `false` so just check for `nil` option
- where(:include_ancestor_groups, :include_descendant_groups, :only_group_labels, :search_term, :test) do
- nil | nil | nil | nil | -> { expect(subject).to contain_exactly(label1, label2, subgroup_label1, subgroup_label2) }
- nil | nil | true | nil | -> { expect(subject).to contain_exactly(label1, label2, subgroup_label1, subgroup_label2) }
- nil | true | nil | nil | -> { expect(subject).to contain_exactly(label1, label2, subgroup_label1, subgroup_label2, sub_subgroup_label1, sub_subgroup_label2) }
- nil | true | true | nil | -> { expect(subject).to contain_exactly(label1, label2, subgroup_label1, subgroup_label2, sub_subgroup_label1, sub_subgroup_label2) }
- true | nil | nil | nil | -> { expect(subject).to contain_exactly(label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2) }
- true | nil | true | nil | -> { expect(subject).to contain_exactly(label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2) }
- true | true | nil | nil | -> { expect(subject).to contain_exactly(label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2, sub_subgroup_label1, sub_subgroup_label2) }
- true | true | true | nil | -> { expect(subject).to contain_exactly(label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2, sub_subgroup_label1, sub_subgroup_label2) }
-
- nil | nil | nil | 'new' | -> { expect(subject).to contain_exactly(label2, subgroup_label2) }
- nil | nil | true | 'new' | -> { expect(subject).to contain_exactly(label2, subgroup_label2) }
- nil | true | nil | 'new' | -> { expect(subject).to contain_exactly(label2, subgroup_label2, sub_subgroup_label2) }
- nil | true | true | 'new' | -> { expect(subject).to contain_exactly(label2, subgroup_label2, sub_subgroup_label2) }
- true | nil | nil | 'new' | -> { expect(subject).to contain_exactly(label2, group_label2, subgroup_label2) }
- true | nil | true | 'new' | -> { expect(subject).to contain_exactly(label2, group_label2, subgroup_label2) }
- true | true | nil | 'new' | -> { expect(subject).to contain_exactly(label2, group_label2, subgroup_label2, sub_subgroup_label2) }
- true | true | true | 'new' | -> { expect(subject).to contain_exactly(label2, group_label2, subgroup_label2, sub_subgroup_label2) }
+ # the expected result is wrapped in a lambda to get around the phase restrictions of RSpec::Parameterized
+ where(:include_ancestor_groups, :search_term, :expected_labels) do
+ nil | nil | -> { [label1, label2, subgroup_label1, subgroup_label2] }
+ false | nil | -> { [label1, label2, subgroup_label1, subgroup_label2] }
+ true | nil | -> { [label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2] }
+ nil | 'new' | -> { [label2, subgroup_label2] }
+ false | 'new' | -> { [label2, subgroup_label2] }
+ true | 'new' | -> { [label2, group_label2, subgroup_label2] }
end
with_them do
let(:params) do
{
include_ancestor_groups: include_ancestor_groups,
- include_descendant_groups: include_descendant_groups,
- only_group_labels: only_group_labels,
search_term: search_term
}
end
subject { resolve_labels(project, params) }
- it { self.instance_exec(&test) }
+ specify { expect(subject).to match_array(instance_exec(&expected_labels)) }
end
end
end
diff --git a/spec/graphql/resolvers/merge_requests_resolver_spec.rb b/spec/graphql/resolvers/merge_requests_resolver_spec.rb
index c5c368fc88f..7dd968d90a8 100644
--- a/spec/graphql/resolvers/merge_requests_resolver_spec.rb
+++ b/spec/graphql/resolvers/merge_requests_resolver_spec.rb
@@ -69,7 +69,7 @@ RSpec.describe Resolvers::MergeRequestsResolver do
it 'batch-resolves by target project full path and IIDS', :request_store do
result = batch_sync(max_queries: queries_per_project) do
- resolve_mr(project, iids: [iid_1, iid_2])
+ resolve_mr(project, iids: [iid_1, iid_2]).to_a
end
expect(result).to contain_exactly(merge_request_1, merge_request_2)
diff --git a/spec/graphql/resolvers/namespace_projects_resolver_spec.rb b/spec/graphql/resolvers/namespace_projects_resolver_spec.rb
index 4ad8f99219f..147a02e1d79 100644
--- a/spec/graphql/resolvers/namespace_projects_resolver_spec.rb
+++ b/spec/graphql/resolvers/namespace_projects_resolver_spec.rb
@@ -6,6 +6,18 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do
include GraphqlHelpers
let(:current_user) { create(:user) }
+ let(:include_subgroups) { true }
+ let(:sort) { nil }
+ let(:search) { nil }
+ let(:ids) { nil }
+ let(:args) do
+ {
+ include_subgroups: include_subgroups,
+ sort: sort,
+ search: search,
+ ids: ids
+ }
+ end
context "with a group" do
let(:group) { create(:group) }
@@ -27,7 +39,7 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do
end
it 'finds all projects including the subgroups' do
- expect(resolve_projects(include_subgroups: true, sort: nil, search: nil)).to contain_exactly(project1, project2, nested_project)
+ expect(resolve_projects(args)).to contain_exactly(project1, project2, nested_project)
end
context 'with an user namespace' do
@@ -38,7 +50,7 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do
end
it 'finds all projects including the subgroups' do
- expect(resolve_projects(include_subgroups: true, sort: nil, search: nil)).to contain_exactly(project1, project2)
+ expect(resolve_projects(args)).to contain_exactly(project1, project2)
end
end
end
@@ -48,6 +60,9 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do
let(:project_2) { create(:project, name: 'Test Project', path: 'test-project', namespace: namespace) }
let(:project_3) { create(:project, name: 'Test', path: 'test', namespace: namespace) }
+ let(:sort) { :similarity }
+ let(:search) { 'test' }
+
before do
project_1.add_developer(current_user)
project_2.add_developer(current_user)
@@ -55,7 +70,7 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do
end
it 'returns projects ordered by similarity to the search input' do
- projects = resolve_projects(include_subgroups: true, sort: :similarity, search: 'test')
+ projects = resolve_projects(args)
project_names = projects.map { |proj| proj['name'] }
expect(project_names.first).to eq('Test')
@@ -63,15 +78,17 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do
end
it 'filters out result that do not match the search input' do
- projects = resolve_projects(include_subgroups: true, sort: :similarity, search: 'test')
+ projects = resolve_projects(args)
project_names = projects.map { |proj| proj['name'] }
expect(project_names).not_to include('Project')
end
context 'when `search` parameter is not given' do
+ let(:search) { nil }
+
it 'returns projects not ordered by similarity' do
- projects = resolve_projects(include_subgroups: true, sort: :similarity, search: nil)
+ projects = resolve_projects(args)
project_names = projects.map { |proj| proj['name'] }
expect(project_names.first).not_to eq('Test')
@@ -79,14 +96,40 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do
end
context 'when only search term is given' do
+ let(:sort) { nil }
+ let(:search) { 'test' }
+
it 'filters out result that do not match the search input, but does not sort them' do
- projects = resolve_projects(include_subgroups: true, sort: :nil, search: 'test')
+ projects = resolve_projects(args)
project_names = projects.map { |proj| proj['name'] }
expect(project_names).to contain_exactly('Test', 'Test Project')
end
end
end
+
+ context 'ids filtering' do
+ subject(:projects) { resolve_projects(args) }
+
+ let(:include_subgroups) { false }
+ let(:project_3) { create(:project, name: 'Project', path: 'project', namespace: namespace) }
+
+ context 'when ids is provided' do
+ let(:ids) { [project_3.to_global_id.to_s] }
+
+ it 'returns matching project' do
+ expect(projects).to contain_exactly(project_3)
+ end
+ end
+
+ context 'when ids is nil' do
+ let(:ids) { nil }
+
+ it 'returns all projects' do
+ expect(projects).to contain_exactly(project1, project2, project_3)
+ end
+ end
+ end
end
context "when passing a non existent, batch loaded namespace" do
@@ -108,7 +151,7 @@ RSpec.describe Resolvers::NamespaceProjectsResolver do
expect(field.to_graphql.complexity.call({}, { include_subgroups: true }, 1)).to eq 24
end
- def resolve_projects(args = { include_subgroups: false, sort: nil, search: nil }, context = { current_user: current_user })
+ def resolve_projects(args = { include_subgroups: false, sort: nil, search: nil, ids: nil }, context = { current_user: current_user })
resolve(described_class, obj: namespace, args: args, ctx: context)
end
end
diff --git a/spec/graphql/resolvers/packages_resolver_spec.rb b/spec/graphql/resolvers/project_packages_resolver_spec.rb
index bc0588daf7f..c8105ed2a38 100644
--- a/spec/graphql/resolvers/packages_resolver_spec.rb
+++ b/spec/graphql/resolvers/project_packages_resolver_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Resolvers::PackagesResolver do
+RSpec.describe Resolvers::ProjectPackagesResolver do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
diff --git a/spec/graphql/resolvers/project_pipeline_resolver_spec.rb b/spec/graphql/resolvers/project_pipeline_resolver_spec.rb
index b852b349d4f..69127c4b061 100644
--- a/spec/graphql/resolvers/project_pipeline_resolver_spec.rb
+++ b/spec/graphql/resolvers/project_pipeline_resolver_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Resolvers::ProjectPipelineResolver do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
- let_it_be(:pipeline) { create(:ci_pipeline, project: project, iid: '1234') }
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project, iid: '1234', sha: 'sha') }
let_it_be(:other_pipeline) { create(:ci_pipeline) }
let(:current_user) { create(:user) }
@@ -30,7 +30,15 @@ RSpec.describe Resolvers::ProjectPipelineResolver do
expect(result).to eq(pipeline)
end
- it 'keeps the queries under the threshold' do
+ it 'resolves pipeline for the passed sha' do
+ result = batch_sync do
+ resolve_pipeline(project, { sha: 'sha' })
+ end
+
+ expect(result).to eq(pipeline)
+ end
+
+ it 'keeps the queries under the threshold for iid' do
create(:ci_pipeline, project: project, iid: '1235')
control = ActiveRecord::QueryRecorder.new do
@@ -45,6 +53,21 @@ RSpec.describe Resolvers::ProjectPipelineResolver do
end.not_to exceed_query_limit(control)
end
+ it 'keeps the queries under the threshold for sha' do
+ create(:ci_pipeline, project: project, sha: 'sha2')
+
+ control = ActiveRecord::QueryRecorder.new do
+ batch_sync { resolve_pipeline(project, { sha: 'sha' }) }
+ end
+
+ expect do
+ batch_sync do
+ resolve_pipeline(project, { sha: 'sha' })
+ resolve_pipeline(project, { sha: 'sha2' })
+ end
+ end.not_to exceed_query_limit(control)
+ end
+
it 'does not resolve a pipeline outside the project' do
result = batch_sync do
resolve_pipeline(other_pipeline.project, { iid: '1234' })
@@ -53,8 +76,14 @@ RSpec.describe Resolvers::ProjectPipelineResolver do
expect(result).to be_nil
end
- it 'errors when no iid is passed' do
- expect { resolve_pipeline(project, {}) }.to raise_error(ArgumentError)
+ it 'errors when no iid or sha is passed' do
+ expect { resolve_pipeline(project, {}) }
+ .to raise_error(Gitlab::Graphql::Errors::ArgumentError)
+ end
+
+ it 'errors when both iid and sha are passed' do
+ expect { resolve_pipeline(project, { iid: '1234', sha: 'sha' }) }
+ .to raise_error(Gitlab::Graphql::Errors::ArgumentError)
end
context 'when the pipeline is a dangling pipeline' do
diff --git a/spec/graphql/resolvers/release_milestones_resolver_spec.rb b/spec/graphql/resolvers/release_milestones_resolver_spec.rb
index f05069998d0..a5a523859f9 100644
--- a/spec/graphql/resolvers/release_milestones_resolver_spec.rb
+++ b/spec/graphql/resolvers/release_milestones_resolver_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe Resolvers::ReleaseMilestonesResolver do
describe '#resolve' do
it "uses offset-pagination" do
- expect(resolved).to be_a(::Gitlab::Graphql::Pagination::OffsetPaginatedRelation)
+ expect(resolved).to be_a(::Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection)
end
it "includes the release's milestones in the returned OffsetActiveRecordRelationConnection" do
diff --git a/spec/graphql/types/access_level_enum_spec.rb b/spec/graphql/types/access_level_enum_spec.rb
index eeb10a50b7e..1b379c56ff9 100644
--- a/spec/graphql/types/access_level_enum_spec.rb
+++ b/spec/graphql/types/access_level_enum_spec.rb
@@ -6,6 +6,6 @@ RSpec.describe GitlabSchema.types['AccessLevelEnum'] do
specify { expect(described_class.graphql_name).to eq('AccessLevelEnum') }
it 'exposes all the existing access levels' do
- expect(described_class.values.keys).to match_array(%w[NO_ACCESS GUEST REPORTER DEVELOPER MAINTAINER OWNER])
+ expect(described_class.values.keys).to match_array(%w[NO_ACCESS MINIMAL_ACCESS GUEST REPORTER DEVELOPER MAINTAINER OWNER])
end
end
diff --git a/spec/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum_spec.rb b/spec/graphql/types/admin/analytics/usage_trends/measurement_identifier_enum_spec.rb
index 8a7408224a2..91851c11dc8 100644
--- a/spec/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum_spec.rb
+++ b/spec/graphql/types/admin/analytics/usage_trends/measurement_identifier_enum_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe GitlabSchema.types['MeasurementIdentifier'] do
it 'exposes all the existing identifier values' do
ee_only_identifiers = %w[billable_users]
- identifiers = Analytics::InstanceStatistics::Measurement.identifiers.keys.reject do |x|
+ identifiers = Analytics::UsageTrends::Measurement.identifiers.keys.reject do |x|
ee_only_identifiers.include?(x)
end.map(&:upcase)
diff --git a/spec/graphql/types/admin/analytics/instance_statistics/measurement_type_spec.rb b/spec/graphql/types/admin/analytics/usage_trends/measurement_type_spec.rb
index ffb1a0f30c9..c50092d7f0e 100644
--- a/spec/graphql/types/admin/analytics/instance_statistics/measurement_type_spec.rb
+++ b/spec/graphql/types/admin/analytics/usage_trends/measurement_type_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GitlabSchema.types['InstanceStatisticsMeasurement'] do
+RSpec.describe GitlabSchema.types['UsageTrendsMeasurement'] do
subject { described_class }
it { is_expected.to have_graphql_field(:recorded_at) }
@@ -10,13 +10,13 @@ RSpec.describe GitlabSchema.types['InstanceStatisticsMeasurement'] do
it { is_expected.to have_graphql_field(:count) }
describe 'authorization' do
- let_it_be(:measurement) { create(:instance_statistics_measurement, :project_count) }
+ let_it_be(:measurement) { create(:usage_trends_measurement, :project_count) }
let(:user) { create(:user) }
let(:query) do
<<~GRAPHQL
- query instanceStatisticsMeasurements($identifier: MeasurementIdentifier!) {
- instanceStatisticsMeasurements(identifier: $identifier) {
+ query usageTrendsMeasurements($identifier: MeasurementIdentifier!) {
+ usageTrendsMeasurements(identifier: $identifier) {
nodes {
count
identifier
@@ -36,7 +36,7 @@ RSpec.describe GitlabSchema.types['InstanceStatisticsMeasurement'] do
context 'when the user is not admin' do
it 'returns no data' do
- expect(subject.dig('data', 'instanceStatisticsMeasurements')).to be_nil
+ expect(subject.dig('data', 'usageTrendsMeasurements')).to be_nil
end
end
@@ -48,7 +48,7 @@ RSpec.describe GitlabSchema.types['InstanceStatisticsMeasurement'] do
end
it 'returns data' do
- expect(subject.dig('data', 'instanceStatisticsMeasurements', 'nodes')).not_to be_empty
+ expect(subject.dig('data', 'usageTrendsMeasurements', 'nodes')).not_to be_empty
end
end
end
diff --git a/spec/graphql/types/alert_management/alert_type_spec.rb b/spec/graphql/types/alert_management/alert_type_spec.rb
index 82b48a20708..9ff01418c9a 100644
--- a/spec/graphql/types/alert_management/alert_type_spec.rb
+++ b/spec/graphql/types/alert_management/alert_type_spec.rb
@@ -10,7 +10,8 @@ RSpec.describe GitlabSchema.types['AlertManagementAlert'] do
it 'exposes the expected fields' do
expected_fields = %i[
iid
- issue_iid
+ issueIid
+ issue
title
description
severity
diff --git a/spec/graphql/types/base_argument_spec.rb b/spec/graphql/types/base_argument_spec.rb
new file mode 100644
index 00000000000..61e0179ff21
--- /dev/null
+++ b/spec/graphql/types/base_argument_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::BaseArgument do
+ include_examples 'Gitlab-style deprecations' do
+ let_it_be(:field) do
+ Types::BaseField.new(name: 'field', type: String, null: true)
+ end
+
+ let(:base_args) { { name: 'test', type: String, required: false, owner: field } }
+
+ def subject(args = {})
+ described_class.new(**base_args.merge(args))
+ end
+ end
+end
diff --git a/spec/graphql/types/board_type_spec.rb b/spec/graphql/types/board_type_spec.rb
index 5ea87d5f473..dca3cfd8aaf 100644
--- a/spec/graphql/types/board_type_spec.rb
+++ b/spec/graphql/types/board_type_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['Board'] do
specify { expect(described_class.graphql_name).to eq('Board') }
- specify { expect(described_class).to require_graphql_authorizations(:read_board) }
+ specify { expect(described_class).to require_graphql_authorizations(:read_issue_board) }
it 'has specific fields' do
expected_fields = %w[id name web_url web_path]
diff --git a/spec/graphql/types/ci/job_type_spec.rb b/spec/graphql/types/ci/job_type_spec.rb
index e277916f5cb..25f626cea0f 100644
--- a/spec/graphql/types/ci/job_type_spec.rb
+++ b/spec/graphql/types/ci/job_type_spec.rb
@@ -14,6 +14,8 @@ RSpec.describe Types::Ci::JobType do
detailedStatus
scheduledAt
artifacts
+ finished_at
+ duration
]
expect(described_class).to have_graphql_fields(*expected_fields)
diff --git a/spec/graphql/types/ci/pipeline_type_spec.rb b/spec/graphql/types/ci/pipeline_type_spec.rb
index 2a1e030480d..e0e84a1b635 100644
--- a/spec/graphql/types/ci/pipeline_type_spec.rb
+++ b/spec/graphql/types/ci/pipeline_type_spec.rb
@@ -12,11 +12,11 @@ RSpec.describe Types::Ci::PipelineType do
id iid sha before_sha status detailed_status config_source duration
coverage created_at updated_at started_at finished_at committed_at
stages user retryable cancelable jobs source_job downstream
- upstream path project active user_permissions warnings
+ upstream path project active user_permissions warnings commit_path
]
if Gitlab.ee?
- expected_fields << 'security_report_summary'
+ expected_fields += %w[security_report_summary security_report_findings]
end
expect(described_class).to have_graphql_fields(*expected_fields)
diff --git a/spec/graphql/types/global_id_type_spec.rb b/spec/graphql/types/global_id_type_spec.rb
index cb129868f7e..8eb023ad2a3 100644
--- a/spec/graphql/types/global_id_type_spec.rb
+++ b/spec/graphql/types/global_id_type_spec.rb
@@ -5,7 +5,6 @@ require 'spec_helper'
RSpec.describe Types::GlobalIDType do
let_it_be(:project) { create(:project) }
let(:gid) { project.to_global_id }
- let(:foreign_gid) { GlobalID.new(::URI::GID.build(app: 'otherapp', model_name: 'Project', model_id: project.id, params: nil)) }
it 'is has the correct name' do
expect(described_class.to_graphql.name).to eq('GlobalID')
@@ -41,16 +40,18 @@ RSpec.describe Types::GlobalIDType do
it 'rejects invalid input' do
expect { described_class.coerce_isolated_input('not valid') }
- .to raise_error(GraphQL::CoercionError)
+ .to raise_error(GraphQL::CoercionError, /not a valid Global ID/)
end
it 'rejects nil' do
expect(described_class.coerce_isolated_input(nil)).to be_nil
end
- it 'rejects gids from different apps' do
- expect { described_class.coerce_isolated_input(foreign_gid) }
- .to raise_error(GraphQL::CoercionError)
+ it 'rejects GIDs from different apps' do
+ invalid_gid = GlobalID.new(::URI::GID.build(app: 'otherapp', model_name: 'Project', model_id: project.id, params: nil))
+
+ expect { described_class.coerce_isolated_input(invalid_gid) }
+ .to raise_error(GraphQL::CoercionError, /is not a Gitlab Global ID/)
end
end
@@ -79,14 +80,22 @@ RSpec.describe Types::GlobalIDType do
let(:gid) { build_stubbed(:user).to_global_id }
it 'raises errors when coercing results' do
- expect { type.coerce_isolated_result(gid) }.to raise_error(GraphQL::CoercionError)
+ expect { type.coerce_isolated_result(gid) }
+ .to raise_error(GraphQL::CoercionError, /Expected a Project ID/)
end
it 'will not coerce invalid input, even if its a valid GID' do
expect { type.coerce_isolated_input(gid.to_s) }
- .to raise_error(GraphQL::CoercionError)
+ .to raise_error(GraphQL::CoercionError, /does not represent an instance of Project/)
end
end
+
+ it 'handles GIDs for invalid resource names gracefully' do
+ invalid_gid = GlobalID.new(::URI::GID.build(app: GlobalID.app, model_name: 'invalid', model_id: 1, params: nil))
+
+ expect { type.coerce_isolated_input(invalid_gid) }
+ .to raise_error(GraphQL::CoercionError, /does not represent an instance of Project/)
+ end
end
describe 'a parameterized type with a namespace' do
diff --git a/spec/graphql/types/group_type_spec.rb b/spec/graphql/types/group_type_spec.rb
index bba702ba3e9..ef11e3d309c 100644
--- a/spec/graphql/types/group_type_spec.rb
+++ b/spec/graphql/types/group_type_spec.rb
@@ -18,6 +18,7 @@ RSpec.describe GitlabSchema.types['Group'] do
two_factor_grace_period auto_devops_enabled emails_disabled
mentions_disabled parent boards milestones group_members
merge_requests container_repositories container_repositories_count
+ packages
]
expect(described_class).to include_graphql_fields(*expected_fields)
diff --git a/spec/graphql/types/label_type_spec.rb b/spec/graphql/types/label_type_spec.rb
index 6a999a2e925..427b5d2dcef 100644
--- a/spec/graphql/types/label_type_spec.rb
+++ b/spec/graphql/types/label_type_spec.rb
@@ -3,7 +3,16 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['Label'] do
it 'has the correct fields' do
- expected_fields = [:id, :description, :description_html, :title, :color, :text_color]
+ expected_fields = [
+ :id,
+ :description,
+ :description_html,
+ :title,
+ :color,
+ :text_color,
+ :created_at,
+ :updated_at
+ ]
expect(described_class).to have_graphql_fields(*expected_fields)
end
diff --git a/spec/graphql/types/merge_request_type_spec.rb b/spec/graphql/types/merge_request_type_spec.rb
index 63d288934e5..3314ea62324 100644
--- a/spec/graphql/types/merge_request_type_spec.rb
+++ b/spec/graphql/types/merge_request_type_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do
merge_error allow_collaboration should_be_rebased rebase_commit_sha
rebase_in_progress default_merge_commit_message
merge_ongoing mergeable_discussions_state web_url
- source_branch_exists target_branch_exists
+ source_branch_exists target_branch_exists diverged_from_target_branch
upvotes downvotes head_pipeline pipelines task_completion_status
milestone assignees reviewers participants subscribed labels discussion_locked time_estimate
total_time_spent reference author merged_at commit_count current_user_todos
@@ -77,4 +77,33 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do
end
end
end
+
+ describe '#diverged_from_target_branch' do
+ subject(:execute_query) { GitlabSchema.execute(query, context: { current_user: current_user }).as_json }
+
+ let!(:merge_request) { create(:merge_request, target_project: project, source_project: project) }
+ let(:project) { create(:project, :public) }
+ let(:current_user) { create :admin }
+ let(:query) do
+ %(
+ {
+ project(fullPath: "#{project.full_path}") {
+ mergeRequests {
+ nodes {
+ divergedFromTargetBranch
+ }
+ }
+ }
+ }
+ )
+ end
+
+ it 'delegates the diverged_from_target_branch? call to the merge request entity' do
+ expect_next_found_instance_of(MergeRequest) do |instance|
+ expect(instance).to receive(:diverged_from_target_branch?)
+ end
+
+ execute_query
+ end
+ end
end
diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb
index fea0a3bd37e..cb8e875dbf4 100644
--- a/spec/graphql/types/query_type_spec.rb
+++ b/spec/graphql/types/query_type_spec.rb
@@ -21,7 +21,7 @@ RSpec.describe GitlabSchema.types['Query'] do
user
users
issue
- instance_statistics_measurements
+ usage_trends_measurements
runner_platforms
]
@@ -65,11 +65,11 @@ RSpec.describe GitlabSchema.types['Query'] do
end
end
- describe 'instance_statistics_measurements field' do
- subject { described_class.fields['instanceStatisticsMeasurements'] }
+ describe 'usage_trends_measurements field' do
+ subject { described_class.fields['usageTrendsMeasurements'] }
- it 'returns instance statistics measurements' do
- is_expected.to have_graphql_type(Types::Admin::Analytics::InstanceStatistics::MeasurementType.connection_type)
+ it 'returns usage trends measurements' do
+ is_expected.to have_graphql_type(Types::Admin::Analytics::UsageTrends::MeasurementType.connection_type)
end
end
diff --git a/spec/graphql/types/snippet_type_spec.rb b/spec/graphql/types/snippet_type_spec.rb
index e73665a1b1d..4d827186a9b 100644
--- a/spec/graphql/types/snippet_type_spec.rb
+++ b/spec/graphql/types/snippet_type_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['Snippet'] do
+ include GraphqlHelpers
+
let_it_be(:user) { create(:user) }
it 'has the correct fields' do
@@ -25,6 +27,14 @@ RSpec.describe GitlabSchema.types['Snippet'] do
end
end
+ describe '#user_permissions' do
+ let_it_be(:snippet) { create(:personal_snippet, :repository, :public, author: user) }
+
+ it 'can resolve the snippet permissions' do
+ expect(resolve_field(:user_permissions, snippet)).to eq(snippet)
+ end
+ end
+
context 'when restricted visibility level is set to public' do
let_it_be(:snippet) { create(:personal_snippet, :repository, :public, author: user) }
diff --git a/spec/graphql/types/snippets/blob_type_spec.rb b/spec/graphql/types/snippets/blob_type_spec.rb
index bfac08f40d3..60c0db8e551 100644
--- a/spec/graphql/types/snippets/blob_type_spec.rb
+++ b/spec/graphql/types/snippets/blob_type_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['SnippetBlob'] do
+ include GraphqlHelpers
+
it 'has the correct fields' do
expected_fields = [:rich_data, :plain_data,
:raw_path, :size, :binary, :name, :path,
@@ -12,16 +14,37 @@ RSpec.describe GitlabSchema.types['SnippetBlob'] do
expect(described_class).to have_graphql_fields(*expected_fields)
end
- specify { expect(described_class.fields['richData'].type).not_to be_non_null }
- specify { expect(described_class.fields['plainData'].type).not_to be_non_null }
- specify { expect(described_class.fields['rawPath'].type).to be_non_null }
- specify { expect(described_class.fields['size'].type).to be_non_null }
- specify { expect(described_class.fields['binary'].type).to be_non_null }
- specify { expect(described_class.fields['name'].type).not_to be_non_null }
- specify { expect(described_class.fields['path'].type).not_to be_non_null }
- specify { expect(described_class.fields['simpleViewer'].type).to be_non_null }
- specify { expect(described_class.fields['richViewer'].type).not_to be_non_null }
- specify { expect(described_class.fields['mode'].type).not_to be_non_null }
- specify { expect(described_class.fields['externalStorage'].type).not_to be_non_null }
- specify { expect(described_class.fields['renderedAsText'].type).to be_non_null }
+ let_it_be(:nullity) do
+ {
+ 'richData' => be_nullable,
+ 'plainData' => be_nullable,
+ 'rawPath' => be_non_null,
+ 'size' => be_non_null,
+ 'binary' => be_non_null,
+ 'name' => be_nullable,
+ 'path' => be_nullable,
+ 'simpleViewer' => be_non_null,
+ 'richViewer' => be_nullable,
+ 'mode' => be_nullable,
+ 'externalStorage' => be_nullable,
+ 'renderedAsText' => be_non_null
+ }
+ end
+
+ let_it_be(:blob) { create(:snippet, :public, :repository).blobs.first }
+
+ shared_examples 'a field from the snippet blob presenter' do |field|
+ it "resolves using the presenter", :request_store do
+ presented = SnippetBlobPresenter.new(blob)
+
+ expect(resolve_field(field, blob)).to eq(presented.try(field.method_sym))
+ end
+ end
+
+ described_class.fields.each_value do |field|
+ describe field.graphql_name do
+ it_behaves_like 'a field from the snippet blob presenter', field
+ specify { expect(field.type).to match(nullity.fetch(field.graphql_name)) }
+ end
+ end
end
diff --git a/spec/graphql/types/user_callout_feature_name_enum_spec.rb b/spec/graphql/types/user_callout_feature_name_enum_spec.rb
new file mode 100644
index 00000000000..28755e1301b
--- /dev/null
+++ b/spec/graphql/types/user_callout_feature_name_enum_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['UserCalloutFeatureNameEnum'] do
+ specify { expect(described_class.graphql_name).to eq('UserCalloutFeatureNameEnum') }
+
+ it 'exposes all the existing user callout feature names' do
+ expect(described_class.values.keys).to match_array(::UserCallout.feature_names.keys.map(&:upcase))
+ end
+end
diff --git a/spec/graphql/types/user_callout_type_spec.rb b/spec/graphql/types/user_callout_type_spec.rb
new file mode 100644
index 00000000000..b26b85a4e8b
--- /dev/null
+++ b/spec/graphql/types/user_callout_type_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['UserCallout'] do
+ specify { expect(described_class.graphql_name).to eq('UserCallout') }
+
+ it 'has expected fields' do
+ expect(described_class).to have_graphql_fields(:feature_name, :dismissed_at)
+ end
+end
diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb
index 5b3662383d8..d9e67ff348b 100644
--- a/spec/graphql/types/user_type_spec.rb
+++ b/spec/graphql/types/user_type_spec.rb
@@ -31,6 +31,7 @@ RSpec.describe GitlabSchema.types['User'] do
groupCount
projectMemberships
starredProjects
+ callouts
]
expect(described_class).to have_graphql_fields(*expected_fields)
@@ -44,4 +45,12 @@ RSpec.describe GitlabSchema.types['User'] do
is_expected.to have_graphql_resolver(Resolvers::Users::SnippetsResolver)
end
end
+
+ describe 'callouts field' do
+ subject { described_class.fields['callouts'] }
+
+ it 'returns user callouts' do
+ is_expected.to have_graphql_type(Types::UserCalloutType.connection_type)
+ end
+ end
end
diff --git a/spec/helpers/application_settings_helper_spec.rb b/spec/helpers/application_settings_helper_spec.rb
index 2cd01451e0d..c74ee3ce0ec 100644
--- a/spec/helpers/application_settings_helper_spec.rb
+++ b/spec/helpers/application_settings_helper_spec.rb
@@ -130,20 +130,15 @@ RSpec.describe ApplicationSettingsHelper do
before do
helper.instance_variable_set(:@application_setting, application_setting)
stub_storage_settings({ 'default': {}, 'storage_1': {}, 'storage_2': {} })
- allow(ApplicationSetting).to receive(:repository_storages_weighted_attributes).and_return(
- [:repository_storages_weighted_default,
- :repository_storages_weighted_storage_1,
- :repository_storages_weighted_storage_2])
-
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([
- { name: :repository_storages_weighted_default, label: 'default', value: 100 },
- { name: :repository_storages_weighted_storage_1, label: 'storage_1', value: 50 },
- { name: :repository_storages_weighted_storage_2, label: 'storage_2', value: 0 }
- ])
+ expect(helper.storage_weights).to eq(OpenStruct.new(
+ default: 100,
+ storage_1: 50,
+ storage_2: 0
+ ))
end
end
diff --git a/spec/helpers/auth_helper_spec.rb b/spec/helpers/auth_helper_spec.rb
index b5d70af1336..beffa4cf60e 100644
--- a/spec/helpers/auth_helper_spec.rb
+++ b/spec/helpers/auth_helper_spec.rb
@@ -99,19 +99,19 @@ RSpec.describe AuthHelper do
end
end
- describe 'experiment_enabled_button_based_providers' do
+ describe 'trial_enabled_button_based_providers' do
it 'returns the intersection set of github & google_oauth2 with enabled providers' do
allow(helper).to receive(:enabled_button_based_providers) { %w(twitter github google_oauth2) }
- expect(helper.experiment_enabled_button_based_providers).to eq(%w(github google_oauth2))
+ expect(helper.trial_enabled_button_based_providers).to eq(%w(github google_oauth2))
allow(helper).to receive(:enabled_button_based_providers) { %w(google_oauth2 bitbucket) }
- expect(helper.experiment_enabled_button_based_providers).to eq(%w(google_oauth2))
+ expect(helper.trial_enabled_button_based_providers).to eq(%w(google_oauth2))
allow(helper).to receive(:enabled_button_based_providers) { %w(bitbucket) }
- expect(helper.experiment_enabled_button_based_providers).to be_empty
+ expect(helper.trial_enabled_button_based_providers).to be_empty
end
end
diff --git a/spec/helpers/avatars_helper_spec.rb b/spec/helpers/avatars_helper_spec.rb
index 9e18ab34c1f..7fcd5ae880a 100644
--- a/spec/helpers/avatars_helper_spec.rb
+++ b/spec/helpers/avatars_helper_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe AvatarsHelper do
include UploadHelpers
- let(:user) { create(:user) }
+ let_it_be(:user) { create(:user) }
describe '#project_icon & #group_icon' do
shared_examples 'resource with a default avatar' do |source_type|
@@ -89,33 +89,60 @@ RSpec.describe AvatarsHelper do
end
end
- describe '#avatar_icon_for_email' do
+ describe '#avatar_icon_for_email', :clean_gitlab_redis_cache do
let(:user) { create(:user, avatar: File.open(uploaded_image_temp_path)) }
- context 'using an email' do
- context 'when there is a matching user' do
- it 'returns a relative URL for the avatar' do
- expect(helper.avatar_icon_for_email(user.email).to_s)
- .to eq(user.avatar.url)
+ subject { helper.avatar_icon_for_email(user.email).to_s }
+
+ shared_examples "returns avatar for email" do
+ context 'using an email' do
+ context 'when there is a matching user' do
+ it 'returns a relative URL for the avatar' do
+ expect(subject).to eq(user.avatar.url)
+ end
end
- end
- context 'when no user exists for the email' do
- it 'calls gravatar_icon' do
- expect(helper).to receive(:gravatar_icon).with('foo@example.com', 20, 2)
+ context 'when no user exists for the email' do
+ it 'calls gravatar_icon' do
+ expect(helper).to receive(:gravatar_icon).with('foo@example.com', 20, 2)
- helper.avatar_icon_for_email('foo@example.com', 20, 2)
+ helper.avatar_icon_for_email('foo@example.com', 20, 2)
+ end
end
- end
- context 'without an email passed' do
- it 'calls gravatar_icon' do
- expect(helper).to receive(:gravatar_icon).with(nil, 20, 2)
+ context 'without an email passed' do
+ it 'calls gravatar_icon' do
+ expect(helper).to receive(:gravatar_icon).with(nil, 20, 2)
+ expect(User).not_to receive(:find_by_any_email)
- helper.avatar_icon_for_email(nil, 20, 2)
+ helper.avatar_icon_for_email(nil, 20, 2)
+ end
end
end
end
+
+ context "when :avatar_cache_for_email flag is enabled" do
+ before do
+ stub_feature_flags(avatar_cache_for_email: true)
+ end
+
+ it_behaves_like "returns avatar for email"
+
+ it "caches the request" do
+ expect(User).to receive(:find_by_any_email).once.and_call_original
+
+ expect(helper.avatar_icon_for_email(user.email).to_s).to eq(user.avatar.url)
+ expect(helper.avatar_icon_for_email(user.email).to_s).to eq(user.avatar.url)
+ end
+ end
+
+ context "when :avatar_cache_for_email flag is disabled" do
+ before do
+ stub_feature_flags(avatar_cache_for_email: false)
+ end
+
+ it_behaves_like "returns avatar for email"
+ end
end
describe '#avatar_icon_for_user' do
@@ -346,7 +373,7 @@ RSpec.describe AvatarsHelper do
is_expected.to eq tag(
:img,
alt: "#{options[:user_name]}'s avatar",
- src: avatar_icon_for_email(options[:user_email], 16),
+ src: helper.avatar_icon_for_email(options[:user_email], 16),
data: { container: 'body' },
class: "avatar s16 has-tooltip",
title: options[:user_name]
@@ -379,7 +406,7 @@ RSpec.describe AvatarsHelper do
is_expected.to eq tag(
:img,
alt: "#{user_with_avatar.username}'s avatar",
- src: avatar_icon_for_email(user_with_avatar.email, 16, only_path: false),
+ src: helper.avatar_icon_for_email(user_with_avatar.email, 16, only_path: false),
data: { container: 'body' },
class: "avatar s16 has-tooltip",
title: user_with_avatar.username
diff --git a/spec/helpers/boards_helper_spec.rb b/spec/helpers/boards_helper_spec.rb
index b85ebec5545..b00ee19cea2 100644
--- a/spec/helpers/boards_helper_spec.rb
+++ b/spec/helpers/boards_helper_spec.rb
@@ -3,52 +3,71 @@
require 'spec_helper'
RSpec.describe BoardsHelper do
- let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:base_group) { create(:group, path: 'base') }
+ let_it_be(:project) { create(:project, group: base_group) }
+ let_it_be(:project_board) { create(:board, project: project) }
+ let_it_be(:group_board) { create(:board, group: base_group) }
describe '#build_issue_link_base' do
context 'project board' do
it 'returns correct path for project board' do
- @project = project
- @board = create(:board, project: @project)
+ assign(:project, project)
+ assign(:board, project_board)
- expect(build_issue_link_base).to eq("/#{@project.namespace.path}/#{@project.path}/-/issues")
+ expect(helper.build_issue_link_base).to eq("/#{project.namespace.path}/#{project.path}/-/issues")
end
end
context 'group board' do
- let(:base_group) { create(:group, path: 'base') }
-
it 'returns correct path for base group' do
- @board = create(:board, group: base_group)
+ assign(:board, group_board)
- expect(build_issue_link_base).to eq('/base/:project_path/issues')
+ expect(helper.build_issue_link_base).to eq('/base/:project_path/issues')
end
it 'returns correct path for subgroup' do
subgroup = create(:group, parent: base_group, path: 'sub')
- @board = create(:board, group: subgroup)
+ assign(:board, create(:board, group: subgroup))
- expect(build_issue_link_base).to eq('/base/sub/:project_path/issues')
+ expect(helper.build_issue_link_base).to eq('/base/sub/:project_path/issues')
end
end
end
- describe '#board_data' do
- let_it_be(:user) { create(:user) }
- let_it_be(:board) { create(:board, project: project) }
+ describe '#board_base_url' do
+ context 'when project board' do
+ it 'generates the correct url' do
+ assign(:board, group_board)
+ assign(:group, base_group)
+
+ expect(helper.board_base_url).to eq "http://test.host/groups/#{base_group.full_path}/-/boards"
+ end
+ end
+
+ context 'when project board' do
+ it 'generates the correct url' do
+ assign(:board, project_board)
+ assign(:project, project)
+
+ expect(helper.board_base_url).to eq "/#{project.full_path}/-/boards"
+ end
+ end
+ end
+ describe '#board_data' do
context 'project_board' do
before do
assign(:project, project)
- assign(:board, board)
+ assign(:board, project_board)
allow(helper).to receive(:current_user) { user }
- allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, board).and_return(true)
- allow(helper).to receive(:can?).with(user, :admin_issue, board).and_return(true)
+ 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)
end
it 'returns a board_lists_path as lists_endpoint' do
- expect(helper.board_data[:lists_endpoint]).to eq(board_lists_path(board))
+ expect(helper.board_data[:lists_endpoint]).to eq(board_lists_path(project_board))
end
it 'returns board type as parent' do
@@ -63,28 +82,33 @@ RSpec.describe BoardsHelper do
expect(helper.board_data[:labels_fetch_path]).to eq("/#{project.full_path}/-/labels.json?include_ancestor_groups=true")
expect(helper.board_data[:labels_manage_path]).to eq("/#{project.full_path}/-/labels")
end
+
+ it 'returns the group id of a project' do
+ expect(helper.board_data[:group_id]).to eq(project.group.id)
+ end
end
context 'group board' do
- let_it_be(:group) { create(:group, path: 'base') }
- let_it_be(:board) { create(:board, group: group) }
-
before do
- assign(:group, group)
- assign(:board, board)
+ assign(:group, base_group)
+ assign(:board, group_board)
allow(helper).to receive(:current_user) { user }
- allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, board).and_return(true)
- allow(helper).to receive(:can?).with(user, :admin_issue, board).and_return(true)
+ 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)
end
it 'returns correct path for base group' do
- expect(helper.build_issue_link_base).to eq('/base/:project_path/issues')
+ expect(helper.build_issue_link_base).to eq("/#{base_group.full_path}/:project_path/issues")
end
it 'returns required label endpoints' do
- expect(helper.board_data[:labels_fetch_path]).to eq("/groups/base/-/labels.json?include_ancestor_groups=true&only_group_labels=true")
- expect(helper.board_data[:labels_manage_path]).to eq("/groups/base/-/labels")
+ expect(helper.board_data[:labels_fetch_path]).to eq("/groups/#{base_group.full_path}/-/labels.json?include_ancestor_groups=true&only_group_labels=true")
+ expect(helper.board_data[:labels_manage_path]).to eq("/groups/#{base_group.full_path}/-/labels")
+ end
+
+ it 'returns the group id' do
+ expect(helper.board_data[:group_id]).to eq(base_group.id)
end
end
end
@@ -93,8 +117,7 @@ RSpec.describe BoardsHelper do
let(:board_json) { helper.current_board_json }
it 'can serialise with a basic set of attributes' do
- board = create(:board, project: project)
- assign(:board, board)
+ assign(:board, project_board)
expect(board_json).to match_schema('current-board')
end
diff --git a/spec/helpers/ci/pipeline_editor_helper_spec.rb b/spec/helpers/ci/pipeline_editor_helper_spec.rb
index 8f38d3b1439..7686983eb0f 100644
--- a/spec/helpers/ci/pipeline_editor_helper_spec.rb
+++ b/spec/helpers/ci/pipeline_editor_helper_spec.rb
@@ -19,12 +19,5 @@ RSpec.describe Ci::PipelineEditorHelper do
expect(subject).to be false
end
-
- it 'user can not view editor if feature is disabled' do
- allow(helper).to receive(:can_collaborate_with_project?).and_return(true)
- stub_feature_flags(ci_pipeline_editor_page: false)
-
- expect(subject).to be false
- end
end
end
diff --git a/spec/helpers/commits_helper_spec.rb b/spec/helpers/commits_helper_spec.rb
index 2f5f4c4596b..397751b07af 100644
--- a/spec/helpers/commits_helper_spec.rb
+++ b/spec/helpers/commits_helper_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe CommitsHelper do
+ include ProjectForksHelper
+
describe '#revert_commit_link' do
context 'when current_user exists' do
before do
@@ -238,15 +240,22 @@ RSpec.describe CommitsHelper do
expect(subject).to be_a(Gitlab::Git::DiffCollection)
end
end
+ end
- context "feature flag is disabled" do
- let(:paginate) { true }
+ describe '#cherry_pick_projects_data' do
+ let(:project) { create(:project, :repository) }
+ let(:user) { create(:user, maintainer_projects: [project]) }
+ let!(:forked_project) { fork_project(project, user, { namespace: user.namespace, repository: true }) }
- it "returns a standard DiffCollection" do
- stub_feature_flags(paginate_commit_view: false)
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ end
- expect(subject).to be_a(Gitlab::Git::DiffCollection)
- end
+ it 'returns data for cherry picking into a project' do
+ expect(helper.cherry_pick_projects_data(project)).to match_array([
+ { id: project.id.to_s, name: project.full_path, refsUrl: refs_project_path(project) },
+ { id: forked_project.id.to_s, name: forked_project.full_path, refsUrl: refs_project_path(forked_project) }
+ ])
end
end
end
diff --git a/spec/helpers/gitlab_routing_helper_spec.rb b/spec/helpers/gitlab_routing_helper_spec.rb
index f23ffcee35d..0df04d2a8a7 100644
--- a/spec/helpers/gitlab_routing_helper_spec.rb
+++ b/spec/helpers/gitlab_routing_helper_spec.rb
@@ -332,4 +332,14 @@ RSpec.describe GitlabRoutingHelper do
end
end
end
+
+ context 'GraphQL ETag paths' do
+ context 'with pipelines' do
+ let(:pipeline) { double(id: 5) }
+
+ it 'returns an ETag path for pipelines' do
+ expect(graphql_etag_pipeline_path(pipeline)).to eq('/api/graphql:pipelines/id/5')
+ end
+ end
+ end
end
diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb
index 61aaa618c45..0d2af464902 100644
--- a/spec/helpers/groups_helper_spec.rb
+++ b/spec/helpers/groups_helper_spec.rb
@@ -18,11 +18,17 @@ RSpec.describe GroupsHelper do
it 'gives default avatar_icon when no avatar is present' do
group = create(:group)
- group.save!
expect(group_icon_url(group.path)).to match_asset_path('group_avatar.png')
end
end
+ describe 'group_dependency_proxy_url' do
+ it 'converts uppercase letters to lowercase' do
+ group = create(:group, path: 'GroupWithUPPERcaseLetters')
+ expect(group_dependency_proxy_url(group)).to end_with("/groupwithuppercaseletters#{DependencyProxy::URL_SUFFIX}")
+ end
+ end
+
describe 'group_lfs_status' do
let(:group) { create(:group) }
let!(:project) { create(:project, namespace_id: group.id) }
@@ -454,18 +460,12 @@ RSpec.describe GroupsHelper do
allow(helper).to receive(:current_user) { current_user }
end
- context 'when cached_sidebar_open_issues_count feature flag is enabled' do
- before do
- stub_feature_flags(cached_sidebar_open_issues_count: true)
+ it 'returns count value from cache' do
+ allow_next_instance_of(count_service) do |service|
+ allow(service).to receive(:count).and_return(2500)
end
- it 'returns count value from cache' do
- allow_next_instance_of(count_service) do |service|
- allow(service).to receive(:count).and_return(2500)
- end
-
- expect(helper.group_open_issues_count(group)).to eq('2.5k')
- end
+ expect(helper.group_open_issues_count(group)).to eq('2.5k')
end
context 'when cached_sidebar_open_issues_count feature flag is disabled' do
diff --git a/spec/helpers/ide_helper_spec.rb b/spec/helpers/ide_helper_spec.rb
new file mode 100644
index 00000000000..db30446fa95
--- /dev/null
+++ b/spec/helpers/ide_helper_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IdeHelper do
+ describe '#ide_data' do
+ let_it_be(:project) { create(:project) }
+
+ before do
+ allow(helper).to receive(:current_user).and_return(project.creator)
+ end
+
+ context 'when instance vars are not set' do
+ it 'returns instance data in the hash as nil' do
+ expect(helper.ide_data)
+ .to include(
+ 'branch-name' => nil,
+ 'file-path' => nil,
+ 'merge-request' => nil,
+ 'forked-project' => nil,
+ 'project' => nil
+ )
+ end
+ end
+
+ context 'when instance vars are set' do
+ it 'returns instance data in the hash' do
+ self.instance_variable_set(:@branch, 'master')
+ self.instance_variable_set(:@path, 'foo/bar')
+ self.instance_variable_set(:@merge_request, '1')
+ self.instance_variable_set(:@forked_project, project)
+ self.instance_variable_set(:@project, project)
+
+ serialized_project = API::Entities::Project.represent(project).to_json
+
+ expect(helper.ide_data)
+ .to include(
+ 'branch-name' => 'master',
+ 'file-path' => 'foo/bar',
+ 'merge-request' => '1',
+ 'forked-project' => serialized_project,
+ 'project' => serialized_project
+ )
+ end
+ end
+ end
+end
diff --git a/spec/helpers/invite_members_helper_spec.rb b/spec/helpers/invite_members_helper_spec.rb
index 576021b37b3..62bd953cce8 100644
--- a/spec/helpers/invite_members_helper_spec.rb
+++ b/spec/helpers/invite_members_helper_spec.rb
@@ -11,6 +11,21 @@ RSpec.describe InviteMembersHelper do
helper.extend(Gitlab::Experimentation::ControllerConcern)
end
+ describe '#show_invite_members_track_event' do
+ it 'shows values when can directly invite members' do
+ allow(helper).to receive(:directly_invite_members?).and_return(true)
+
+ expect(helper.show_invite_members_track_event).to eq 'show_invite_members'
+ end
+
+ it 'shows values when can indirectly invite members' do
+ allow(helper).to receive(:directly_invite_members?).and_return(false)
+ allow(helper).to receive(:indirectly_invite_members?).and_return(true)
+
+ expect(helper.show_invite_members_track_event).to eq 'show_invite_members_version_b'
+ end
+ end
+
context 'with project' do
before do
assign(:project, project)
@@ -56,15 +71,7 @@ RSpec.describe InviteMembersHelper do
allow(helper).to receive(:current_user) { owner }
end
- it 'returns false' do
- allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_a) { false }
-
- expect(helper.directly_invite_members?).to eq false
- end
-
it 'returns true' do
- allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_a) { true }
-
expect(helper.directly_invite_members?).to eq true
end
end
@@ -75,8 +82,6 @@ RSpec.describe InviteMembersHelper do
end
it 'returns false' do
- allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_a) { true }
-
expect(helper.directly_invite_members?).to eq false
end
end
diff --git a/spec/helpers/issuables_description_templates_helper_spec.rb b/spec/helpers/issuables_description_templates_helper_spec.rb
index 42643b755f8..e8961ccb535 100644
--- a/spec/helpers/issuables_description_templates_helper_spec.rb
+++ b/spec/helpers/issuables_description_templates_helper_spec.rb
@@ -13,22 +13,33 @@ RSpec.describe IssuablesDescriptionTemplatesHelper, :clean_gitlab_redis_cache do
let_it_be(:group_member) { create(:group_member, :developer, group: parent_group, user: user) }
let_it_be(:project_member) { create(:project_member, :developer, user: user, project: project) }
- it 'returns empty hash when template type does not exist' do
- expect(helper.issuable_templates(build(:project), 'non-existent-template-type')).to eq([])
+ context 'when feature flag disabled' do
+ before do
+ stub_feature_flags(inherited_issuable_templates: false)
+ end
+
+ it 'returns empty array when template type does not exist' do
+ expect(helper.issuable_templates(project, 'non-existent-template-type')).to eq([])
+ end
end
- context 'with cached issuable templates' do
+ context 'when feature flag enabled' do
before do
- allow(Gitlab::Template::IssueTemplate).to receive(:template_names).and_return({})
- allow(Gitlab::Template::MergeRequestTemplate).to receive(:template_names).and_return({})
+ stub_feature_flags(inherited_issuable_templates: true)
+ end
- helper.issuable_templates(project, 'issues')
- helper.issuable_templates(project, 'merge_request')
+ it 'returns empty hash when template type does not exist' do
+ expect(helper.issuable_templates(build(:project), 'non-existent-template-type')).to eq({})
end
+ end
+ context 'with cached issuable templates' do
it 'does not call TemplateFinder' do
- expect(Gitlab::Template::IssueTemplate).not_to receive(:template_names)
- expect(Gitlab::Template::MergeRequestTemplate).not_to receive(:template_names)
+ expect(Gitlab::Template::IssueTemplate).to receive(:template_names).once.and_call_original
+ expect(Gitlab::Template::MergeRequestTemplate).to receive(:template_names).once.and_call_original
+
+ helper.issuable_templates(project, 'issues')
+ helper.issuable_templates(project, 'merge_request')
helper.issuable_templates(project, 'issues')
helper.issuable_templates(project, 'merge_request')
end
@@ -63,29 +74,78 @@ RSpec.describe IssuablesDescriptionTemplatesHelper, :clean_gitlab_redis_cache do
end
describe '#issuable_templates_names' do
- let(:project) { double(Project, id: 21) }
-
- let(:templates) do
- [
- { name: "another_issue_template", id: "another_issue_template", project_id: project.id },
- { name: "custom_issue_template", id: "custom_issue_template", project_id: project.id }
- ]
- end
+ let_it_be(:project) { build(:project) }
- it 'returns project templates only' do
+ before do
allow(helper).to receive(:ref_project).and_return(project)
allow(helper).to receive(:issuable_templates).and_return(templates)
+ end
+
+ context 'when feature flag disabled' do
+ let(:templates) do
+ [
+ { name: "another_issue_template", id: "another_issue_template", project_id: project.id },
+ { name: "custom_issue_template", id: "custom_issue_template", project_id: project.id }
+ ]
+ end
- expect(helper.issuable_templates_names(Issue.new)).to eq(%w[another_issue_template custom_issue_template])
+ before do
+ stub_feature_flags(inherited_issuable_templates: false)
+ end
+
+ it 'returns project templates only' do
+ expect(helper.issuable_templates_names(Issue.new)).to eq(%w[another_issue_template custom_issue_template])
+ end
+ end
+
+ context 'when feature flag enabled' do
+ before do
+ stub_feature_flags(inherited_issuable_templates: true)
+ end
+
+ context 'with matching project templates' do
+ let(:templates) do
+ {
+ "" => [
+ { name: "another_issue_template", id: "another_issue_template", project_id: project.id },
+ { name: "custom_issue_template", id: "custom_issue_template", project_id: project.id }
+ ],
+ "Instance" => [
+ { name: "first_issue_issue_template", id: "first_issue_issue_template", project_id: non_existing_record_id },
+ { name: "second_instance_issue_template", id: "second_instance_issue_template", project_id: non_existing_record_id }
+ ]
+ }
+ end
+
+ it 'returns project templates only' do
+ expect(helper.issuable_templates_names(Issue.new)).to eq(%w[another_issue_template custom_issue_template])
+ end
+ end
+
+ context 'without matching project templates' do
+ let(:templates) do
+ {
+ "Project Templates" => [
+ { name: "another_issue_template", id: "another_issue_template", project_id: non_existing_record_id },
+ { name: "custom_issue_template", id: "custom_issue_template", project_id: non_existing_record_id }
+ ],
+ "Instance" => [
+ { name: "first_issue_issue_template", id: "first_issue_issue_template", project_id: non_existing_record_id },
+ { name: "second_instance_issue_template", id: "second_instance_issue_template", project_id: non_existing_record_id }
+ ]
+ }
+ end
+
+ it 'returns empty array' do
+ expect(helper.issuable_templates_names(Issue.new)).to eq([])
+ end
+ end
end
context 'when there are not templates in the project' do
let(:templates) { {} }
it 'returns empty array' do
- allow(helper).to receive(:ref_project).and_return(project)
- allow(helper).to receive(:issuable_templates).and_return(templates)
-
expect(helper.issuable_templates_names(Issue.new)).to eq([])
end
end
diff --git a/spec/helpers/learn_gitlab_helper_spec.rb b/spec/helpers/learn_gitlab_helper_spec.rb
index f789eb9d940..6cee8a9191c 100644
--- a/spec/helpers/learn_gitlab_helper_spec.rb
+++ b/spec/helpers/learn_gitlab_helper_spec.rb
@@ -41,11 +41,13 @@ RSpec.describe LearnGitlabHelper do
it 'sets correct path and completion status' do
expect(onboarding_actions_data[:git_write]).to eq({
url: project_issue_url(project, LearnGitlabHelper::ACTION_ISSUE_IDS[:git_write]),
- completed: true
+ completed: true,
+ svg: helper.image_path("learn_gitlab/git_write.svg")
})
expect(onboarding_actions_data[:pipeline_created]).to eq({
url: project_issue_url(project, LearnGitlabHelper::ACTION_ISSUE_IDS[:pipeline_created]),
- completed: false
+ completed: false,
+ svg: helper.image_path("learn_gitlab/pipeline_created.svg")
})
end
end
diff --git a/spec/helpers/merge_requests_helper_spec.rb b/spec/helpers/merge_requests_helper_spec.rb
index fce4d560b2f..3cf855229bb 100644
--- a/spec/helpers/merge_requests_helper_spec.rb
+++ b/spec/helpers/merge_requests_helper_spec.rb
@@ -90,4 +90,57 @@ RSpec.describe MergeRequestsHelper do
)
end
end
+
+ describe '#reviewers_label' do
+ let(:merge_request) { build_stubbed(:merge_request) }
+ let(:reviewer1) { build_stubbed(:user, name: 'Jane Doe') }
+ let(:reviewer2) { build_stubbed(:user, name: 'John Doe') }
+
+ before do
+ allow(merge_request).to receive(:reviewers).and_return(reviewers)
+ end
+
+ context 'when multiple reviewers exist' do
+ let(:reviewers) { [reviewer1, reviewer2] }
+
+ it 'returns reviewer label with reviewer names' do
+ expect(helper.reviewers_label(merge_request)).to eq("Reviewers: Jane Doe and John Doe")
+ end
+
+ it 'returns reviewer label only with include_value: false' do
+ expect(helper.reviewers_label(merge_request, include_value: false)).to eq("Reviewers")
+ end
+
+ context 'when the name contains a URL' do
+ let(:reviewers) { [build_stubbed(:user, name: 'www.gitlab.com')] }
+
+ it 'returns sanitized name' do
+ expect(helper.reviewers_label(merge_request)).to eq("Reviewer: www_gitlab_com")
+ end
+ end
+ end
+
+ context 'when one reviewer exists' do
+ let(:reviewers) { [reviewer1] }
+
+ it 'returns reviewer label with no names' do
+ expect(helper.reviewers_label(merge_request)).to eq("Reviewer: Jane Doe")
+ end
+
+ it 'returns reviewer label only with include_value: false' do
+ expect(helper.reviewers_label(merge_request, include_value: false)).to eq("Reviewer")
+ end
+ end
+
+ context 'when no reviewers exist' do
+ let(:reviewers) { [] }
+
+ it 'returns reviewer label with no names' do
+ expect(helper.reviewers_label(merge_request)).to eq("Reviewers: ")
+ end
+ it 'returns reviewer label only with include_value: false' do
+ expect(helper.reviewers_label(merge_request, include_value: false)).to eq("Reviewers")
+ end
+ end
+ end
end
diff --git a/spec/helpers/namespaces_helper_spec.rb b/spec/helpers/namespaces_helper_spec.rb
index 1636ba6ef42..b436f4ab0c9 100644
--- a/spec/helpers/namespaces_helper_spec.rb
+++ b/spec/helpers/namespaces_helper_spec.rb
@@ -46,13 +46,26 @@ RSpec.describe NamespacesHelper do
end
describe '#namespaces_options' do
- it 'returns groups without being a member for admin' do
- allow(helper).to receive(:current_user).and_return(admin)
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'returns groups without being a member for admin' do
+ allow(helper).to receive(:current_user).and_return(admin)
- options = helper.namespaces_options(user_group.id, display_path: true, extra_group: user_group.id)
+ options = helper.namespaces_options(user_group.id, display_path: true, extra_group: user_group.id)
- expect(options).to include(admin_group.name)
- expect(options).to include(user_group.name)
+ expect(options).to include(admin_group.name)
+ expect(options).to include(user_group.name)
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it 'returns only allowed namespaces for admin' do
+ allow(helper).to receive(:current_user).and_return(admin)
+
+ options = helper.namespaces_options(user_group.id, display_path: true, extra_group: user_group.id)
+
+ expect(options).to include(admin_group.name)
+ expect(options).not_to include(user_group.name)
+ end
end
it 'returns only allowed namespaces for user' do
@@ -74,13 +87,16 @@ RSpec.describe NamespacesHelper do
expect(options).to include(admin_group.name)
end
- it 'selects existing group' do
- allow(helper).to receive(:current_user).and_return(admin)
+ context 'when admin mode is disabled' do
+ it 'selects existing group' do
+ allow(helper).to receive(:current_user).and_return(admin)
+ user_group.add_owner(admin)
- options = helper.namespaces_options(:extra_group, display_path: true, extra_group: user_group)
+ options = helper.namespaces_options(:extra_group, display_path: true, extra_group: user_group)
- expect(options).to include("selected=\"selected\" value=\"#{user_group.id}\"")
- expect(options).to include(admin_group.name)
+ expect(options).to include("selected=\"selected\" value=\"#{user_group.id}\"")
+ expect(options).to include(admin_group.name)
+ end
end
it 'selects the new group by default' do
diff --git a/spec/helpers/notifications_helper_spec.rb b/spec/helpers/notifications_helper_spec.rb
index 555cffba614..a5338659659 100644
--- a/spec/helpers/notifications_helper_spec.rb
+++ b/spec/helpers/notifications_helper_spec.rb
@@ -19,22 +19,6 @@ RSpec.describe NotificationsHelper do
it { expect(notification_title(:global)).to match('Global') }
end
- describe '#notification_event_name' do
- context 'for success_pipeline' do
- it 'returns the custom name' do
- expect(FastGettext).to receive(:cached_find).with('NotificationEvent|Successful pipeline')
- expect(notification_event_name(:success_pipeline)).to eq('Successful pipeline')
- end
- end
-
- context 'for everything else' do
- it 'returns a humanized name' do
- expect(FastGettext).to receive(:cached_find).with('NotificationEvent|Failed pipeline')
- expect(notification_event_name(:failed_pipeline)).to eq('Failed pipeline')
- end
- end
- end
-
describe '#notification_icon_level' do
let(:user) { create(:user) }
let(:global_setting) { user.global_notification_setting }
diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb
index be0ad5e1a3f..e5420fb6729 100644
--- a/spec/helpers/preferences_helper_spec.rb
+++ b/spec/helpers/preferences_helper_spec.rb
@@ -29,6 +29,7 @@ RSpec.describe PreferencesHelper do
['Starred Projects', 'stars'],
["Your Projects' Activity", 'project_activity'],
["Starred Projects' Activity", 'starred_project_activity'],
+ ["Followed Users' Activity", 'followed_user_activity'],
["Your Groups", 'groups'],
["Your To-Do List", 'todos'],
["Assigned Issues", 'issues'],
diff --git a/spec/helpers/projects/project_members_helper_spec.rb b/spec/helpers/projects/project_members_helper_spec.rb
index 5e0b4df7f7f..1a55840a58a 100644
--- a/spec/helpers/projects/project_members_helper_spec.rb
+++ b/spec/helpers/projects/project_members_helper_spec.rb
@@ -166,7 +166,7 @@ RSpec.describe Projects::ProjectMembersHelper do
members: helper.project_members_data_json(project, present_members(project_members)),
member_path: '/foo-bar/-/project_members/:id',
source_id: project.id,
- can_manage_members: true
+ can_manage_members: 'true'
})
end
end
@@ -193,7 +193,7 @@ RSpec.describe Projects::ProjectMembersHelper do
members: helper.project_group_links_data_json(project_group_links),
member_path: '/foo-bar/-/group_links/:id',
source_id: project.id,
- can_manage_members: true
+ can_manage_members: 'true'
})
end
end
diff --git a/spec/helpers/projects/security/configuration_helper_spec.rb b/spec/helpers/projects/security/configuration_helper_spec.rb
new file mode 100644
index 00000000000..c5049bd87f0
--- /dev/null
+++ b/spec/helpers/projects/security/configuration_helper_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::Security::ConfigurationHelper do
+ let(:current_user) { create(:user) }
+
+ describe 'security_upgrade_path' do
+ subject { security_upgrade_path }
+
+ it { is_expected.to eq('https://about.gitlab.com/pricing/') }
+ end
+end
diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb
index 303e3c78153..e6cd11a4d70 100644
--- a/spec/helpers/projects_helper_spec.rb
+++ b/spec/helpers/projects_helper_spec.rb
@@ -401,40 +401,20 @@ RSpec.describe ProjectsHelper do
context 'Security & Compliance tabs' do
before do
- stub_feature_flags(secure_security_and_compliance_configuration_page_on_ce: feature_flag_enabled)
allow(helper).to receive(:can?).with(user, :read_security_configuration, project).and_return(can_read_security_configuration)
end
context 'when user cannot read security configuration' do
let(:can_read_security_configuration) { false }
- context 'when feature flag is disabled' do
- let(:feature_flag_enabled) { false }
-
- it { is_expected.not_to include(:security_configuration) }
- end
-
- context 'when feature flag is enabled' do
- let(:feature_flag_enabled) { true }
-
- it { is_expected.not_to include(:security_configuration) }
- end
+ it { is_expected.not_to include(:security_configuration) }
end
context 'when user can read security configuration' do
let(:can_read_security_configuration) { true }
+ let(:feature_flag_enabled) { true }
- context 'when feature flag is disabled' do
- let(:feature_flag_enabled) { false }
-
- it { is_expected.not_to include(:security_configuration) }
- end
-
- context 'when feature flag is enabled' do
- let(:feature_flag_enabled) { true }
-
- it { is_expected.to include(:security_configuration) }
- end
+ it { is_expected.to include(:security_configuration) }
end
end
diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb
index a977f2c88c6..13d3a80bd13 100644
--- a/spec/helpers/search_helper_spec.rb
+++ b/spec/helpers/search_helper_spec.rb
@@ -534,10 +534,11 @@ RSpec.describe SearchHelper do
where(:description, :expected) do
'test' | '<span class="gl-text-gray-900 gl-font-weight-bold">test</span>'
- '<span style="color: blue;">this test should not be blue</span>' | '<span>this <span class="gl-text-gray-900 gl-font-weight-bold">test</span> should not be blue</span>'
+ '<span style="color: blue;">this test should not be blue</span>' | 'this <span class="gl-text-gray-900 gl-font-weight-bold">test</span> should not be blue'
'<a href="#" onclick="alert(\'XSS\')">Click Me test</a>' | '<a href="#">Click Me <span class="gl-text-gray-900 gl-font-weight-bold">test</span></a>'
'<script type="text/javascript">alert(\'Another XSS\');</script> test' | ' <span class="gl-text-gray-900 gl-font-weight-bold">test</span>'
'Lorem test ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec.' | 'Lorem <span class="gl-text-gray-900 gl-font-weight-bold">test</span> ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Don...'
+ '<img src="https://random.foo.com/test.png" width="128" height="128" />some image' | 'some image'
end
with_them do
@@ -580,7 +581,7 @@ RSpec.describe SearchHelper do
describe '#issuable_state_to_badge_class' do
context 'with merge request' do
it 'returns correct badge based on status' do
- expect(issuable_state_to_badge_class(build(:merge_request, :merged))).to eq(:primary)
+ expect(issuable_state_to_badge_class(build(:merge_request, :merged))).to eq(:info)
expect(issuable_state_to_badge_class(build(:merge_request, :closed))).to eq(:danger)
expect(issuable_state_to_badge_class(build(:merge_request, :opened))).to eq(:success)
end
diff --git a/spec/helpers/services_helper_spec.rb b/spec/helpers/services_helper_spec.rb
index 534f33d9b5a..1726a8362a7 100644
--- a/spec/helpers/services_helper_spec.rb
+++ b/spec/helpers/services_helper_spec.rb
@@ -4,27 +4,35 @@ require 'spec_helper'
RSpec.describe ServicesHelper do
describe '#integration_form_data' do
+ let(:fields) do
+ [
+ :id,
+ :show_active,
+ :activated,
+ :type,
+ :merge_request_events,
+ :commit_events,
+ :enable_comments,
+ :comment_detail,
+ :learn_more_path,
+ :trigger_events,
+ :fields,
+ :inherit_from_id,
+ :integration_level,
+ :editable,
+ :cancel_path,
+ :can_test,
+ :test_path,
+ :reset_path
+ ]
+ end
+
subject { helper.integration_form_data(integration) }
- context 'Jira service' do
- let(:integration) { build(:jira_service) }
-
- it 'includes Jira specific fields' do
- is_expected.to include(
- :id,
- :show_active,
- :activated,
- :type,
- :merge_request_events,
- :commit_events,
- :enable_comments,
- :comment_detail,
- :trigger_events,
- :fields,
- :inherit_from_id,
- :integration_level
- )
- end
+ context 'Slack service' do
+ let(:integration) { build(:slack_service) }
+
+ it { is_expected.to include(*fields) }
specify do
expect(subject[:reset_path]).to eq(helper.scoped_reset_integration_path(integration))
diff --git a/spec/helpers/stat_anchors_helper_spec.rb b/spec/helpers/stat_anchors_helper_spec.rb
index 0615baac3cb..f3830bf4172 100644
--- a/spec/helpers/stat_anchors_helper_spec.rb
+++ b/spec/helpers/stat_anchors_helper_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe StatAnchorsHelper do
context 'when anchor is not a link' do
context 'when class_modifier is set' do
- let(:anchor) { anchor_klass.new(false, nil, nil, 'default') }
+ let(:anchor) { anchor_klass.new(false, nil, nil, 'btn-default') }
it 'returns the proper attributes' do
expect(subject[:class]).to include('gl-button btn btn-default')
@@ -49,5 +49,21 @@ RSpec.describe StatAnchorsHelper do
expect(subject[:itemprop]).to eq true
end
end
+
+ context 'when data is not set' do
+ let(:anchor) { anchor_klass.new(false, nil, nil, nil, nil, nil, nil) }
+
+ it 'returns the data attributes' do
+ expect(subject[:data]).to be_nil
+ end
+ end
+
+ context 'when itemprop is set' do
+ let(:anchor) { anchor_klass.new(false, nil, nil, nil, nil, nil, { 'toggle' => 'modal' }) }
+
+ it 'returns the data attributes' do
+ expect(subject[:data]).to eq({ 'toggle' => 'modal' })
+ end
+ end
end
end
diff --git a/spec/helpers/timeboxes_helper_spec.rb b/spec/helpers/timeboxes_helper_spec.rb
index 94e997f7a65..9cbed7668ac 100644
--- a/spec/helpers/timeboxes_helper_spec.rb
+++ b/spec/helpers/timeboxes_helper_spec.rb
@@ -58,15 +58,6 @@ RSpec.describe TimeboxesHelper do
it { expect(result_for(start_date: yesterday)).to eq("started on #{yesterday_formatted}") }
it { expect(result_for(start_date: yesterday, due_date: tomorrow)).to eq("#{yesterday_formatted}–#{tomorrow_formatted}") }
end
-
- context 'iteration' do
- # Iterations always have start and due dates, so only A-B format is expected
- it 'formats properly' do
- iteration = build(:iteration, start_date: yesterday, due_date: tomorrow)
-
- expect(timebox_date_range(iteration)).to eq("#{yesterday_formatted}–#{tomorrow_formatted}")
- end
- end
end
describe '#milestone_counts' do
diff --git a/spec/helpers/visibility_level_helper_spec.rb b/spec/helpers/visibility_level_helper_spec.rb
index 10e0815918f..2aac0cae0c6 100644
--- a/spec/helpers/visibility_level_helper_spec.rb
+++ b/spec/helpers/visibility_level_helper_spec.rb
@@ -33,6 +33,22 @@ RSpec.describe VisibilityLevelHelper do
end
end
+ describe 'visibility_level_label' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:level_value, :level_name) do
+ Gitlab::VisibilityLevel::PRIVATE | 'Private'
+ Gitlab::VisibilityLevel::INTERNAL | 'Internal'
+ Gitlab::VisibilityLevel::PUBLIC | 'Public'
+ end
+
+ with_them do
+ it 'returns the name of the visibility level' do
+ expect(visibility_level_label(level_value)).to eq(level_name)
+ end
+ end
+ end
+
describe 'visibility_level_description' do
context 'used with a Project' do
let(:descriptions) do
diff --git a/spec/initializers/rack_multipart_patch_spec.rb b/spec/initializers/rack_multipart_patch_spec.rb
new file mode 100644
index 00000000000..862fdc7901b
--- /dev/null
+++ b/spec/initializers/rack_multipart_patch_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Rack::Multipart do # rubocop:disable RSpec/FilePath
+ def multipart_fixture(name, length, boundary = "AaB03x")
+ data = <<EOF
+--#{boundary}\r
+content-disposition: form-data; name="reply"\r
+\r
+yes\r
+--#{boundary}\r
+content-disposition: form-data; name="fileupload"; filename="dj.jpg"\r
+Content-Type: image/jpeg\r
+Content-Transfer-Encoding: base64\r
+\r
+/9j/4AAQSkZJRgABAQAAAQABAAD//gA+Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcg\r
+--#{boundary}--\r
+EOF
+
+ type = %(multipart/form-data; boundary=#{boundary})
+
+ length ||= data.bytesize
+
+ {
+ "CONTENT_TYPE" => type,
+ "CONTENT_LENGTH" => length.to_s,
+ input: StringIO.new(data)
+ }
+ end
+
+ context 'with Content-Length under the limit' do
+ it 'extracts multipart message' do
+ env = Rack::MockRequest.env_for("/", multipart_fixture(:text, nil))
+
+ expect(described_class).to receive(:log_large_multipart?).and_call_original
+ expect(described_class).not_to receive(:log_multipart_warning)
+ params = described_class.parse_multipart(env)
+
+ expect(params.keys).to include(*%w(reply fileupload))
+ end
+ end
+
+ context 'with Content-Length over the limit' do
+ shared_examples 'logs multipart message' do
+ it 'extracts multipart message' do
+ env = Rack::MockRequest.env_for("/", multipart_fixture(:text, length))
+
+ expect(described_class).to receive(:log_large_multipart?).and_return(true)
+ expect(described_class).to receive(:log_multipart_warning).and_call_original
+ expect(described_class).to receive(:log_warn).with({
+ message: 'Large multipart body detected',
+ path: '/',
+ content_length: anything,
+ correlation_id: anything
+ })
+ params = described_class.parse_multipart(env)
+
+ expect(params.keys).to include(*%w(reply fileupload))
+ end
+ end
+
+ context 'from environment' do
+ let(:length) { 1001 }
+
+ before do
+ stub_env('RACK_MULTIPART_LOGGING_BYTES', 1000)
+ end
+
+ it_behaves_like 'logs multipart message'
+ end
+
+ context 'default limit' do
+ let(:length) { 100_000_001 }
+
+ it_behaves_like 'logs multipart message'
+ end
+ end
+end
diff --git a/spec/lib/api/entities/plan_limit_spec.rb b/spec/lib/api/entities/plan_limit_spec.rb
new file mode 100644
index 00000000000..ee42c67f9b6
--- /dev/null
+++ b/spec/lib/api/entities/plan_limit_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Entities::PlanLimit do
+ let(:plan_limits) { create(:plan_limits) }
+
+ subject { described_class.new(plan_limits).as_json }
+
+ it 'exposes correct attributes' do
+ expect(subject).to include(
+ :conan_max_file_size,
+ :generic_packages_max_file_size,
+ :maven_max_file_size,
+ :npm_max_file_size,
+ :nuget_max_file_size,
+ :pypi_max_file_size
+ )
+ end
+
+ it 'does not expose id and plan_id' do
+ expect(subject).not_to include(:id, :plan_id)
+ end
+end
diff --git a/spec/lib/api/entities/project_repository_storage_move_spec.rb b/spec/lib/api/entities/projects/repository_storage_move_spec.rb
index b0102dc376a..81f5d98b713 100644
--- a/spec/lib/api/entities/project_repository_storage_move_spec.rb
+++ b/spec/lib/api/entities/projects/repository_storage_move_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Entities::ProjectRepositoryStorageMove do
+RSpec.describe API::Entities::Projects::RepositoryStorageMove do
describe '#as_json' do
subject { entity.as_json }
diff --git a/spec/lib/api/entities/public_group_details_spec.rb b/spec/lib/api/entities/public_group_details_spec.rb
new file mode 100644
index 00000000000..34162ed00ca
--- /dev/null
+++ b/spec/lib/api/entities/public_group_details_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Entities::PublicGroupDetails do
+ subject(:entity) { described_class.new(group) }
+
+ let(:group) { create(:group, :with_avatar) }
+
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ it 'includes public group fields' do
+ is_expected.to eq(
+ id: group.id,
+ name: group.name,
+ web_url: group.web_url,
+ avatar_url: group.avatar_url(only_path: false),
+ full_name: group.full_name,
+ full_path: group.full_path
+ )
+ end
+ end
+end
diff --git a/spec/lib/api/entities/snippet_repository_storage_move_spec.rb b/spec/lib/api/entities/snippets/repository_storage_move_spec.rb
index 8086be3ffa7..a848afbcff9 100644
--- a/spec/lib/api/entities/snippet_repository_storage_move_spec.rb
+++ b/spec/lib/api/entities/snippets/repository_storage_move_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe API::Entities::SnippetRepositoryStorageMove do
+RSpec.describe API::Entities::Snippets::RepositoryStorageMove do
describe '#as_json' do
subject { entity.as_json }
diff --git a/spec/lib/backup/repositories_spec.rb b/spec/lib/backup/repositories_spec.rb
index 492058c6a00..7a8cc713e4f 100644
--- a/spec/lib/backup/repositories_spec.rb
+++ b/spec/lib/backup/repositories_spec.rb
@@ -230,6 +230,16 @@ RSpec.describe Backup::Repositories do
expect(pool_repository).not_to be_failed
expect(pool_repository.object_pool.exists?).to be(true)
end
+
+ it 'skips pools with no source project, :sidekiq_might_not_need_inline' do
+ pool_repository = create(:pool_repository, state: :obsolete)
+ pool_repository.update_column(:source_project_id, nil)
+
+ subject.restore
+
+ pool_repository.reload
+ expect(pool_repository).to be_obsolete
+ end
end
it 'cleans existing repositories' do
diff --git a/spec/lib/banzai/filter/custom_emoji_filter_spec.rb b/spec/lib/banzai/filter/custom_emoji_filter_spec.rb
index ca8c9750e7f..5e76e8164dd 100644
--- a/spec/lib/banzai/filter/custom_emoji_filter_spec.rb
+++ b/spec/lib/banzai/filter/custom_emoji_filter_spec.rb
@@ -10,6 +10,10 @@ RSpec.describe Banzai::Filter::CustomEmojiFilter do
let_it_be(:custom_emoji) { create(:custom_emoji, name: 'tanuki', group: group) }
let_it_be(:custom_emoji2) { create(:custom_emoji, name: 'happy_tanuki', group: group, file: 'https://foo.bar/happy.png') }
+ it_behaves_like 'emoji filter' do
+ let(:emoji_name) { ':tanuki:' }
+ end
+
it 'replaces supported name custom emoji' do
doc = filter('<p>:tanuki:</p>', project: project)
@@ -17,25 +21,12 @@ RSpec.describe Banzai::Filter::CustomEmojiFilter do
expect(doc.css('gl-emoji img').size).to eq 1
end
- it 'ignores non existent custom emoji' do
- exp = act = '<p>:foo:</p>'
- doc = filter(act)
-
- expect(doc.to_html).to match Regexp.escape(exp)
- 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)
end
- it 'matches with adjacent text' do
- doc = filter('tanuki (:tanuki:)')
-
- expect(doc.css('img').size).to eq 1
- end
-
it 'matches multiple same custom emoji' do
doc = filter(':tanuki: :tanuki:')
@@ -54,18 +45,6 @@ RSpec.describe Banzai::Filter::CustomEmojiFilter do
expect(doc.css('img').size).to be 0
end
- it 'keeps whitespace intact' do
- doc = filter('This deserves a :tanuki:, big time.')
-
- expect(doc.to_html).to match(/^This deserves a <gl-emoji.+>, big time\.\z/)
- end
-
- it 'does not match emoji in a string' do
- doc = filter("'2a00:tanuki:100::1'")
-
- expect(doc.css('gl-emoji').size).to eq 0
- end
-
it 'does not do N+1 query' do
create(:custom_emoji, name: 'party-parrot', group: group)
diff --git a/spec/lib/banzai/filter/emoji_filter_spec.rb b/spec/lib/banzai/filter/emoji_filter_spec.rb
index 9005b4401b7..cb0b470eaa1 100644
--- a/spec/lib/banzai/filter/emoji_filter_spec.rb
+++ b/spec/lib/banzai/filter/emoji_filter_spec.rb
@@ -5,6 +5,10 @@ require 'spec_helper'
RSpec.describe Banzai::Filter::EmojiFilter do
include FilterSpecHelper
+ it_behaves_like 'emoji filter' do
+ let(:emoji_name) { ':+1:' }
+ end
+
it 'replaces supported name emoji' do
doc = filter('<p>:heart:</p>')
expect(doc.css('gl-emoji').first.text).to eq '❤'
@@ -15,12 +19,6 @@ RSpec.describe Banzai::Filter::EmojiFilter do
expect(doc.css('gl-emoji').first.text).to eq '❤'
end
- it 'ignores unsupported emoji' do
- exp = act = '<p>:foo:</p>'
- doc = filter(act)
- expect(doc.to_html).to match Regexp.escape(exp)
- end
-
it 'ignores unicode versions of trademark, copyright, and registered trademark' do
exp = act = '<p>™ © ®</p>'
doc = filter(act)
@@ -65,11 +63,6 @@ RSpec.describe Banzai::Filter::EmojiFilter do
expect(doc.css('gl-emoji').size).to eq 1
end
- it 'matches with adjacent text' do
- doc = filter('+1 (:+1:)')
- expect(doc.css('gl-emoji').size).to eq 1
- end
-
it 'unicode matches with adjacent text' do
doc = filter('+1 (👍)')
expect(doc.css('gl-emoji').size).to eq 1
@@ -90,12 +83,6 @@ RSpec.describe Banzai::Filter::EmojiFilter do
expect(doc.css('gl-emoji').size).to eq 6
end
- it 'does not match emoji in a string' do
- doc = filter("'2a00:a4c0:100::1'")
-
- expect(doc.css('gl-emoji').size).to eq 0
- end
-
it 'has a data-name attribute' do
doc = filter(':-1:')
expect(doc.css('gl-emoji').first.attr('data-name')).to eq 'thumbsdown'
@@ -106,12 +93,6 @@ RSpec.describe Banzai::Filter::EmojiFilter do
expect(doc.css('gl-emoji').first.attr('data-unicode-version')).to eq '6.0'
end
- it 'keeps whitespace intact' do
- doc = filter('This deserves a :+1:, big time.')
-
- expect(doc.to_html).to match(/^This deserves a <gl-emoji.+>, big time\.\z/)
- end
-
it 'unicode keeps whitespace intact' do
doc = filter('This deserves a 🎱, big time.')
diff --git a/spec/lib/banzai/filter/gollum_tags_filter_spec.rb b/spec/lib/banzai/filter/gollum_tags_filter_spec.rb
index f39b5280490..ec17bb26346 100644
--- a/spec/lib/banzai/filter/gollum_tags_filter_spec.rb
+++ b/spec/lib/banzai/filter/gollum_tags_filter_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe Banzai::Filter::GollumTagsFilter do
path: 'images/image.jpg',
raw_data: '')
wiki_file = Gitlab::Git::WikiFile.new(gollum_file_double)
- expect(wiki).to receive(:find_file).with('images/image.jpg').and_return(wiki_file)
+ expect(wiki).to receive(:find_file).with('images/image.jpg', load_content: false).and_return(wiki_file)
tag = '[[images/image.jpg]]'
doc = filter("See #{tag}", wiki: wiki)
@@ -31,7 +31,7 @@ RSpec.describe Banzai::Filter::GollumTagsFilter do
end
it 'does not creates img tag if image does not exist' do
- expect(wiki).to receive(:find_file).with('images/image.jpg').and_return(nil)
+ expect(wiki).to receive(:find_file).with('images/image.jpg', load_content: false).and_return(nil)
tag = '[[images/image.jpg]]'
doc = filter("See #{tag}", wiki: wiki)
diff --git a/spec/lib/banzai/filter/sanitization_filter_spec.rb b/spec/lib/banzai/filter/sanitization_filter_spec.rb
index bc4b60dfe60..f880fe06ce3 100644
--- a/spec/lib/banzai/filter/sanitization_filter_spec.rb
+++ b/spec/lib/banzai/filter/sanitization_filter_spec.rb
@@ -33,14 +33,14 @@ RSpec.describe Banzai::Filter::SanitizationFilter do
end
it 'sanitizes `class` attribute from all elements' do
- act = %q{<pre class="code highlight white c"><code>&lt;span class="k"&gt;def&lt;/span&gt;</code></pre>}
- exp = %q{<pre><code>&lt;span class="k"&gt;def&lt;/span&gt;</code></pre>}
+ act = %q(<pre class="code highlight white c"><code>&lt;span class="k"&gt;def&lt;/span&gt;</code></pre>)
+ exp = %q(<pre><code>&lt;span class="k"&gt;def&lt;/span&gt;</code></pre>)
expect(filter(act).to_html).to eq exp
end
it 'sanitizes `class` attribute from non-highlight spans' do
- act = %q{<span class="k">def</span>}
- expect(filter(act).to_html).to eq %q{<span>def</span>}
+ act = %q(<span class="k">def</span>)
+ expect(filter(act).to_html).to eq %q(<span>def</span>)
end
it 'allows `text-align` property in `style` attribute on table elements' do
@@ -82,12 +82,12 @@ RSpec.describe Banzai::Filter::SanitizationFilter do
end
it 'allows `span` elements' do
- exp = act = %q{<span>Hello</span>}
+ exp = act = %q(<span>Hello</span>)
expect(filter(act).to_html).to eq exp
end
it 'allows `abbr` elements' do
- exp = act = %q{<abbr title="HyperText Markup Language">HTML</abbr>}
+ exp = act = %q(<abbr title="HyperText Markup Language">HTML</abbr>)
expect(filter(act).to_html).to eq exp
end
@@ -132,7 +132,7 @@ RSpec.describe Banzai::Filter::SanitizationFilter do
end
it 'allows the `data-sourcepos` attribute globally' do
- exp = %q{<p data-sourcepos="1:1-1:10">foo/bar.md</p>}
+ exp = %q(<p data-sourcepos="1:1-1:10">foo/bar.md</p>)
act = filter(exp)
expect(act.to_html).to eq exp
@@ -140,41 +140,41 @@ RSpec.describe Banzai::Filter::SanitizationFilter do
describe 'footnotes' do
it 'allows correct footnote id property on links' do
- exp = %q{<a href="#fn1" id="fnref1">foo/bar.md</a>}
+ exp = %q(<a href="#fn1" id="fnref1">foo/bar.md</a>)
act = filter(exp)
expect(act.to_html).to eq exp
end
it 'allows correct footnote id property on li element' do
- exp = %q{<ol><li id="fn1">footnote</li></ol>}
+ exp = %q(<ol><li id="fn1">footnote</li></ol>)
act = filter(exp)
expect(act.to_html).to eq exp
end
it 'removes invalid id for footnote links' do
- exp = %q{<a href="#fn1">link</a>}
+ exp = %q(<a href="#fn1">link</a>)
%w[fnrefx test xfnref1].each do |id|
- act = filter(%Q{<a href="#fn1" id="#{id}">link</a>})
+ act = filter(%(<a href="#fn1" id="#{id}">link</a>))
expect(act.to_html).to eq exp
end
end
it 'removes invalid id for footnote li' do
- exp = %q{<ol><li>footnote</li></ol>}
+ exp = %q(<ol><li>footnote</li></ol>)
%w[fnx test xfn1].each do |id|
- act = filter(%Q{<ol><li id="#{id}">footnote</li></ol>})
+ act = filter(%(<ol><li id="#{id}">footnote</li></ol>))
expect(act.to_html).to eq exp
end
end
it 'allows footnotes numbered higher than 9' do
- exp = %q{<a href="#fn15" id="fnref15">link</a><ol><li id="fn15">footnote</li></ol>}
+ exp = %q(<a href="#fn15" id="fnref15">link</a><ol><li id="fn15">footnote</li></ol>)
act = filter(exp)
expect(act.to_html).to eq exp
diff --git a/spec/lib/banzai/filter/video_link_filter_spec.rb b/spec/lib/banzai/filter/video_link_filter_spec.rb
index 32fbc6b687f..ec954aa9163 100644
--- a/spec/lib/banzai/filter/video_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/video_link_filter_spec.rb
@@ -33,6 +33,7 @@ RSpec.describe Banzai::Filter::VideoLinkFilter do
expect(video.name).to eq 'video'
expect(video['src']).to eq src
expect(video['width']).to eq "400"
+ expect(video['preload']).to eq 'metadata'
expect(paragraph.name).to eq 'p'
diff --git a/spec/lib/banzai/pipeline/full_pipeline_spec.rb b/spec/lib/banzai/pipeline/full_pipeline_spec.rb
index bcee6f8f65d..989e06a992d 100644
--- a/spec/lib/banzai/pipeline/full_pipeline_spec.rb
+++ b/spec/lib/banzai/pipeline/full_pipeline_spec.rb
@@ -142,5 +142,12 @@ RSpec.describe Banzai::Pipeline::FullPipeline do
expect(output).to include("<span>#</span>#{issue.iid}")
end
+
+ it 'converts user reference with escaped underscore because of italics' do
+ markdown = '_@test\__'
+ output = described_class.to_html(markdown, project: project)
+
+ expect(output).to include('<em>@test_</em>')
+ end
end
end
diff --git a/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb b/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb
index 241d6db4f11..5f31ad0c8f6 100644
--- a/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb
+++ b/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb
@@ -31,11 +31,13 @@ RSpec.describe Banzai::Pipeline::PlainMarkdownPipeline do
end
end
- # Test strings taken from https://spec.commonmark.org/0.29/#backslash-escapes
describe 'CommonMark tests', :aggregate_failures do
- it 'converts all ASCII punctuation to literals' do
- markdown = %q(\!\"\#\$\%\&\'\*\+\,\-\.\/\:\;\<\=\>\?\@\[\]\^\_\`\{\|\}\~) + %q[\(\)\\\\]
- punctuation = %w(! " # $ % &amp; ' * + , - . / : ; &lt; = &gt; ? @ [ \\ ] ^ _ ` { | } ~) + %w[( )]
+ it 'converts all reference punctuation to literals' do
+ reference_chars = Banzai::Filter::MarkdownPreEscapeFilter::REFERENCE_CHARACTERS
+ markdown = reference_chars.split('').map {|char| char.prepend("\\") }.join
+ punctuation = Banzai::Filter::MarkdownPreEscapeFilter::REFERENCE_CHARACTERS.split('')
+ punctuation = punctuation.delete_if {|char| char == '&' }
+ punctuation << '&amp;'
result = described_class.call(markdown, project: project)
output = result[:output].to_html
@@ -44,57 +46,45 @@ RSpec.describe Banzai::Pipeline::PlainMarkdownPipeline do
expect(result[:escaped_literals]).to be_truthy
end
- it 'does not convert other characters to literals' do
- markdown = %q(\→\A\a\ \3\φ\«)
- expected = '\→\A\a\ \3\φ\«'
-
- result = correct_html_included(markdown, expected)
- expect(result[:escaped_literals]).to be_falsey
- end
+ it 'ensure we handle all the GitLab reference characters' do
+ reference_chars = ObjectSpace.each_object(Class).map do |klass|
+ next unless klass.included_modules.include?(Referable)
+ next unless klass.respond_to?(:reference_prefix)
+ next unless klass.reference_prefix.length == 1
- describe 'escaped characters are treated as regular characters and do not have their usual Markdown meanings' do
- where(:markdown, :expected) do
- %q(\*not emphasized*) | %q(<span>*</span>not emphasized*)
- %q(\<br/> not a tag) | %q(<span>&lt;</span>br/&gt; not a tag)
- %q!\[not a link](/foo)! | %q!<span>[</span>not a link](/foo)!
- %q(\`not code`) | %q(<span>`</span>not code`)
- %q(1\. not a list) | %q(1<span>.</span> not a list)
- %q(\# not a heading) | %q(<span>#</span> not a heading)
- %q(\[foo]: /url "not a reference") | %q(<span>[</span>foo]: /url "not a reference")
- %q(\&ouml; not a character entity) | %q(<span>&amp;</span>ouml; not a character entity)
- end
+ klass.reference_prefix
+ end.compact
- with_them do
- it 'keeps them as literals' do
- correct_html_included(markdown, expected)
- end
+ reference_chars.all? do |char|
+ Banzai::Filter::MarkdownPreEscapeFilter::REFERENCE_CHARACTERS.include?(char)
end
end
- it 'backslash is itself escaped, the following character is not' do
- markdown = %q(\\\\*emphasis*)
- expected = %q(<span>\</span><em>emphasis</em>)
+ it 'does not convert non-reference punctuation to spans' do
+ markdown = %q(\"\'\*\+\,\-\.\/\:\;\<\=\>\?\[\]\_\`\{\|\}) + %q[\(\)\\\\]
- correct_html_included(markdown, expected)
+ result = described_class.call(markdown, project: project)
+ output = result[:output].to_html
+
+ expect(output).not_to include('<span>')
+ expect(result[:escaped_literals]).to be_falsey
end
- it 'backslash at the end of the line is a hard line break' do
- markdown = <<~MARKDOWN
- foo\\
- bar
- MARKDOWN
- expected = "foo<br>\nbar"
+ it 'does not convert other characters to literals' do
+ markdown = %q(\→\A\a\ \3\φ\«)
+ expected = '\→\A\a\ \3\φ\«'
- correct_html_included(markdown, expected)
+ result = correct_html_included(markdown, expected)
+ expect(result[:escaped_literals]).to be_falsey
end
describe 'backslash escapes do not work in code blocks, code spans, autolinks, or raw HTML' do
where(:markdown, :expected) do
- %q(`` \[\` ``) | %q(<code>\[\`</code>)
- %q( \[\]) | %Q(<code>\\[\\]\n</code>)
- %Q(~~~\n\\[\\]\n~~~) | %Q(<code>\\[\\]\n</code>)
- %q(<http://example.com?find=\*>) | %q(<a href="http://example.com?find=%5C*">http://example.com?find=\*</a>)
- %q[<a href="/bar\/)">] | %q[<a href="/bar%5C/)">]
+ %q(`` \@\! ``) | %q(<code>\@\!</code>)
+ %q( \@\!) | %Q(<code>\\@\\!\n</code>)
+ %Q(~~~\n\\@\\!\n~~~) | %Q(<code>\\@\\!\n</code>)
+ %q(<http://example.com?find=\@>) | %q(<a href="http://example.com?find=%5C@">http://example.com?find=\@</a>)
+ %q[<a href="/bar\@)">] | %q[<a href="/bar%5C@)">]
end
with_them do
@@ -104,9 +94,9 @@ RSpec.describe Banzai::Pipeline::PlainMarkdownPipeline do
describe 'work in all other contexts, including URLs and link titles, link references, and info strings in fenced code blocks' do
where(:markdown, :expected) do
- %q![foo](/bar\* "ti\*tle")! | %q(<a href="/bar*" title="ti*tle">foo</a>)
- %Q![foo]\n\n[foo]: /bar\\* "ti\\*tle"! | %q(<a href="/bar*" title="ti*tle">foo</a>)
- %Q(``` foo\\+bar\nfoo\n```) | %Q(<code lang="foo+bar">foo\n</code>)
+ %q![foo](/bar\@ "\@title")! | %q(<a href="/bar@" title="@title">foo</a>)
+ %Q![foo]\n\n[foo]: /bar\\@ "\\@title"! | %q(<a href="/bar@" title="@title">foo</a>)
+ %Q(``` foo\\@bar\nfoo\n```) | %Q(<code lang="foo@bar">foo\n</code>)
end
with_them do
diff --git a/spec/lib/bulk_imports/common/loaders/entity_loader_spec.rb b/spec/lib/bulk_imports/common/loaders/entity_loader_spec.rb
deleted file mode 100644
index 57ffdfa9aee..00000000000
--- a/spec/lib/bulk_imports/common/loaders/entity_loader_spec.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe BulkImports::Common::Loaders::EntityLoader do
- describe '#load' do
- it "creates entities for the given data" do
- group = create(:group, path: "imported-group")
- parent_entity = create(:bulk_import_entity, group: group, bulk_import: create(:bulk_import))
- context = BulkImports::Pipeline::Context.new(parent_entity)
-
- data = {
- source_type: :group_entity,
- source_full_path: "parent/subgroup",
- destination_name: "subgroup",
- destination_namespace: parent_entity.group.full_path,
- parent_id: parent_entity.id
- }
-
- expect { subject.load(context, data) }.to change(BulkImports::Entity, :count).by(1)
-
- subgroup_entity = BulkImports::Entity.last
-
- expect(subgroup_entity.source_full_path).to eq 'parent/subgroup'
- expect(subgroup_entity.destination_namespace).to eq 'imported-group'
- expect(subgroup_entity.destination_name).to eq 'subgroup'
- expect(subgroup_entity.parent_id).to eq parent_entity.id
- end
- end
-end
diff --git a/spec/lib/bulk_imports/common/transformers/prohibited_attributes_transformer_spec.rb b/spec/lib/bulk_imports/common/transformers/prohibited_attributes_transformer_spec.rb
index 03d138b227c..08a82bc84ed 100644
--- a/spec/lib/bulk_imports/common/transformers/prohibited_attributes_transformer_spec.rb
+++ b/spec/lib/bulk_imports/common/transformers/prohibited_attributes_transformer_spec.rb
@@ -68,5 +68,11 @@ RSpec.describe BulkImports::Common::Transformers::ProhibitedAttributesTransforme
expect(transformed_hash).to eq(expected_hash)
end
+
+ context 'when there is no data to transform' do
+ it 'returns' do
+ expect(subject.transform(nil, nil)).to be_nil
+ end
+ end
end
end
diff --git a/spec/lib/bulk_imports/common/transformers/award_emoji_transformer_spec.rb b/spec/lib/bulk_imports/common/transformers/user_reference_transformer_spec.rb
index 5b560a30bf5..ff11a10bfe9 100644
--- a/spec/lib/bulk_imports/common/transformers/award_emoji_transformer_spec.rb
+++ b/spec/lib/bulk_imports/common/transformers/user_reference_transformer_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::Common::Transformers::AwardEmojiTransformer do
+RSpec.describe BulkImports::Common::Transformers::UserReferenceTransformer do
describe '#transform' do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
@@ -12,7 +12,6 @@ RSpec.describe BulkImports::Common::Transformers::AwardEmojiTransformer do
let(:hash) do
{
- 'name' => 'thumbs up',
'user' => {
'public_email' => email
}
@@ -44,5 +43,27 @@ RSpec.describe BulkImports::Common::Transformers::AwardEmojiTransformer do
include_examples 'sets user_id and removes user key'
end
+
+ context 'when there is no data to transform' do
+ it 'returns' do
+ expect(subject.transform(nil, nil)).to be_nil
+ end
+ end
+
+ context 'when custom reference is provided' do
+ it 'updates provided reference' do
+ hash = {
+ 'author' => {
+ 'public_email' => user.email
+ }
+ }
+
+ transformer = described_class.new(reference: 'author')
+ result = transformer.transform(context, hash)
+
+ expect(result['author']).to be_nil
+ expect(result['author_id']).to eq(user.id)
+ end
+ end
end
end
diff --git a/spec/lib/bulk_imports/groups/graphql/get_labels_query_spec.rb b/spec/lib/bulk_imports/groups/graphql/get_labels_query_spec.rb
index 247da200d68..85f82be7d18 100644
--- a/spec/lib/bulk_imports/groups/graphql/get_labels_query_spec.rb
+++ b/spec/lib/bulk_imports/groups/graphql/get_labels_query_spec.rb
@@ -3,15 +3,18 @@
require 'spec_helper'
RSpec.describe BulkImports::Groups::Graphql::GetLabelsQuery do
- describe '#variables' do
- let(:entity) { double(source_full_path: 'test', next_page_for: 'next_page', bulk_import: nil) }
- let(:context) { BulkImports::Pipeline::Context.new(entity) }
-
- it 'returns query variables based on entity information' do
- expected = { full_path: entity.source_full_path, cursor: entity.next_page_for }
-
- expect(described_class.variables(context)).to eq(expected)
- end
+ it 'has a valid query' do
+ entity = create(:bulk_import_entity)
+ context = BulkImports::Pipeline::Context.new(entity)
+
+ query = GraphQL::Query.new(
+ GitlabSchema,
+ described_class.to_s,
+ variables: described_class.variables(context)
+ )
+ result = GitlabSchema.static_validator.validate(query)
+
+ expect(result[:errors]).to be_empty
end
describe '#data_path' do
diff --git a/spec/lib/bulk_imports/groups/graphql/get_milestones_query_spec.rb b/spec/lib/bulk_imports/groups/graphql/get_milestones_query_spec.rb
new file mode 100644
index 00000000000..a38505fbf85
--- /dev/null
+++ b/spec/lib/bulk_imports/groups/graphql/get_milestones_query_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Groups::Graphql::GetMilestonesQuery do
+ it 'has a valid query' do
+ entity = create(:bulk_import_entity)
+ context = BulkImports::Pipeline::Context.new(entity)
+
+ query = GraphQL::Query.new(
+ GitlabSchema,
+ described_class.to_s,
+ variables: described_class.variables(context)
+ )
+ result = GitlabSchema.static_validator.validate(query)
+
+ expect(result[:errors]).to be_empty
+ end
+
+ describe '#data_path' do
+ it 'returns data path' do
+ expected = %w[data group milestones nodes]
+
+ expect(described_class.data_path).to eq(expected)
+ end
+ end
+
+ describe '#page_info_path' do
+ it 'returns pagination information path' do
+ expected = %w[data group milestones page_info]
+
+ expect(described_class.page_info_path).to eq(expected)
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/groups/loaders/labels_loader_spec.rb b/spec/lib/bulk_imports/groups/loaders/labels_loader_spec.rb
deleted file mode 100644
index ac2f9c8cb1d..00000000000
--- a/spec/lib/bulk_imports/groups/loaders/labels_loader_spec.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe BulkImports::Groups::Loaders::LabelsLoader do
- describe '#load' do
- let(:user) { create(:user) }
- let(:group) { create(:group) }
- let(:entity) { create(:bulk_import_entity, group: group) }
- let(:context) { BulkImports::Pipeline::Context.new(entity) }
-
- let(:data) do
- {
- 'title' => 'label',
- 'description' => 'description',
- 'color' => '#FFFFFF'
- }
- end
-
- it 'creates the label' do
- expect { subject.load(context, data) }.to change(Label, :count).by(1)
-
- label = group.labels.first
-
- expect(label.title).to eq(data['title'])
- expect(label.description).to eq(data['description'])
- expect(label.color).to eq(data['color'])
- end
- end
-end
diff --git a/spec/lib/bulk_imports/groups/loaders/members_loader_spec.rb b/spec/lib/bulk_imports/groups/loaders/members_loader_spec.rb
deleted file mode 100644
index d552578e7be..00000000000
--- a/spec/lib/bulk_imports/groups/loaders/members_loader_spec.rb
+++ /dev/null
@@ -1,42 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe BulkImports::Groups::Loaders::MembersLoader do
- describe '#load' do
- let_it_be(:user_importer) { create(:user) }
- let_it_be(:user_member) { create(:user) }
- let_it_be(:group) { create(:group) }
- let_it_be(:bulk_import) { create(:bulk_import, user: user_importer) }
- let_it_be(:entity) { create(:bulk_import_entity, bulk_import: bulk_import, group: group) }
- let_it_be(:context) { BulkImports::Pipeline::Context.new(entity) }
-
- let_it_be(:data) do
- {
- 'user_id' => user_member.id,
- 'created_by_id' => user_importer.id,
- 'access_level' => 30,
- 'created_at' => '2020-01-01T00:00:00Z',
- 'updated_at' => '2020-01-01T00:00:00Z',
- 'expires_at' => nil
- }
- end
-
- it 'does nothing when there is no data' do
- expect { subject.load(context, nil) }.not_to change(GroupMember, :count)
- end
-
- it 'creates the member' do
- expect { subject.load(context, data) }.to change(GroupMember, :count).by(1)
-
- member = group.members.last
-
- expect(member.user).to eq(user_member)
- expect(member.created_by).to eq(user_importer)
- expect(member.access_level).to eq(30)
- expect(member.created_at).to eq('2020-01-01T00:00:00Z')
- expect(member.updated_at).to eq('2020-01-01T00:00:00Z')
- expect(member.expires_at).to eq(nil)
- end
- end
-end
diff --git a/spec/lib/bulk_imports/groups/pipelines/labels_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/labels_pipeline_spec.rb
index 63f28916d9a..3327a30f1d5 100644
--- a/spec/lib/bulk_imports/groups/pipelines/labels_pipeline_spec.rb
+++ b/spec/lib/bulk_imports/groups/pipelines/labels_pipeline_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe BulkImports::Groups::Pipelines::LabelsPipeline do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:cursor) { 'cursor' }
+ let(:timestamp) { Time.new(2020, 01, 01).utc }
let(:entity) do
create(
:bulk_import_entity,
@@ -20,21 +21,23 @@ RSpec.describe BulkImports::Groups::Pipelines::LabelsPipeline do
subject { described_class.new(context) }
- def extractor_data(title:, has_next_page:, cursor: nil)
- data = [
- {
- 'title' => title,
- 'description' => 'desc',
- 'color' => '#428BCA'
- }
- ]
+ def label_data(title)
+ {
+ 'title' => title,
+ 'description' => 'desc',
+ 'color' => '#428BCA',
+ 'created_at' => timestamp.to_s,
+ 'updated_at' => timestamp.to_s
+ }
+ end
+ def extractor_data(title:, has_next_page:, cursor: nil)
page_info = {
'end_cursor' => cursor,
'has_next_page' => has_next_page
}
- BulkImports::Pipeline::ExtractedData.new(data: data, page_info: page_info)
+ BulkImports::Pipeline::ExtractedData.new(data: [label_data(title)], page_info: page_info)
end
describe '#run' do
@@ -55,6 +58,8 @@ RSpec.describe BulkImports::Groups::Pipelines::LabelsPipeline do
expect(label.title).to eq('label2')
expect(label.description).to eq('desc')
expect(label.color).to eq('#428BCA')
+ expect(label.created_at).to eq(timestamp)
+ expect(label.updated_at).to eq(timestamp)
end
end
@@ -90,6 +95,20 @@ RSpec.describe BulkImports::Groups::Pipelines::LabelsPipeline do
end
end
+ describe '#load' do
+ it 'creates the label' do
+ data = label_data('label')
+
+ expect { subject.load(context, data) }.to change(Label, :count).by(1)
+
+ label = group.labels.first
+
+ data.each do |key, value|
+ expect(label[key]).to eq(value)
+ end
+ end
+ end
+
describe 'pipeline parts' do
it { expect(described_class).to include_module(BulkImports::Pipeline) }
it { expect(described_class).to include_module(BulkImports::Pipeline::Runner) }
@@ -110,9 +129,5 @@ RSpec.describe BulkImports::Groups::Pipelines::LabelsPipeline do
{ klass: BulkImports::Common::Transformers::ProhibitedAttributesTransformer, options: nil }
)
end
-
- it 'has loaders' do
- expect(described_class.get_loader).to eq(klass: BulkImports::Groups::Loaders::LabelsLoader, options: nil)
- end
end
end
diff --git a/spec/lib/bulk_imports/groups/pipelines/members_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/members_pipeline_spec.rb
index 9f498f8154f..74d3e09d263 100644
--- a/spec/lib/bulk_imports/groups/pipelines/members_pipeline_spec.rb
+++ b/spec/lib/bulk_imports/groups/pipelines/members_pipeline_spec.rb
@@ -37,6 +37,34 @@ RSpec.describe BulkImports::Groups::Pipelines::MembersPipeline do
end
end
+ describe '#load' do
+ it 'does nothing when there is no data' do
+ expect { subject.load(context, nil) }.not_to change(GroupMember, :count)
+ end
+
+ it 'creates the member' do
+ data = {
+ 'user_id' => member_user1.id,
+ 'created_by_id' => member_user2.id,
+ 'access_level' => 30,
+ 'created_at' => '2020-01-01T00:00:00Z',
+ 'updated_at' => '2020-01-01T00:00:00Z',
+ 'expires_at' => nil
+ }
+
+ expect { subject.load(context, data) }.to change(GroupMember, :count).by(1)
+
+ member = group.members.last
+
+ expect(member.user).to eq(member_user1)
+ expect(member.created_by).to eq(member_user2)
+ expect(member.access_level).to eq(30)
+ expect(member.created_at).to eq('2020-01-01T00:00:00Z')
+ expect(member.updated_at).to eq('2020-01-01T00:00:00Z')
+ expect(member.expires_at).to eq(nil)
+ end
+ end
+
describe 'pipeline parts' do
it { expect(described_class).to include_module(BulkImports::Pipeline) }
it { expect(described_class).to include_module(BulkImports::Pipeline::Runner) }
@@ -58,10 +86,6 @@ RSpec.describe BulkImports::Groups::Pipelines::MembersPipeline do
{ klass: BulkImports::Groups::Transformers::MemberAttributesTransformer, options: nil }
)
end
-
- it 'has loaders' do
- expect(described_class.get_loader).to eq(klass: BulkImports::Groups::Loaders::MembersLoader, options: nil)
- end
end
def member_data(email:, has_next_page:, cursor: nil)
diff --git a/spec/lib/bulk_imports/groups/pipelines/milestones_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/milestones_pipeline_spec.rb
new file mode 100644
index 00000000000..f0c34c65257
--- /dev/null
+++ b/spec/lib/bulk_imports/groups/pipelines/milestones_pipeline_spec.rb
@@ -0,0 +1,151 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Groups::Pipelines::MilestonesPipeline do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:cursor) { 'cursor' }
+ let_it_be(:timestamp) { Time.new(2020, 01, 01).utc }
+ let_it_be(:bulk_import) { create(:bulk_import, user: user) }
+
+ let(:entity) do
+ create(
+ :bulk_import_entity,
+ bulk_import: bulk_import,
+ source_full_path: 'source/full/path',
+ destination_name: 'My Destination Group',
+ destination_namespace: group.full_path,
+ group: group
+ )
+ end
+
+ let(:context) { BulkImports::Pipeline::Context.new(entity) }
+
+ subject { described_class.new(context) }
+
+ def milestone_data(title)
+ {
+ 'title' => title,
+ 'description' => 'desc',
+ 'state' => 'closed',
+ 'start_date' => '2020-10-21',
+ 'due_date' => '2020-10-22',
+ 'created_at' => timestamp.to_s,
+ 'updated_at' => timestamp.to_s
+ }
+ end
+
+ def extracted_data(title:, has_next_page:, cursor: nil)
+ page_info = {
+ 'end_cursor' => cursor,
+ 'has_next_page' => has_next_page
+ }
+
+ BulkImports::Pipeline::ExtractedData.new(data: [milestone_data(title)], page_info: page_info)
+ end
+
+ before do
+ group.add_owner(user)
+ end
+
+ describe '#run' do
+ it 'imports group milestones' do
+ first_page = extracted_data(title: 'milestone1', has_next_page: true, cursor: cursor)
+ last_page = extracted_data(title: 'milestone2', has_next_page: false)
+
+ allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor|
+ allow(extractor)
+ .to receive(:extract)
+ .and_return(first_page, last_page)
+ end
+
+ expect { subject.run }.to change(Milestone, :count).by(2)
+
+ expect(group.milestones.pluck(:title)).to contain_exactly('milestone1', 'milestone2')
+
+ milestone = group.milestones.last
+
+ expect(milestone.description).to eq('desc')
+ expect(milestone.state).to eq('closed')
+ expect(milestone.start_date.to_s).to eq('2020-10-21')
+ expect(milestone.due_date.to_s).to eq('2020-10-22')
+ expect(milestone.created_at).to eq(timestamp)
+ expect(milestone.updated_at).to eq(timestamp)
+ end
+ end
+
+ describe '#after_run' do
+ context 'when extracted data has next page' do
+ it 'updates tracker information and runs pipeline again' do
+ data = extracted_data(title: 'milestone', has_next_page: true, cursor: cursor)
+
+ expect(subject).to receive(:run)
+
+ subject.after_run(data)
+
+ tracker = entity.trackers.find_by(relation: :milestones)
+
+ expect(tracker.has_next_page).to eq(true)
+ expect(tracker.next_page).to eq(cursor)
+ end
+ end
+
+ context 'when extracted data has no next page' do
+ it 'updates tracker information and does not run pipeline' do
+ data = extracted_data(title: 'milestone', has_next_page: false)
+
+ expect(subject).not_to receive(:run)
+
+ subject.after_run(data)
+
+ tracker = entity.trackers.find_by(relation: :milestones)
+
+ expect(tracker.has_next_page).to eq(false)
+ expect(tracker.next_page).to be_nil
+ end
+ end
+ end
+
+ describe '#load' do
+ it 'creates the milestone' do
+ data = milestone_data('milestone')
+
+ expect { subject.load(context, data) }.to change(Milestone, :count).by(1)
+ end
+
+ context 'when user is not authorized to create the milestone' do
+ before do
+ allow(user).to receive(:can?).with(:admin_milestone, group).and_return(false)
+ end
+
+ it 'raises NotAllowedError' do
+ data = extracted_data(title: 'milestone', has_next_page: false)
+
+ expect { subject.load(context, data) }.to raise_error(::BulkImports::Pipeline::NotAllowedError)
+ end
+ end
+ end
+
+ describe 'pipeline parts' do
+ it { expect(described_class).to include_module(BulkImports::Pipeline) }
+ it { expect(described_class).to include_module(BulkImports::Pipeline::Runner) }
+
+ it 'has extractors' do
+ expect(described_class.get_extractor)
+ .to eq(
+ klass: BulkImports::Common::Extractors::GraphqlExtractor,
+ options: {
+ query: BulkImports::Groups::Graphql::GetMilestonesQuery
+ }
+ )
+ end
+
+ it 'has transformers' do
+ expect(described_class.transformers)
+ .to contain_exactly(
+ { klass: BulkImports::Common::Transformers::ProhibitedAttributesTransformer, options: nil }
+ )
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb
index 0404c52b895..2a99646bb4a 100644
--- a/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb
+++ b/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb
@@ -3,9 +3,14 @@
require 'spec_helper'
RSpec.describe BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group, path: 'group') }
+ let_it_be(:parent) { create(:group, name: 'imported-group', path: 'imported-group') }
+ let(:context) { BulkImports::Pipeline::Context.new(parent_entity) }
+
+ subject { described_class.new(context) }
+
describe '#run' do
- let_it_be(:user) { create(:user) }
- let(:parent) { create(:group, name: 'imported-group', path: 'imported-group') }
let!(:parent_entity) do
create(
:bulk_import_entity,
@@ -14,8 +19,6 @@ RSpec.describe BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline do
)
end
- let(:context) { BulkImports::Pipeline::Context.new(parent_entity) }
-
let(:subgroup_data) do
[
{
@@ -25,8 +28,6 @@ RSpec.describe BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline do
]
end
- subject { described_class.new(context) }
-
before do
allow_next_instance_of(BulkImports::Groups::Extractors::SubgroupsExtractor) do |extractor|
allow(extractor).to receive(:extract).and_return(subgroup_data)
@@ -47,6 +48,29 @@ RSpec.describe BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline do
end
end
+ describe '#load' do
+ let(:parent_entity) { create(:bulk_import_entity, group: group, bulk_import: create(:bulk_import)) }
+
+ it 'creates entities for the given data' do
+ data = {
+ source_type: :group_entity,
+ source_full_path: 'parent/subgroup',
+ destination_name: 'subgroup',
+ destination_namespace: parent_entity.group.full_path,
+ parent_id: parent_entity.id
+ }
+
+ expect { subject.load(context, data) }.to change(BulkImports::Entity, :count).by(1)
+
+ subgroup_entity = BulkImports::Entity.last
+
+ expect(subgroup_entity.source_full_path).to eq 'parent/subgroup'
+ expect(subgroup_entity.destination_namespace).to eq 'group'
+ expect(subgroup_entity.destination_name).to eq 'subgroup'
+ expect(subgroup_entity.parent_id).to eq parent_entity.id
+ end
+ end
+
describe 'pipeline parts' do
it { expect(described_class).to include_module(BulkImports::Pipeline) }
it { expect(described_class).to include_module(BulkImports::Pipeline::Runner) }
@@ -61,9 +85,5 @@ RSpec.describe BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline do
{ klass: BulkImports::Groups::Transformers::SubgroupToEntityTransformer, options: nil }
)
end
-
- it 'has loaders' do
- expect(described_class.get_loader).to eq(klass: BulkImports::Common::Loaders::EntityLoader, options: nil)
- end
end
end
diff --git a/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb b/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb
index 5a7a51675d6..b3fe8a2ba25 100644
--- a/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb
+++ b/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb
@@ -80,14 +80,14 @@ RSpec.describe BulkImports::Groups::Transformers::GroupAttributesTransformer do
expect(transformed_data['parent_id']).to eq(parent.id)
end
- context 'when destination namespace is user namespace' do
+ context 'when destination namespace is empty' do
it 'does not set parent id' do
entity = create(
:bulk_import_entity,
bulk_import: bulk_import,
source_full_path: 'source/full/path',
destination_name: group.name,
- destination_namespace: user.namespace.full_path
+ destination_namespace: ''
)
context = BulkImports::Pipeline::Context.new(entity)
diff --git a/spec/lib/bulk_imports/importers/group_importer_spec.rb b/spec/lib/bulk_imports/importers/group_importer_spec.rb
index b4fdb7b5e5b..5d501b49e41 100644
--- a/spec/lib/bulk_imports/importers/group_importer_spec.rb
+++ b/spec/lib/bulk_imports/importers/group_importer_spec.rb
@@ -22,10 +22,13 @@ RSpec.describe BulkImports::Importers::GroupImporter do
expect_to_run_pipeline BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline, context: context
expect_to_run_pipeline BulkImports::Groups::Pipelines::MembersPipeline, context: context
expect_to_run_pipeline BulkImports::Groups::Pipelines::LabelsPipeline, context: context
+ expect_to_run_pipeline BulkImports::Groups::Pipelines::MilestonesPipeline, context: context
if Gitlab.ee?
expect_to_run_pipeline('EE::BulkImports::Groups::Pipelines::EpicsPipeline'.constantize, context: context)
expect_to_run_pipeline('EE::BulkImports::Groups::Pipelines::EpicAwardEmojiPipeline'.constantize, context: context)
+ expect_to_run_pipeline('EE::BulkImports::Groups::Pipelines::EpicEventsPipeline'.constantize, context: context)
+ expect_to_run_pipeline('EE::BulkImports::Groups::Pipelines::IterationsPipeline'.constantize, context: context)
end
subject.execute
diff --git a/spec/lib/bulk_imports/pipeline/runner_spec.rb b/spec/lib/bulk_imports/pipeline/runner_spec.rb
index 76e4e64a7d6..59f01c9caaa 100644
--- a/spec/lib/bulk_imports/pipeline/runner_spec.rb
+++ b/spec/lib/bulk_imports/pipeline/runner_spec.rb
@@ -27,29 +27,31 @@ RSpec.describe BulkImports::Pipeline::Runner do
end
end
- describe 'pipeline runner' do
- before do
- stub_const('BulkImports::Extractor', extractor)
- stub_const('BulkImports::Transformer', transformer)
- stub_const('BulkImports::Loader', loader)
-
- pipeline = Class.new do
- include BulkImports::Pipeline
+ before do
+ stub_const('BulkImports::Extractor', extractor)
+ stub_const('BulkImports::Transformer', transformer)
+ stub_const('BulkImports::Loader', loader)
- extractor BulkImports::Extractor
- transformer BulkImports::Transformer
- loader BulkImports::Loader
+ pipeline = Class.new do
+ include BulkImports::Pipeline
- def after_run(_); end
- end
+ extractor BulkImports::Extractor
+ transformer BulkImports::Transformer
+ loader BulkImports::Loader
- stub_const('BulkImports::MyPipeline', pipeline)
+ def after_run(_); end
end
- context 'when entity is not marked as failed' do
- let(:entity) { create(:bulk_import_entity) }
- let(:context) { BulkImports::Pipeline::Context.new(entity) }
+ stub_const('BulkImports::MyPipeline', pipeline)
+ end
+ let_it_be_with_refind(:entity) { create(:bulk_import_entity) }
+ let(:context) { BulkImports::Pipeline::Context.new(entity, extra: :data) }
+
+ subject { BulkImports::MyPipeline.new(context) }
+
+ describe 'pipeline runner' do
+ context 'when entity is not marked as failed' do
it 'runs pipeline extractor, transformer, loader' do
extracted_data = BulkImports::Pipeline::ExtractedData.new(data: { foo: :bar })
@@ -76,58 +78,61 @@ RSpec.describe BulkImports::Pipeline::Runner do
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
expect(logger).to receive(:info)
.with(
- bulk_import_entity_id: entity.id,
- bulk_import_entity_type: 'group_entity',
- message: 'Pipeline started',
- pipeline_class: 'BulkImports::MyPipeline'
+ log_params(
+ context,
+ message: 'Pipeline started',
+ pipeline_class: 'BulkImports::MyPipeline'
+ )
)
expect(logger).to receive(:info)
.with(
- bulk_import_entity_id: entity.id,
- bulk_import_entity_type: 'group_entity',
- pipeline_class: 'BulkImports::MyPipeline',
- pipeline_step: :extractor,
- step_class: 'BulkImports::Extractor'
+ log_params(
+ context,
+ pipeline_class: 'BulkImports::MyPipeline',
+ pipeline_step: :extractor,
+ step_class: 'BulkImports::Extractor'
+ )
)
expect(logger).to receive(:info)
.with(
- bulk_import_entity_id: entity.id,
- bulk_import_entity_type: 'group_entity',
- pipeline_class: 'BulkImports::MyPipeline',
- pipeline_step: :transformer,
- step_class: 'BulkImports::Transformer'
+ log_params(
+ context,
+ pipeline_class: 'BulkImports::MyPipeline',
+ pipeline_step: :transformer,
+ step_class: 'BulkImports::Transformer'
+ )
)
expect(logger).to receive(:info)
.with(
- bulk_import_entity_id: entity.id,
- bulk_import_entity_type: 'group_entity',
- pipeline_class: 'BulkImports::MyPipeline',
- pipeline_step: :loader,
- step_class: 'BulkImports::Loader'
+ log_params(
+ context,
+ pipeline_class: 'BulkImports::MyPipeline',
+ pipeline_step: :loader,
+ step_class: 'BulkImports::Loader'
+ )
)
expect(logger).to receive(:info)
.with(
- bulk_import_entity_id: entity.id,
- bulk_import_entity_type: 'group_entity',
- pipeline_class: 'BulkImports::MyPipeline',
- pipeline_step: :after_run
+ log_params(
+ context,
+ pipeline_class: 'BulkImports::MyPipeline',
+ pipeline_step: :after_run
+ )
)
expect(logger).to receive(:info)
.with(
- bulk_import_entity_id: entity.id,
- bulk_import_entity_type: 'group_entity',
- message: 'Pipeline finished',
- pipeline_class: 'BulkImports::MyPipeline'
+ log_params(
+ context,
+ message: 'Pipeline finished',
+ pipeline_class: 'BulkImports::MyPipeline'
+ )
)
end
- BulkImports::MyPipeline.new(context).run
+ subject.run
end
context 'when exception is raised' do
- let(:entity) { create(:bulk_import_entity, :created) }
- let(:context) { BulkImports::Pipeline::Context.new(entity) }
-
before do
allow_next_instance_of(BulkImports::Extractor) do |extractor|
allow(extractor).to receive(:extract).with(context).and_raise(StandardError, 'Error!')
@@ -135,7 +140,21 @@ RSpec.describe BulkImports::Pipeline::Runner do
end
it 'logs import failure' do
- BulkImports::MyPipeline.new(context).run
+ expect_next_instance_of(Gitlab::Import::Logger) do |logger|
+ expect(logger).to receive(:error)
+ .with(
+ log_params(
+ context,
+ pipeline_step: :extractor,
+ pipeline_class: 'BulkImports::MyPipeline',
+ exception_class: 'StandardError',
+ exception_message: 'Error!'
+ )
+ )
+ end
+
+ expect { subject.run }
+ .to change(entity.failures, :count).by(1)
failure = entity.failures.first
@@ -152,29 +171,29 @@ RSpec.describe BulkImports::Pipeline::Runner do
end
it 'marks entity as failed' do
- BulkImports::MyPipeline.new(context).run
-
- expect(entity.failed?).to eq(true)
+ expect { subject.run }
+ .to change(entity, :status_name).to(:failed)
end
it 'logs warn message' do
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
expect(logger).to receive(:warn)
.with(
- message: 'Pipeline failed',
- pipeline_class: 'BulkImports::MyPipeline',
- bulk_import_entity_id: entity.id,
- bulk_import_entity_type: entity.source_type
+ log_params(
+ context,
+ message: 'Pipeline failed',
+ pipeline_class: 'BulkImports::MyPipeline'
+ )
)
end
- BulkImports::MyPipeline.new(context).run
+ subject.run
end
end
context 'when pipeline is not marked to abort on failure' do
- it 'marks entity as failed' do
- BulkImports::MyPipeline.new(context).run
+ it 'does not mark entity as failed' do
+ subject.run
expect(entity.failed?).to eq(false)
end
@@ -183,24 +202,31 @@ RSpec.describe BulkImports::Pipeline::Runner do
end
context 'when entity is marked as failed' do
- let(:entity) { create(:bulk_import_entity) }
- let(:context) { BulkImports::Pipeline::Context.new(entity) }
-
it 'logs and returns without execution' do
- allow(entity).to receive(:failed?).and_return(true)
+ entity.fail_op!
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
expect(logger).to receive(:info)
.with(
- message: 'Skipping due to failed pipeline status',
- pipeline_class: 'BulkImports::MyPipeline',
- bulk_import_entity_id: entity.id,
- bulk_import_entity_type: 'group_entity'
+ log_params(
+ context,
+ message: 'Skipping due to failed pipeline status',
+ pipeline_class: 'BulkImports::MyPipeline'
+ )
)
end
- BulkImports::MyPipeline.new(context).run
+ subject.run
end
end
end
+
+ def log_params(context, extra = {})
+ {
+ bulk_import_id: context.bulk_import.id,
+ bulk_import_entity_id: context.entity.id,
+ bulk_import_entity_type: context.entity.source_type,
+ context_extra: context.extra
+ }.merge(extra)
+ end
end
diff --git a/spec/lib/bulk_imports/pipeline_spec.rb b/spec/lib/bulk_imports/pipeline_spec.rb
index 3811a02a7fd..c882e3d26ea 100644
--- a/spec/lib/bulk_imports/pipeline_spec.rb
+++ b/spec/lib/bulk_imports/pipeline_spec.rb
@@ -3,25 +3,25 @@
require 'spec_helper'
RSpec.describe BulkImports::Pipeline do
- describe 'pipeline attributes' do
- before do
- stub_const('BulkImports::Extractor', Class.new)
- stub_const('BulkImports::Transformer', Class.new)
- stub_const('BulkImports::Loader', Class.new)
-
- klass = Class.new do
- include BulkImports::Pipeline
+ before do
+ stub_const('BulkImports::Extractor', Class.new)
+ stub_const('BulkImports::Transformer', Class.new)
+ stub_const('BulkImports::Loader', Class.new)
- abort_on_failure!
+ klass = Class.new do
+ include BulkImports::Pipeline
- extractor BulkImports::Extractor, { foo: :bar }
- transformer BulkImports::Transformer, { foo: :bar }
- loader BulkImports::Loader, { foo: :bar }
- end
+ abort_on_failure!
- stub_const('BulkImports::MyPipeline', klass)
+ extractor BulkImports::Extractor, foo: :bar
+ transformer BulkImports::Transformer, foo: :bar
+ loader BulkImports::Loader, foo: :bar
end
+ stub_const('BulkImports::MyPipeline', klass)
+ end
+
+ describe 'pipeline attributes' do
describe 'getters' do
it 'retrieves class attributes' do
expect(BulkImports::MyPipeline.get_extractor).to eq({ klass: BulkImports::Extractor, options: { foo: :bar } })
@@ -29,6 +29,27 @@ RSpec.describe BulkImports::Pipeline do
expect(BulkImports::MyPipeline.get_loader).to eq({ klass: BulkImports::Loader, options: { foo: :bar } })
expect(BulkImports::MyPipeline.abort_on_failure?).to eq(true)
end
+
+ context 'when extractor and loader are defined within the pipeline' do
+ before do
+ klass = Class.new do
+ include BulkImports::Pipeline
+
+ def extract; end
+
+ def load; end
+ end
+
+ stub_const('BulkImports::AnotherPipeline', klass)
+ end
+
+ it 'returns itself when retrieving extractor & loader' do
+ pipeline = BulkImports::AnotherPipeline.new(nil)
+
+ expect(pipeline.send(:extractor)).to eq(pipeline)
+ expect(pipeline.send(:loader)).to eq(pipeline)
+ end
+ end
end
describe 'setters' do
@@ -54,4 +75,69 @@ RSpec.describe BulkImports::Pipeline do
end
end
end
+
+ describe '#instantiate' do
+ context 'when options are present' do
+ it 'instantiates new object with options' do
+ expect(BulkImports::Extractor).to receive(:new).with(foo: :bar)
+ expect(BulkImports::Transformer).to receive(:new).with(foo: :bar)
+ expect(BulkImports::Loader).to receive(:new).with(foo: :bar)
+
+ pipeline = BulkImports::MyPipeline.new(nil)
+
+ pipeline.send(:extractor)
+ pipeline.send(:transformers)
+ pipeline.send(:loader)
+ end
+ end
+
+ context 'when options are missing' do
+ before do
+ klass = Class.new do
+ include BulkImports::Pipeline
+
+ extractor BulkImports::Extractor
+ transformer BulkImports::Transformer
+ loader BulkImports::Loader
+ end
+
+ stub_const('BulkImports::NoOptionsPipeline', klass)
+ end
+
+ it 'instantiates new object without options' do
+ expect(BulkImports::Extractor).to receive(:new).with(no_args)
+ expect(BulkImports::Transformer).to receive(:new).with(no_args)
+ expect(BulkImports::Loader).to receive(:new).with(no_args)
+
+ pipeline = BulkImports::NoOptionsPipeline.new(nil)
+
+ pipeline.send(:extractor)
+ pipeline.send(:transformers)
+ pipeline.send(:loader)
+ end
+ end
+ end
+
+ describe '#transformers' do
+ before do
+ klass = Class.new do
+ include BulkImports::Pipeline
+
+ transformer BulkImports::Transformer
+
+ def transform; end
+ end
+
+ stub_const('BulkImports::TransformersPipeline', klass)
+ end
+
+ it 'has instance transform method first to run' do
+ transformer = double
+ allow(BulkImports::Transformer).to receive(:new).and_return(transformer)
+
+ pipeline = BulkImports::TransformersPipeline.new(nil)
+
+ expect(pipeline.send(:transformers)).to eq([pipeline, transformer])
+ end
+ end
end
diff --git a/spec/lib/sentry/api_urls_spec.rb b/spec/lib/error_tracking/sentry_client/api_urls_spec.rb
index d56b4397e1c..bd701748dc2 100644
--- a/spec/lib/sentry/api_urls_spec.rb
+++ b/spec/lib/error_tracking/sentry_client/api_urls_spec.rb
@@ -2,13 +2,13 @@
require 'spec_helper'
-RSpec.describe Sentry::ApiUrls do
+RSpec.describe ErrorTracking::SentryClient::ApiUrls do
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' }
let(:token) { 'test-token' }
let(:issue_id) { '123456' }
let(:issue_id_with_reserved_chars) { '123$%' }
let(:escaped_issue_id) { '123%24%25' }
- let(:api_urls) { Sentry::ApiUrls.new(sentry_url) }
+ let(:api_urls) { described_class.new(sentry_url) }
# Sentry API returns 404 if there are extra slashes in the URL!
shared_examples 'correct url with extra slashes' do
diff --git a/spec/lib/sentry/client/event_spec.rb b/spec/lib/error_tracking/sentry_client/event_spec.rb
index 07ed331c44c..64e674f1e9b 100644
--- a/spec/lib/sentry/client/event_spec.rb
+++ b/spec/lib/error_tracking/sentry_client/event_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Sentry::Client do
+RSpec.describe ErrorTracking::SentryClient do
include SentryClientHelpers
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }
diff --git a/spec/lib/sentry/client/issue_link_spec.rb b/spec/lib/error_tracking/sentry_client/issue_link_spec.rb
index fe3abe7cb23..f86d328ef89 100644
--- a/spec/lib/sentry/client/issue_link_spec.rb
+++ b/spec/lib/error_tracking/sentry_client/issue_link_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Sentry::Client::IssueLink do
+RSpec.describe ErrorTracking::SentryClient::IssueLink do
include SentryClientHelpers
let_it_be(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }
diff --git a/spec/lib/sentry/client/issue_spec.rb b/spec/lib/error_tracking/sentry_client/issue_spec.rb
index dedef905c95..e54296c58e0 100644
--- a/spec/lib/sentry/client/issue_spec.rb
+++ b/spec/lib/error_tracking/sentry_client/issue_spec.rb
@@ -2,12 +2,12 @@
require 'spec_helper'
-RSpec.describe Sentry::Client::Issue do
+RSpec.describe ErrorTracking::SentryClient::Issue do
include SentryClientHelpers
let(:token) { 'test-token' }
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0' }
- let(:client) { Sentry::Client.new(sentry_url, token) }
+ let(:client) { ErrorTracking::SentryClient.new(sentry_url, token) }
let(:issue_id) { 11 }
describe '#list_issues' do
@@ -136,7 +136,7 @@ RSpec.describe Sentry::Client::Issue do
subject { client.list_issues(issue_status: issue_status, limit: limit, sort: 'fish') }
it 'throws an error' do
- expect { subject }.to raise_error(Sentry::Client::BadRequestError, 'Invalid value for sort param')
+ expect { subject }.to raise_error(ErrorTracking::SentryClient::BadRequestError, 'Invalid value for sort param')
end
end
@@ -164,7 +164,7 @@ RSpec.describe Sentry::Client::Issue do
end
it 'raises exception' do
- expect { subject }.to raise_error(Sentry::Client::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
@@ -173,7 +173,7 @@ RSpec.describe Sentry::Client::Issue do
deep_size = 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(Sentry::Client::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
diff --git a/spec/lib/sentry/pagination_parser_spec.rb b/spec/lib/error_tracking/sentry_client/pagination_parser_spec.rb
index c4ed24827bb..c4b771d5b93 100644
--- a/spec/lib/sentry/pagination_parser_spec.rb
+++ b/spec/lib/error_tracking/sentry_client/pagination_parser_spec.rb
@@ -2,7 +2,7 @@
require 'fast_spec_helper'
-RSpec.describe Sentry::PaginationParser do
+RSpec.describe ErrorTracking::SentryClient::PaginationParser do
describe '.parse' do
subject { described_class.parse(headers) }
diff --git a/spec/lib/sentry/client/projects_spec.rb b/spec/lib/error_tracking/sentry_client/projects_spec.rb
index ea2c5ccb81e..247f9c1c085 100644
--- a/spec/lib/sentry/client/projects_spec.rb
+++ b/spec/lib/error_tracking/sentry_client/projects_spec.rb
@@ -2,12 +2,12 @@
require 'spec_helper'
-RSpec.describe Sentry::Client::Projects do
+RSpec.describe ErrorTracking::SentryClient::Projects do
include SentryClientHelpers
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }
let(:token) { 'test-token' }
- let(:client) { Sentry::Client.new(sentry_url, token) }
+ let(:client) { ErrorTracking::SentryClient.new(sentry_url, token) }
let(:projects_sample_response) do
Gitlab::Utils.deep_indifferent_access(
Gitlab::Json.parse(fixture_file('sentry/list_projects_sample_response.json'))
@@ -44,7 +44,7 @@ RSpec.describe Sentry::Client::Projects do
end
it 'raises exception' do
- expect { subject }.to raise_error(Sentry::Client::MissingKeysError, 'Sentry API response is missing keys. key not found: "slug"')
+ expect { subject }.to raise_error(ErrorTracking::SentryClient::MissingKeysError, 'Sentry API response is missing keys. key not found: "slug"')
end
end
diff --git a/spec/lib/sentry/client/repo_spec.rb b/spec/lib/error_tracking/sentry_client/repo_spec.rb
index 956c0b6eee1..9a1c7a69c3d 100644
--- a/spec/lib/sentry/client/repo_spec.rb
+++ b/spec/lib/error_tracking/sentry_client/repo_spec.rb
@@ -2,12 +2,12 @@
require 'spec_helper'
-RSpec.describe Sentry::Client::Repo do
+RSpec.describe ErrorTracking::SentryClient::Repo do
include SentryClientHelpers
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }
let(:token) { 'test-token' }
- let(:client) { Sentry::Client.new(sentry_url, token) }
+ let(:client) { ErrorTracking::SentryClient.new(sentry_url, token) }
let(:repos_sample_response) { Gitlab::Json.parse(fixture_file('sentry/repos_sample_response.json')) }
describe '#repos' do
diff --git a/spec/lib/sentry/client_spec.rb b/spec/lib/error_tracking/sentry_client_spec.rb
index cddcb6e98fa..9ffd756f057 100644
--- a/spec/lib/sentry/client_spec.rb
+++ b/spec/lib/error_tracking/sentry_client_spec.rb
@@ -2,11 +2,11 @@
require 'spec_helper'
-RSpec.describe Sentry::Client do
+RSpec.describe ErrorTracking::SentryClient do
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }
let(:token) { 'test-token' }
- subject { Sentry::Client.new(sentry_url, token) }
+ subject { described_class.new(sentry_url, token) }
it { is_expected.to respond_to :projects }
it { is_expected.to respond_to :list_issues }
diff --git a/spec/lib/expand_variables_spec.rb b/spec/lib/expand_variables_spec.rb
index b603325cdb8..407187ea05f 100644
--- a/spec/lib/expand_variables_spec.rb
+++ b/spec/lib/expand_variables_spec.rb
@@ -82,6 +82,13 @@ RSpec.describe ExpandVariables do
value: 'key$variable',
result: 'keyvalue',
variables: -> { [{ key: 'variable', value: 'value' }] }
+ },
+ "simple expansion using Collection": {
+ value: 'key$variable',
+ result: 'keyvalue',
+ variables: Gitlab::Ci::Variables::Collection.new([
+ { key: 'variable', value: 'value' }
+ ])
}
}
end
diff --git a/spec/lib/feature_spec.rb b/spec/lib/feature_spec.rb
index 1bcb2223012..3e158391d7f 100644
--- a/spec/lib/feature_spec.rb
+++ b/spec/lib/feature_spec.rb
@@ -269,7 +269,7 @@ RSpec.describe Feature, stub_feature_flags: false do
end
it 'when invalid type is used' do
- expect { described_class.enabled?(:my_feature_flag, type: :licensed) }
+ expect { described_class.enabled?(:my_feature_flag, type: :ops) }
.to raise_error(/The `type:` of/)
end
diff --git a/spec/lib/generators/gitlab/usage_metric_definition_generator_spec.rb b/spec/lib/generators/gitlab/usage_metric_definition_generator_spec.rb
new file mode 100644
index 00000000000..b62eac14e3e
--- /dev/null
+++ b/spec/lib/generators/gitlab/usage_metric_definition_generator_spec.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'generator_helper'
+
+RSpec.describe Gitlab::UsageMetricDefinitionGenerator do
+ describe 'Validation' do
+ let(:key_path) { 'counter.category.event' }
+ let(:dir) { '7d' }
+ let(:options) { [key_path, '--dir', dir, '--pretend'] }
+
+ subject { described_class.start(options) }
+
+ it 'does not raise an error' do
+ expect { subject }.not_to raise_error
+ end
+
+ context 'with a missing directory' do
+ let(:options) { [key_path, '--pretend'] }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(RuntimeError)
+ end
+ end
+
+ context 'with an invalid directory' do
+ let(:dir) { '8d' }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(RuntimeError)
+ end
+ end
+
+ context 'with an already existing metric with the same key_path' do
+ before do
+ allow(Gitlab::Usage::MetricDefinition).to receive(:definitions).and_return(Hash[key_path, 'definition'])
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(RuntimeError)
+ end
+ end
+ end
+
+ describe 'Name suggestions' do
+ let(:temp_dir) { Dir.mktmpdir }
+
+ before do
+ stub_const("#{described_class}::TOP_LEVEL_DIR", temp_dir)
+ end
+
+ context 'with product_intelligence_metrics_names_suggestions feature ON' do
+ it 'adds name key to metric definition' do
+ stub_feature_flags(product_intelligence_metrics_names_suggestions: true)
+
+ expect(::Gitlab::Usage::Metrics::NamesSuggestions::Generator).to receive(:generate).and_return('some name')
+ described_class.new(['counts_weekly.test_metric'], { 'dir' => '7d' }).invoke_all
+ metric_definition_path = Dir.glob(File.join(temp_dir, 'metrics/counts_7d/*_test_metric.yml')).first
+
+ expect(YAML.safe_load(File.read(metric_definition_path))).to include("name" => "some name")
+ end
+ end
+
+ context 'with product_intelligence_metrics_names_suggestions feature OFF' do
+ it 'adds name key to metric definition' do
+ stub_feature_flags(product_intelligence_metrics_names_suggestions: false)
+
+ expect(::Gitlab::Usage::Metrics::NamesSuggestions::Generator).not_to receive(:generate)
+ described_class.new(['counts_weekly.test_metric'], { 'dir' => '7d' }).invoke_all
+ metric_definition_path = Dir.glob(File.join(temp_dir, 'metrics/counts_7d/*_test_metric.yml')).first
+
+ expect(YAML.safe_load(File.read(metric_definition_path)).keys).not_to include(:name)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/alert_management/payload/generic_spec.rb b/spec/lib/gitlab/alert_management/payload/generic_spec.rb
index d022c629458..b0c238c62c8 100644
--- a/spec/lib/gitlab/alert_management/payload/generic_spec.rb
+++ b/spec/lib/gitlab/alert_management/payload/generic_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe Gitlab::AlertManagement::Payload::Generic do
describe '#title' do
subject { parsed_payload.title }
- it_behaves_like 'parsable alert payload field with fallback', 'New: Incident', 'title'
+ it_behaves_like 'parsable alert payload field with fallback', 'New: Alert', 'title'
end
describe '#severity' do
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/average_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/average_spec.rb
new file mode 100644
index 00000000000..e2fdd4918d5
--- /dev/null
+++ b/spec/lib/gitlab/analytics/cycle_analytics/average_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Analytics::CycleAnalytics::Average do
+ let_it_be(:project) { create(:project) }
+
+ let_it_be(:issue_1) do
+ # Duration: 10 days
+ create(:issue, project: project, created_at: 20.days.ago).tap do |issue|
+ issue.metrics.update!(first_mentioned_in_commit_at: 10.days.ago)
+ end
+ end
+
+ let_it_be(:issue_2) do
+ # Duration: 5 days
+ create(:issue, project: project, created_at: 20.days.ago).tap do |issue|
+ issue.metrics.update!(first_mentioned_in_commit_at: 15.days.ago)
+ end
+ end
+
+ let(:stage) do
+ build(
+ :cycle_analytics_project_stage,
+ start_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents::IssueCreated.identifier,
+ end_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents::IssueFirstMentionedInCommit.identifier,
+ project: project
+ )
+ end
+
+ let(:query) { Issue.joins(:metrics).in_projects(project.id) }
+
+ around do |example|
+ freeze_time { example.run }
+ end
+
+ subject(:average) { described_class.new(stage: stage, query: query) }
+
+ describe '#seconds' do
+ subject(:average_duration_in_seconds) { average.seconds }
+
+ context 'when no results' do
+ let(:query) { Issue.none }
+
+ it { is_expected.to eq(nil) }
+ end
+
+ context 'returns the average duration in seconds' do
+ it { is_expected.to be_within(0.5).of(7.5.days.to_f) }
+ end
+ end
+
+ describe '#days' do
+ subject(:average_duration_in_days) { average.days }
+
+ context 'when no results' do
+ let(:query) { Issue.none }
+
+ it { is_expected.to eq(nil) }
+ end
+
+ context 'returns the average duration in days' do
+ it { is_expected.to be_within(0.01).of(7.5) }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/sorting_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/sorting_spec.rb
new file mode 100644
index 00000000000..8f5be709a11
--- /dev/null
+++ b/spec/lib/gitlab/analytics/cycle_analytics/sorting_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Analytics::CycleAnalytics::Sorting do
+ let(:stage) { build(:cycle_analytics_project_stage, start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged) }
+
+ subject(:order_values) { described_class.apply(MergeRequest.joins(:metrics), stage, sort, direction).order_values }
+
+ context 'when invalid sorting params are given' do
+ let(:sort) { :unknown_sort }
+ let(:direction) { :unknown_direction }
+
+ it 'falls back to end_event DESC sorting' do
+ expect(order_values).to eq([stage.end_event.timestamp_projection.desc])
+ end
+ end
+
+ context 'sorting end_event' do
+ let(:sort) { :end_event }
+
+ context 'direction desc' do
+ let(:direction) { :desc }
+
+ specify do
+ expect(order_values).to eq([stage.end_event.timestamp_projection.desc])
+ end
+ end
+
+ context 'direction asc' do
+ let(:direction) { :asc }
+
+ specify do
+ expect(order_values).to eq([stage.end_event.timestamp_projection.asc])
+ end
+ end
+ end
+
+ context 'sorting duration' do
+ let(:sort) { :duration }
+
+ context 'direction desc' do
+ let(:direction) { :desc }
+
+ specify do
+ expect(order_values).to eq([Arel::Nodes::Subtraction.new(stage.end_event.timestamp_projection, stage.start_event.timestamp_projection).desc])
+ end
+ end
+
+ context 'direction asc' do
+ let(:direction) { :asc }
+
+ specify do
+ expect(order_values).to eq([Arel::Nodes::Subtraction.new(stage.end_event.timestamp_projection, stage.start_event.timestamp_projection).asc])
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/analytics/instance_statistics/workers_argument_builder_spec.rb b/spec/lib/gitlab/analytics/usage_trends/workers_argument_builder_spec.rb
index 115c8145f59..34c5bd6c6ae 100644
--- a/spec/lib/gitlab/analytics/instance_statistics/workers_argument_builder_spec.rb
+++ b/spec/lib/gitlab/analytics/usage_trends/workers_argument_builder_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Analytics::InstanceStatistics::WorkersArgumentBuilder do
+RSpec.describe Gitlab::Analytics::UsageTrends::WorkersArgumentBuilder do
context 'when no measurement identifiers are given' do
it 'returns empty array' do
expect(described_class.new(measurement_identifiers: []).execute).to be_empty
@@ -16,8 +16,8 @@ RSpec.describe Gitlab::Analytics::InstanceStatistics::WorkersArgumentBuilder do
let_it_be(:project_3) { create(:project, namespace: user_1.namespace, creator: user_1) }
let(:recorded_at) { 2.days.ago }
- let(:projects_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:projects) }
- let(:users_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:users) }
+ let(:projects_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:projects) }
+ let(:users_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:users) }
let(:measurement_identifiers) { [projects_measurement_identifier, users_measurement_identifier] }
subject { described_class.new(measurement_identifiers: measurement_identifiers, recorded_at: recorded_at).execute }
@@ -46,19 +46,19 @@ RSpec.describe Gitlab::Analytics::InstanceStatistics::WorkersArgumentBuilder do
context 'when custom min and max queries are present' do
let(:min_id) { User.second.id }
let(:max_id) { User.maximum(:id) }
- let(:users_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:users) }
+ let(:users_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:users) }
before do
create_list(:user, 2)
min_max_queries = {
- ::Analytics::InstanceStatistics::Measurement.identifiers[:users] => {
+ ::Analytics::UsageTrends::Measurement.identifiers[:users] => {
minimum_query: -> { min_id },
maximum_query: -> { max_id }
}
}
- allow(::Analytics::InstanceStatistics::Measurement).to receive(:identifier_min_max_queries) { min_max_queries }
+ allow(::Analytics::UsageTrends::Measurement).to receive(:identifier_min_max_queries) { min_max_queries }
end
subject do
diff --git a/spec/lib/gitlab/application_context_spec.rb b/spec/lib/gitlab/application_context_spec.rb
index 88f865adea7..0fbbc67ef6a 100644
--- a/spec/lib/gitlab/application_context_spec.rb
+++ b/spec/lib/gitlab/application_context_spec.rb
@@ -30,7 +30,7 @@ RSpec.describe Gitlab::ApplicationContext do
describe '.push' do
it 'passes the expected context on to labkit' do
fake_proc = duck_type(:call)
- expected_context = { user: fake_proc }
+ expected_context = { user: fake_proc, client_id: fake_proc }
expect(Labkit::Context).to receive(:push).with(expected_context)
@@ -92,6 +92,34 @@ RSpec.describe Gitlab::ApplicationContext do
expect(result(context))
.to include(project: project.full_path, root_namespace: project.full_path_components.first)
end
+
+ describe 'setting the client' do
+ let_it_be(:remote_ip) { '127.0.0.1' }
+ let_it_be(:runner) { create(:ci_runner) }
+ let_it_be(:options) { { remote_ip: remote_ip, runner: runner, user: user } }
+
+ using RSpec::Parameterized::TableSyntax
+
+ where(:provided_options, :client) do
+ [:remote_ip] | :remote_ip
+ [:remote_ip, :runner] | :runner
+ [:remote_ip, :runner, :user] | :user
+ end
+
+ with_them do
+ it 'sets the client_id to the expected value' do
+ context = described_class.new(**options.slice(*provided_options))
+
+ client_id = case client
+ when :remote_ip then "ip/#{remote_ip}"
+ when :runner then "runner/#{runner.id}"
+ when :user then "user/#{user.id}"
+ end
+
+ expect(result(context)[:client_id]).to eq(client_id)
+ end
+ 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 6c6cee9c273..7a8e6e77d52 100644
--- a/spec/lib/gitlab/auth/o_auth/user_spec.rb
+++ b/spec/lib/gitlab/auth/o_auth/user_spec.rb
@@ -995,6 +995,23 @@ RSpec.describe Gitlab::Auth::OAuth::User do
end
end
+ context 'when gl_user is nil' do
+ # We can't use `allow_next_instance_of` here because the stubbed method is called inside `initialize`.
+ # When the class calls `gl_user` during `initialize`, the `nil` value is overwritten and we do not see expected results from the spec.
+ # So we use `allow_any_instance_of` to preserve the `nil` value to test the behavior when `gl_user` is nil.
+
+ # rubocop:disable RSpec/AnyInstanceOf
+ before do
+ allow_any_instance_of(described_class).to receive(:gl_user) { nil }
+ allow_any_instance_of(described_class).to receive(:sync_profile_from_provider?) { true } # to make the code flow proceed until gl_user.build_user_synced_attributes_metadata is called
+ end
+ # rubocop:enable RSpec/AnyInstanceOf
+
+ it 'does not raise NoMethodError' do
+ expect { oauth_user }.not_to raise_error
+ end
+ end
+
describe '._uid_and_provider' do
let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') }
diff --git a/spec/lib/gitlab/avatar_cache_spec.rb b/spec/lib/gitlab/avatar_cache_spec.rb
new file mode 100644
index 00000000000..ffe6f81b6e7
--- /dev/null
+++ b/spec/lib/gitlab/avatar_cache_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe Gitlab::AvatarCache, :clean_gitlab_redis_cache do
+ def with(&blk)
+ Gitlab::Redis::Cache.with(&blk) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
+ def read(key, subkey)
+ with do |redis|
+ redis.hget(key, subkey)
+ end
+ end
+
+ let(:thing) { double("thing", avatar_path: avatar_path) }
+ let(:avatar_path) { "/avatars/my_fancy_avatar.png" }
+ let(:key) { described_class.send(:email_key, "foo@bar.com") }
+
+ let(:perform_fetch) do
+ described_class.by_email("foo@bar.com", 20, 2, true) do
+ thing.avatar_path
+ end
+ end
+
+ describe "#by_email" do
+ it "writes a new value into the cache" do
+ expect(read(key, "20:2:true")).to eq(nil)
+
+ perform_fetch
+
+ expect(read(key, "20:2:true")).to eq(avatar_path)
+ end
+
+ it "finds the cached value and doesn't execute the block" do
+ expect(thing).to receive(:avatar_path).once
+
+ described_class.by_email("foo@bar.com", 20, 2, true) do
+ thing.avatar_path
+ end
+
+ described_class.by_email("foo@bar.com", 20, 2, true) do
+ thing.avatar_path
+ end
+ end
+
+ it "finds the cached value in the request store and doesn't execute the block" do
+ expect(thing).to receive(:avatar_path).once
+
+ Gitlab::WithRequestStore.with_request_store do
+ described_class.by_email("foo@bar.com", 20, 2, true) do
+ thing.avatar_path
+ end
+
+ described_class.by_email("foo@bar.com", 20, 2, true) do
+ thing.avatar_path
+ end
+
+ expect(Gitlab::SafeRequestStore.read([key, "20:2:true"])).to eq(avatar_path)
+ end
+ end
+ end
+
+ describe "#delete_by_email" do
+ subject { described_class.delete_by_email(*emails) }
+
+ before do
+ perform_fetch
+ end
+
+ context "no emails, somehow" do
+ let(:emails) { [] }
+
+ it { is_expected.to eq(0) }
+ end
+
+ context "single email" do
+ let(:emails) { "foo@bar.com" }
+
+ it "removes the email" do
+ expect(read(key, "20:2:true")).to eq(avatar_path)
+
+ expect(subject).to eq(1)
+
+ expect(read(key, "20:2:true")).to eq(nil)
+ end
+ end
+
+ context "multiple emails" do
+ let(:emails) { ["foo@bar.com", "missing@baz.com"] }
+
+ it "removes the emails it finds" do
+ expect(read(key, "20:2:true")).to eq(avatar_path)
+
+ expect(subject).to eq(1)
+
+ expect(read(key, "20:2:true")).to eq(nil)
+ end
+ end
+ end
+end
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
new file mode 100644
index 00000000000..8febe850e04
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchingStrategy, '#next_batch' do
+ let(:batching_strategy) { described_class.new }
+ let(:namespaces) { table(:namespaces) }
+
+ let!(:namespace1) { namespaces.create!(name: 'batchtest1', path: 'batch-test1') }
+ let!(:namespace2) { namespaces.create!(name: 'batchtest2', path: 'batch-test2') }
+ let!(:namespace3) { namespaces.create!(name: 'batchtest3', path: 'batch-test3') }
+ let!(:namespace4) { namespaces.create!(name: 'batchtest4', path: 'batch-test4') }
+
+ 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)
+
+ expect(batch_bounds).to eq([namespace1.id, namespace3.id])
+ end
+ end
+
+ 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)
+
+ expect(batch_bounds).to eq([namespace2.id, namespace4.id])
+ end
+ end
+
+ 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)
+
+ expect(batch_bounds).to eq([namespace4.id, namespace4.id])
+ end
+ end
+
+ 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)
+
+ expect(batch_bounds).to be_nil
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb b/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb
index 110a1ff8a08..7ad93c3124a 100644
--- a/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb
+++ b/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb
@@ -38,22 +38,9 @@ RSpec.describe Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJo
describe '#perform' do
let(:migration_class) { described_class.name }
- let!(:job1) do
- table(:background_migration_jobs).create!(
- class_name: migration_class,
- arguments: [1, 10, table_name, 'id', 'id', 'id_convert_to_bigint', sub_batch_size]
- )
- end
-
- let!(:job2) do
- table(:background_migration_jobs).create!(
- class_name: migration_class,
- arguments: [11, 20, table_name, 'id', 'id', 'id_convert_to_bigint', sub_batch_size]
- )
- end
it 'copies all primary keys in range' do
- subject.perform(12, 15, table_name, 'id', 'id', 'id_convert_to_bigint', sub_batch_size)
+ subject.perform(12, 15, table_name, 'id', sub_batch_size, 'id', 'id_convert_to_bigint')
expect(test_table.where('id = id_convert_to_bigint').pluck(:id)).to contain_exactly(12, 15)
expect(test_table.where(id_convert_to_bigint: 0).pluck(:id)).to contain_exactly(11, 19)
@@ -61,7 +48,7 @@ RSpec.describe Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJo
end
it 'copies all foreign keys in range' do
- subject.perform(10, 14, table_name, 'id', 'fk', 'fk_convert_to_bigint', sub_batch_size)
+ subject.perform(10, 14, table_name, 'id', sub_batch_size, 'fk', 'fk_convert_to_bigint')
expect(test_table.where('fk = fk_convert_to_bigint').pluck(:id)).to contain_exactly(11, 12)
expect(test_table.where(fk_convert_to_bigint: 0).pluck(:id)).to contain_exactly(15, 19)
@@ -71,21 +58,11 @@ RSpec.describe Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJo
it 'copies columns with NULLs' do
expect(test_table.where("name_convert_to_text = 'no name'").count).to eq(4)
- subject.perform(10, 20, table_name, 'id', 'name', 'name_convert_to_text', sub_batch_size)
+ subject.perform(10, 20, table_name, 'id', sub_batch_size, 'name', 'name_convert_to_text')
expect(test_table.where('name = name_convert_to_text').pluck(:id)).to contain_exactly(11, 12, 19)
expect(test_table.where('name is NULL and name_convert_to_text is NULL').pluck(:id)).to contain_exactly(15)
expect(test_table.where("name_convert_to_text = 'no name'").count).to eq(0)
end
-
- it 'tracks completion with BackgroundMigrationJob' do
- expect do
- subject.perform(11, 20, table_name, 'id', 'id', 'id_convert_to_bigint', sub_batch_size)
- end.to change { Gitlab::Database::BackgroundMigrationJob.succeeded.count }.from(0).to(1)
-
- expect(job1.reload.status).to eq(0)
- expect(job2.reload.status).to eq(1)
- expect(test_table.where('id = id_convert_to_bigint').count).to eq(4)
- end
end
end
diff --git a/spec/lib/gitlab/background_migration/merge_request_assignees_migration_progress_check_spec.rb b/spec/lib/gitlab/background_migration/merge_request_assignees_migration_progress_check_spec.rb
deleted file mode 100644
index 85a9c88ebff..00000000000
--- a/spec/lib/gitlab/background_migration/merge_request_assignees_migration_progress_check_spec.rb
+++ /dev/null
@@ -1,99 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::MergeRequestAssigneesMigrationProgressCheck do
- context 'rescheduling' do
- context 'when there are ongoing and no dead jobs' do
- it 'reschedules check' do
- allow(Gitlab::BackgroundMigration).to receive(:exists?)
- .with('PopulateMergeRequestAssigneesTable')
- .and_return(true)
-
- allow(Gitlab::BackgroundMigration).to receive(:dead_jobs?)
- .with('PopulateMergeRequestAssigneesTable')
- .and_return(false)
-
- expect(BackgroundMigrationWorker).to receive(:perform_in).with(described_class::RESCHEDULE_DELAY, described_class.name)
-
- described_class.new.perform
- end
- end
-
- context 'when there are ongoing and dead jobs' do
- it 'reschedules check' do
- allow(Gitlab::BackgroundMigration).to receive(:exists?)
- .with('PopulateMergeRequestAssigneesTable')
- .and_return(true)
-
- allow(Gitlab::BackgroundMigration).to receive(:dead_jobs?)
- .with('PopulateMergeRequestAssigneesTable')
- .and_return(true)
-
- expect(BackgroundMigrationWorker).to receive(:perform_in).with(described_class::RESCHEDULE_DELAY, described_class.name)
-
- described_class.new.perform
- end
- end
-
- context 'when there retrying jobs and no scheduled' do
- it 'reschedules check' do
- allow(Gitlab::BackgroundMigration).to receive(:exists?)
- .with('PopulateMergeRequestAssigneesTable')
- .and_return(false)
-
- allow(Gitlab::BackgroundMigration).to receive(:retrying_jobs?)
- .with('PopulateMergeRequestAssigneesTable')
- .and_return(true)
-
- expect(BackgroundMigrationWorker).to receive(:perform_in).with(described_class::RESCHEDULE_DELAY, described_class.name)
-
- described_class.new.perform
- end
- end
- end
-
- context 'when there are no scheduled, or retrying or dead' do
- before do
- stub_feature_flags(multiple_merge_request_assignees: false)
- end
-
- it 'enables feature' do
- allow(Gitlab::BackgroundMigration).to receive(:exists?)
- .with('PopulateMergeRequestAssigneesTable')
- .and_return(false)
-
- allow(Gitlab::BackgroundMigration).to receive(:retrying_jobs?)
- .with('PopulateMergeRequestAssigneesTable')
- .and_return(false)
-
- allow(Gitlab::BackgroundMigration).to receive(:dead_jobs?)
- .with('PopulateMergeRequestAssigneesTable')
- .and_return(false)
-
- described_class.new.perform
-
- expect(Feature.enabled?(:multiple_merge_request_assignees, type: :licensed)).to eq(true)
- end
- end
-
- context 'when there are only dead jobs' do
- it 'raises DeadJobsError error' do
- allow(Gitlab::BackgroundMigration).to receive(:exists?)
- .with('PopulateMergeRequestAssigneesTable')
- .and_return(false)
-
- allow(Gitlab::BackgroundMigration).to receive(:retrying_jobs?)
- .with('PopulateMergeRequestAssigneesTable')
- .and_return(false)
-
- allow(Gitlab::BackgroundMigration).to receive(:dead_jobs?)
- .with('PopulateMergeRequestAssigneesTable')
- .and_return(true)
-
- expect { described_class.new.perform }
- .to raise_error(described_class::DeadJobsError,
- "Only dead background jobs in the queue for #{described_class::WORKER}")
- end
- end
-end
diff --git a/spec/lib/gitlab/background_migration/migrate_legacy_artifacts_spec.rb b/spec/lib/gitlab/background_migration/migrate_legacy_artifacts_spec.rb
index 08f2b2a043e..5c93e69b5e5 100644
--- a/spec/lib/gitlab/background_migration/migrate_legacy_artifacts_spec.rb
+++ b/spec/lib/gitlab/background_migration/migrate_legacy_artifacts_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::BackgroundMigration::MigrateLegacyArtifacts do
+RSpec.describe Gitlab::BackgroundMigration::MigrateLegacyArtifacts, schema: 20210210093901 do
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:pipelines) { table(:ci_pipelines) }
diff --git a/spec/lib/gitlab/background_migration/move_container_registry_enabled_to_project_feature_spec.rb b/spec/lib/gitlab/background_migration/move_container_registry_enabled_to_project_feature_spec.rb
new file mode 100644
index 00000000000..1c62d703a34
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/move_container_registry_enabled_to_project_feature_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::MoveContainerRegistryEnabledToProjectFeature, :migration, schema: 2021_02_26_120851 do
+ let(:enabled) { 20 }
+ let(:disabled) { 0 }
+
+ let(:namespaces) { table(:namespaces) }
+ let(:project_features) { table(:project_features) }
+ let(:projects) { table(:projects) }
+
+ let(:namespace) { namespaces.create!(name: 'user', path: 'user') }
+ let!(:project1) { projects.create!(namespace_id: namespace.id) }
+ let!(:project2) { projects.create!(namespace_id: namespace.id) }
+ let!(:project3) { projects.create!(namespace_id: namespace.id) }
+ let!(:project4) { projects.create!(namespace_id: namespace.id) }
+
+ # pages_access_level cannot be null.
+ let(:non_null_project_features) { { pages_access_level: enabled } }
+ let!(:project_feature1) { project_features.create!(project_id: project1.id, **non_null_project_features) }
+ let!(:project_feature2) { project_features.create!(project_id: project2.id, **non_null_project_features) }
+ let!(:project_feature3) { project_features.create!(project_id: project3.id, **non_null_project_features) }
+
+ describe '#perform' do
+ before do
+ project1.update!(container_registry_enabled: true)
+ project2.update!(container_registry_enabled: false)
+ project3.update!(container_registry_enabled: nil)
+ project4.update!(container_registry_enabled: true)
+ end
+
+ it 'copies values to project_features' do
+ expect(project1.container_registry_enabled).to eq(true)
+ expect(project2.container_registry_enabled).to eq(false)
+ expect(project3.container_registry_enabled).to eq(nil)
+ expect(project4.container_registry_enabled).to eq(true)
+
+ expect(project_feature1.container_registry_access_level).to eq(disabled)
+ expect(project_feature2.container_registry_access_level).to eq(disabled)
+ expect(project_feature3.container_registry_access_level).to eq(disabled)
+
+ expect_next_instance_of(Gitlab::BackgroundMigration::Logger) do |logger|
+ expect(logger).to receive(:info)
+ .with(message: "#{described_class}: Copied container_registry_enabled values for projects with IDs between #{project1.id}..#{project4.id}")
+
+ expect(logger).not_to receive(:info)
+ end
+
+ subject.perform(project1.id, project4.id)
+
+ expect(project1.reload.container_registry_enabled).to eq(true)
+ expect(project2.reload.container_registry_enabled).to eq(false)
+ expect(project3.reload.container_registry_enabled).to eq(nil)
+ expect(project4.container_registry_enabled).to eq(true)
+
+ expect(project_feature1.reload.container_registry_access_level).to eq(enabled)
+ expect(project_feature2.reload.container_registry_access_level).to eq(disabled)
+ expect(project_feature3.reload.container_registry_access_level).to eq(disabled)
+ end
+
+ context 'when no projects exist in range' do
+ it 'does not fail' do
+ expect(project1.container_registry_enabled).to eq(true)
+ expect(project_feature1.container_registry_access_level).to eq(disabled)
+
+ expect { subject.perform(-1, -2) }.not_to raise_error
+
+ expect(project1.container_registry_enabled).to eq(true)
+ expect(project_feature1.container_registry_access_level).to eq(disabled)
+ end
+ end
+
+ context 'when projects in range all have nil container_registry_enabled' do
+ it 'does not fail' do
+ expect(project3.container_registry_enabled).to eq(nil)
+ expect(project_feature3.container_registry_access_level).to eq(disabled)
+
+ expect { subject.perform(project3.id, project3.id) }.not_to raise_error
+
+ expect(project3.container_registry_enabled).to eq(nil)
+ expect(project_feature3.container_registry_access_level).to eq(disabled)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback_spec.rb b/spec/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback_spec.rb
index 8e74935e127..07b1d99d333 100644
--- a/spec/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback_spec.rb
+++ b/spec/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback_spec.rb
@@ -27,12 +27,33 @@ RSpec.describe Gitlab::BackgroundMigration::PopulateFindingUuidForVulnerabilityF
let(:finding_1) { finding_creator.call(sast_report, location_fingerprint_1) }
let(:finding_2) { finding_creator.call(dast_report, location_fingerprint_2) }
let(:finding_3) { finding_creator.call(secret_detection_report, location_fingerprint_3) }
- let(:uuid_1_components) { ['sast', identifier.fingerprint, location_fingerprint_1, project.id].join('-') }
- let(:uuid_2_components) { ['dast', identifier.fingerprint, location_fingerprint_2, project.id].join('-') }
- let(:uuid_3_components) { ['secret_detection', identifier.fingerprint, location_fingerprint_3, project.id].join('-') }
- let(:expected_uuid_1) { Gitlab::UUID.v5(uuid_1_components) }
- let(:expected_uuid_2) { Gitlab::UUID.v5(uuid_2_components) }
- let(:expected_uuid_3) { Gitlab::UUID.v5(uuid_3_components) }
+ let(:expected_uuid_1) do
+ Security::VulnerabilityUUID.generate(
+ report_type: 'sast',
+ primary_identifier_fingerprint: identifier.fingerprint,
+ location_fingerprint: location_fingerprint_1,
+ project_id: project.id
+ )
+ end
+
+ let(:expected_uuid_2) do
+ Security::VulnerabilityUUID.generate(
+ report_type: 'dast',
+ primary_identifier_fingerprint: identifier.fingerprint,
+ location_fingerprint: location_fingerprint_2,
+ project_id: project.id
+ )
+ end
+
+ let(:expected_uuid_3) do
+ Security::VulnerabilityUUID.generate(
+ report_type: 'secret_detection',
+ primary_identifier_fingerprint: identifier.fingerprint,
+ location_fingerprint: location_fingerprint_3,
+ project_id: project.id
+ )
+ end
+
let(:finding_creator) do
-> (report_type, location_fingerprint) do
findings.create!(
diff --git a/spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb b/spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb
new file mode 100644
index 00000000000..990ef4fbe6a
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb
@@ -0,0 +1,149 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::RecalculateVulnerabilitiesOccurrencesUuid, schema: 20201110110454 do
+ let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') }
+ let(:users) { table(:users) }
+ let(:user) { create_user! }
+ let(:project) { table(:projects).create!(id: 123, namespace_id: namespace.id) }
+ let(:scanners) { table(:vulnerability_scanners) }
+ let(:scanner) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') }
+ let(:different_scanner) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') }
+ let(:vulnerabilities) { table(:vulnerabilities) }
+ let(:vulnerabilities_findings) { table(:vulnerability_occurrences) }
+ let(:vulnerability_identifiers) { table(:vulnerability_identifiers) }
+ let(:vulnerability_identifier) do
+ vulnerability_identifiers.create!(
+ project_id: project.id,
+ external_type: 'uuid-v5',
+ external_id: 'uuid-v5',
+ fingerprint: '7e394d1b1eb461a7406d7b1e08f057a1cf11287a',
+ name: 'Identifier for UUIDv5')
+ end
+
+ let(:different_vulnerability_identifier) do
+ vulnerability_identifiers.create!(
+ project_id: project.id,
+ external_type: 'uuid-v4',
+ external_id: 'uuid-v4',
+ fingerprint: '772da93d34a1ba010bcb5efa9fb6f8e01bafcc89',
+ name: 'Identifier for UUIDv4')
+ end
+
+ let!(:vulnerability_for_uuidv4) do
+ create_vulnerability!(
+ project_id: project.id,
+ author_id: user.id
+ )
+ end
+
+ let!(:vulnerability_for_uuidv5) do
+ create_vulnerability!(
+ project_id: project.id,
+ author_id: user.id
+ )
+ end
+
+ let(:known_uuid_v5) { "77211ed6-7dff-5f6b-8c9a-da89ad0a9b60" }
+ let(:known_uuid_v4) { "b3cc2518-5446-4dea-871c-89d5e999c1ac" }
+ let(:desired_uuid_v5) { "3ca8ad45-6344-508b-b5e3-306a3bd6c6ba" }
+
+ subject { described_class.new.perform(finding.id, finding.id) }
+
+ context "when finding has a UUIDv4" do
+ before do
+ @uuid_v4 = create_finding!(
+ vulnerability_id: vulnerability_for_uuidv4.id,
+ project_id: project.id,
+ scanner_id: different_scanner.id,
+ primary_identifier_id: different_vulnerability_identifier.id,
+ report_type: 0, # "sast"
+ location_fingerprint: "fa18f432f1d56675f4098d318739c3cd5b14eb3e",
+ uuid: known_uuid_v4
+ )
+ end
+
+ let(:finding) { @uuid_v4 }
+
+ it "replaces it with UUIDv5" do
+ expect(vulnerabilities_findings.pluck(:uuid)).to eq([known_uuid_v4])
+
+ subject
+
+ expect(vulnerabilities_findings.pluck(:uuid)).to eq([desired_uuid_v5])
+ end
+ end
+
+ context "when finding has a UUIDv5" do
+ before do
+ @uuid_v5 = create_finding!(
+ vulnerability_id: vulnerability_for_uuidv5.id,
+ project_id: project.id,
+ scanner_id: scanner.id,
+ primary_identifier_id: vulnerability_identifier.id,
+ report_type: 0, # "sast"
+ location_fingerprint: "838574be0210968bf6b9f569df9c2576242cbf0a",
+ uuid: known_uuid_v5
+ )
+ end
+
+ let(:finding) { @uuid_v5 }
+
+ it "stays the same" do
+ expect(vulnerabilities_findings.pluck(:uuid)).to eq([known_uuid_v5])
+
+ subject
+
+ expect(vulnerabilities_findings.pluck(:uuid)).to eq([known_uuid_v5])
+ end
+ end
+
+ private
+
+ def create_vulnerability!(project_id:, author_id:, title: 'test', severity: 7, confidence: 7, report_type: 0)
+ vulnerabilities.create!(
+ project_id: project_id,
+ author_id: author_id,
+ title: title,
+ severity: severity,
+ confidence: confidence,
+ report_type: report_type
+ )
+ end
+
+ # rubocop:disable Metrics/ParameterLists
+ def create_finding!(
+ vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:,
+ name: "test", severity: 7, confidence: 7, report_type: 0,
+ project_fingerprint: '123qweasdzxc', location_fingerprint: 'test',
+ metadata_version: 'test', raw_metadata: 'test', uuid: 'test')
+ vulnerabilities_findings.create!(
+ vulnerability_id: vulnerability_id,
+ project_id: project_id,
+ name: name,
+ severity: severity,
+ confidence: confidence,
+ report_type: report_type,
+ project_fingerprint: project_fingerprint,
+ scanner_id: scanner.id,
+ primary_identifier_id: vulnerability_identifier.id,
+ location_fingerprint: location_fingerprint,
+ metadata_version: metadata_version,
+ raw_metadata: raw_metadata,
+ uuid: uuid
+ )
+ end
+ # rubocop:enable Metrics/ParameterLists
+
+ def create_user!(name: "Example User", email: "user@example.com", user_type: nil, created_at: Time.zone.now, confirmed_at: Time.zone.now)
+ users.create!(
+ name: name,
+ email: email,
+ username: name,
+ projects_limit: 0,
+ user_type: user_type,
+ confirmed_at: confirmed_at
+ )
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/set_default_iteration_cadences_spec.rb b/spec/lib/gitlab/background_migration/set_default_iteration_cadences_spec.rb
new file mode 100644
index 00000000000..46c919f0854
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/set_default_iteration_cadences_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::SetDefaultIterationCadences, schema: 20201231133921 do
+ let(:namespaces) { table(:namespaces) }
+ let(:iterations) { table(:sprints) }
+ let(:iterations_cadences) { table(:iterations_cadences) }
+
+ describe '#perform' do
+ context 'when no iteration cadences exists' do
+ let!(:group_1) { namespaces.create!(name: 'group 1', path: 'group-1') }
+ let!(:group_2) { namespaces.create!(name: 'group 2', path: 'group-2') }
+ let!(:group_3) { namespaces.create!(name: 'group 3', path: 'group-3') }
+
+ let!(:iteration_1) { iterations.create!(group_id: group_1.id, iid: 1, title: 'Iteration 1', start_date: 10.days.ago, due_date: 8.days.ago) }
+ let!(:iteration_2) { iterations.create!(group_id: group_3.id, iid: 1, title: 'Iteration 2', start_date: 10.days.ago, due_date: 8.days.ago) }
+ let!(:iteration_3) { iterations.create!(group_id: group_3.id, iid: 1, title: 'Iteration 3', start_date: 5.days.ago, due_date: 2.days.ago) }
+
+ subject { described_class.new.perform(group_1.id, group_2.id, group_3.id, namespaces.last.id + 1) }
+
+ before do
+ subject
+ end
+
+ it 'creates iterations_cadence records for the requested groups' do
+ expect(iterations_cadences.count).to eq(2)
+ end
+
+ it 'assigns the iteration cadences to the iterations correctly' do
+ iterations_cadence = iterations_cadences.find_by(group_id: group_1.id)
+ iteration_records = iterations.where(iterations_cadence_id: iterations_cadence.id)
+
+ expect(iterations_cadence.start_date).to eq(iteration_1.start_date)
+ expect(iterations_cadence.last_run_date).to eq(iteration_1.start_date)
+ expect(iterations_cadence.title).to eq('group 1 Iterations')
+ expect(iteration_records.size).to eq(1)
+ expect(iteration_records.first.id).to eq(iteration_1.id)
+
+ iterations_cadence = iterations_cadences.find_by(group_id: group_3.id)
+ iteration_records = iterations.where(iterations_cadence_id: iterations_cadence.id)
+
+ expect(iterations_cadence.start_date).to eq(iteration_3.start_date)
+ expect(iterations_cadence.last_run_date).to eq(iteration_3.start_date)
+ expect(iterations_cadence.title).to eq('group 3 Iterations')
+ expect(iteration_records.size).to eq(2)
+ expect(iteration_records.first.id).to eq(iteration_2.id)
+ expect(iteration_records.second.id).to eq(iteration_3.id)
+ end
+
+ it 'does not call Group class' do
+ expect(::Group).not_to receive(:where)
+
+ subject
+ end
+ end
+
+ context 'when an iteration cadence exists for a group' do
+ let!(:group) { namespaces.create!(name: 'group', path: 'group') }
+
+ let!(:iterations_cadence_1) { iterations_cadences.create!(group_id: group.id, start_date: 2.days.ago, title: 'Cadence 1') }
+
+ let!(:iteration_1) { iterations.create!(group_id: group.id, iid: 1, title: 'Iteration 1', start_date: 10.days.ago, due_date: 8.days.ago) }
+ let!(:iteration_2) { iterations.create!(group_id: group.id, iterations_cadence_id: iterations_cadence_1.id, iid: 2, title: 'Iteration 2', start_date: 5.days.ago, due_date: 3.days.ago) }
+
+ subject { described_class.new.perform(group.id) }
+
+ it 'does not create a new iterations_cadence' do
+ expect { subject }.not_to change { iterations_cadences.count }
+ end
+
+ it 'assigns iteration cadences to iterations if needed' do
+ subject
+
+ expect(iteration_1.reload.iterations_cadence_id).to eq(iterations_cadence_1.id)
+ expect(iteration_2.reload.iterations_cadence_id).to eq(iterations_cadence_1.id)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/checks/branch_check_spec.rb b/spec/lib/gitlab/checks/branch_check_spec.rb
index 822bdc8389d..3086cb1bd33 100644
--- a/spec/lib/gitlab/checks/branch_check_spec.rb
+++ b/spec/lib/gitlab/checks/branch_check_spec.rb
@@ -70,6 +70,82 @@ RSpec.describe Gitlab::Checks::BranchCheck do
expect { subject.validate! }.to raise_error(Gitlab::GitAccess::ForbiddenError, 'You are not allowed to push code to protected branches on this project.')
end
+ context 'when user has push access' do
+ before do
+ allow(user_access)
+ .to receive(:can_push_to_branch?)
+ .and_return(true)
+ end
+
+ context 'if protected branches is allowed to force push' do
+ before do
+ allow(ProtectedBranch)
+ .to receive(:allow_force_push?)
+ .with(project, 'master')
+ .and_return(true)
+ end
+
+ it 'allows force push' do
+ expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true)
+
+ expect { subject.validate! }.not_to raise_error
+ end
+ end
+
+ context 'if protected branches is not allowed to force push' do
+ before do
+ allow(ProtectedBranch)
+ .to receive(:allow_force_push?)
+ .with(project, 'master')
+ .and_return(false)
+ end
+
+ it 'prevents force push' do
+ expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true)
+
+ expect { subject.validate! }.to raise_error
+ end
+ end
+ end
+
+ context 'when user does not have push access' do
+ before do
+ allow(user_access)
+ .to receive(:can_push_to_branch?)
+ .and_return(false)
+ end
+
+ context 'if protected branches is allowed to force push' do
+ before do
+ allow(ProtectedBranch)
+ .to receive(:allow_force_push?)
+ .with(project, 'master')
+ .and_return(true)
+ end
+
+ it 'prevents force push' do
+ expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true)
+
+ expect { subject.validate! }.to raise_error
+ end
+ end
+
+ context 'if protected branches is not allowed to force push' do
+ before do
+ allow(ProtectedBranch)
+ .to receive(:allow_force_push?)
+ .with(project, 'master')
+ .and_return(false)
+ end
+
+ it 'prevents force push' do
+ expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true)
+
+ expect { subject.validate! }.to raise_error
+ end
+ end
+ end
+
context 'when project repository is empty' do
let(:project) { create(:project) }
diff --git a/spec/lib/gitlab/checks/lfs_check_spec.rb b/spec/lib/gitlab/checks/lfs_check_spec.rb
index 713858e0e35..19c1d820dff 100644
--- a/spec/lib/gitlab/checks/lfs_check_spec.rb
+++ b/spec/lib/gitlab/checks/lfs_check_spec.rb
@@ -39,13 +39,26 @@ RSpec.describe Gitlab::Checks::LfsCheck do
end
end
- context 'deletion' do
- let(:changes) { { oldrev: oldrev, ref: ref } }
+ context 'with deletion' do
+ shared_examples 'a skipped integrity check' do
+ it 'skips integrity check' do
+ expect(project.repository).not_to receive(:new_objects)
+ expect_any_instance_of(Gitlab::Git::LfsChanges).not_to receive(:new_pointers)
+
+ subject.validate!
+ end
+ end
- it 'skips integrity check' do
- expect(project.repository).not_to receive(:new_objects)
+ context 'with missing newrev' do
+ it_behaves_like 'a skipped integrity check' do
+ let(:changes) { { oldrev: oldrev, ref: ref } }
+ end
+ end
- subject.validate!
+ context 'with blank newrev' do
+ it_behaves_like 'a skipped integrity check' do
+ let(:changes) { { oldrev: oldrev, newrev: Gitlab::Git::BLANK_SHA, ref: ref } }
+ end
end
end
diff --git a/spec/lib/gitlab/ci/artifacts/metrics_spec.rb b/spec/lib/gitlab/ci/artifacts/metrics_spec.rb
new file mode 100644
index 00000000000..3a2095498ec
--- /dev/null
+++ b/spec/lib/gitlab/ci/artifacts/metrics_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Artifacts::Metrics, :prometheus do
+ let(:metrics) { described_class.new }
+
+ describe '#increment_destroyed_artifacts' do
+ context 'when incrementing by more than one' do
+ let(:counter) { metrics.send(:destroyed_artifacts_counter) }
+
+ it 'increments a single counter' do
+ subject.increment_destroyed_artifacts(10)
+ subject.increment_destroyed_artifacts(20)
+ subject.increment_destroyed_artifacts(30)
+
+ expect(counter.get).to eq 60
+ expect(counter.values.count).to eq 1
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/build/cache_spec.rb b/spec/lib/gitlab/ci/build/cache_spec.rb
new file mode 100644
index 00000000000..9188045988b
--- /dev/null
+++ b/spec/lib/gitlab/ci/build/cache_spec.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Build::Cache do
+ describe '.initialize' do
+ context 'when the multiple cache feature flag is disabled' do
+ before do
+ stub_feature_flags(multiple_cache_per_job: false)
+ end
+
+ it 'instantiates a cache seed' do
+ cache_config = { key: 'key-a' }
+ pipeline = double(::Ci::Pipeline)
+ cache_seed = double(Gitlab::Ci::Pipeline::Seed::Build::Cache)
+ allow(Gitlab::Ci::Pipeline::Seed::Build::Cache).to receive(:new).and_return(cache_seed)
+
+ cache = described_class.new(cache_config, pipeline)
+
+ expect(Gitlab::Ci::Pipeline::Seed::Build::Cache).to have_received(:new).with(pipeline, cache_config)
+ expect(cache.instance_variable_get(:@cache)).to eq(cache_seed)
+ end
+ end
+
+ context 'when the multiple cache feature flag is enabled' do
+ context 'when the cache is an array' do
+ it 'instantiates an array of cache seeds' do
+ cache_config = [{ key: 'key-a' }, { key: 'key-b' }]
+ pipeline = double(::Ci::Pipeline)
+ cache_seed_a = double(Gitlab::Ci::Pipeline::Seed::Build::Cache)
+ cache_seed_b = double(Gitlab::Ci::Pipeline::Seed::Build::Cache)
+ allow(Gitlab::Ci::Pipeline::Seed::Build::Cache).to receive(:new).and_return(cache_seed_a, cache_seed_b)
+
+ cache = described_class.new(cache_config, pipeline)
+
+ expect(Gitlab::Ci::Pipeline::Seed::Build::Cache).to have_received(:new).with(pipeline, { key: 'key-a' })
+ expect(Gitlab::Ci::Pipeline::Seed::Build::Cache).to have_received(:new).with(pipeline, { key: 'key-b' })
+ expect(cache.instance_variable_get(:@cache)).to eq([cache_seed_a, cache_seed_b])
+ end
+ end
+
+ context 'when the cache is a hash' do
+ it 'instantiates a cache seed' do
+ cache_config = { key: 'key-a' }
+ pipeline = double(::Ci::Pipeline)
+ cache_seed = double(Gitlab::Ci::Pipeline::Seed::Build::Cache)
+ allow(Gitlab::Ci::Pipeline::Seed::Build::Cache).to receive(:new).and_return(cache_seed)
+
+ cache = described_class.new(cache_config, pipeline)
+
+ expect(Gitlab::Ci::Pipeline::Seed::Build::Cache).to have_received(:new).with(pipeline, cache_config)
+ expect(cache.instance_variable_get(:@cache)).to eq([cache_seed])
+ end
+ end
+ end
+ end
+
+ describe '#cache_attributes' do
+ context 'when the multiple cache feature flag is disabled' do
+ before do
+ stub_feature_flags(multiple_cache_per_job: false)
+ end
+
+ it "returns the cache seed's build attributes" do
+ cache_config = { key: 'key-a' }
+ pipeline = double(::Ci::Pipeline)
+ cache = described_class.new(cache_config, pipeline)
+
+ attributes = cache.cache_attributes
+
+ expect(attributes).to eq({
+ options: { cache: { key: 'key-a' } }
+ })
+ end
+ end
+
+ context 'when the multiple cache feature flag is enabled' do
+ context 'when there are no caches' do
+ it 'returns an empty hash' do
+ cache_config = []
+ pipeline = double(::Ci::Pipeline)
+ cache = described_class.new(cache_config, pipeline)
+
+ attributes = cache.cache_attributes
+
+ expect(attributes).to eq({})
+ end
+ end
+
+ context 'when there are caches' do
+ it 'returns the structured attributes for the caches' do
+ cache_config = [{ key: 'key-a' }, { key: 'key-b' }]
+ pipeline = double(::Ci::Pipeline)
+ cache = described_class.new(cache_config, pipeline)
+
+ attributes = cache.cache_attributes
+
+ expect(attributes).to eq({
+ options: { cache: cache_config }
+ })
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/build/context/build_spec.rb b/spec/lib/gitlab/ci/build/context/build_spec.rb
index 61ca8e759b5..46447231424 100644
--- a/spec/lib/gitlab/ci/build/context/build_spec.rb
+++ b/spec/lib/gitlab/ci/build/context/build_spec.rb
@@ -9,7 +9,9 @@ RSpec.describe Gitlab::Ci::Build::Context::Build do
let(:context) { described_class.new(pipeline, seed_attributes) }
describe '#variables' do
- subject { context.variables }
+ subject { context.variables.to_hash }
+
+ it { expect(context.variables).to be_instance_of(Gitlab::Ci::Variables::Collection) }
it { is_expected.to include('CI_COMMIT_REF_NAME' => 'master') }
it { is_expected.to include('CI_PIPELINE_IID' => pipeline.iid.to_s) }
diff --git a/spec/lib/gitlab/ci/build/context/global_spec.rb b/spec/lib/gitlab/ci/build/context/global_spec.rb
index 7394708f9b6..61f2b90426d 100644
--- a/spec/lib/gitlab/ci/build/context/global_spec.rb
+++ b/spec/lib/gitlab/ci/build/context/global_spec.rb
@@ -9,7 +9,9 @@ RSpec.describe Gitlab::Ci::Build::Context::Global do
let(:context) { described_class.new(pipeline, yaml_variables: yaml_variables) }
describe '#variables' do
- subject { context.variables }
+ subject { context.variables.to_hash }
+
+ it { expect(context.variables).to be_instance_of(Gitlab::Ci::Variables::Collection) }
it { is_expected.to include('CI_COMMIT_REF_NAME' => 'master') }
it { is_expected.to include('CI_PIPELINE_IID' => pipeline.iid.to_s) }
diff --git a/spec/lib/gitlab/ci/build/policy/variables_spec.rb b/spec/lib/gitlab/ci/build/policy/variables_spec.rb
index f692aa6146e..6c8c968dc0c 100644
--- a/spec/lib/gitlab/ci/build/policy/variables_spec.rb
+++ b/spec/lib/gitlab/ci/build/policy/variables_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe Gitlab::Ci::Build::Policy::Variables do
let(:seed) do
double('build seed',
to_resource: ci_build,
- variables: ci_build.scoped_variables_hash
+ variables: ci_build.scoped_variables
)
end
@@ -91,7 +91,7 @@ RSpec.describe Gitlab::Ci::Build::Policy::Variables do
let(:seed) do
double('bridge seed',
to_resource: bridge,
- variables: ci_build.scoped_variables_hash
+ variables: ci_build.scoped_variables
)
end
diff --git a/spec/lib/gitlab/ci/build/rules/rule_spec.rb b/spec/lib/gitlab/ci/build/rules/rule_spec.rb
index 5694cd5d0a0..6f3c9278677 100644
--- a/spec/lib/gitlab/ci/build/rules/rule_spec.rb
+++ b/spec/lib/gitlab/ci/build/rules/rule_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Gitlab::Ci::Build::Rules::Rule do
let(:seed) do
double('build seed',
to_resource: ci_build,
- variables: ci_build.scoped_variables_hash
+ variables: ci_build.scoped_variables
)
end
diff --git a/spec/lib/gitlab/ci/build/rules_spec.rb b/spec/lib/gitlab/ci/build/rules_spec.rb
index 0b50def05d4..1d5bdf30278 100644
--- a/spec/lib/gitlab/ci/build/rules_spec.rb
+++ b/spec/lib/gitlab/ci/build/rules_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Gitlab::Ci::Build::Rules do
let(:seed) do
double('build seed',
to_resource: ci_build,
- variables: ci_build.scoped_variables_hash
+ variables: ci_build.scoped_variables
)
end
diff --git a/spec/lib/gitlab/ci/charts_spec.rb b/spec/lib/gitlab/ci/charts_spec.rb
index 46d7d4a58f0..3a82d058819 100644
--- a/spec/lib/gitlab/ci/charts_spec.rb
+++ b/spec/lib/gitlab/ci/charts_spec.rb
@@ -98,7 +98,12 @@ RSpec.describe Gitlab::Ci::Charts do
subject { chart.total }
before do
- create(:ci_empty_pipeline, project: project, duration: 120)
+ # The created_at time used by the following execution
+ # can end up being after the creation of the 'today' time
+ # objects created above, and cause the queried counts to
+ # go to zero when the test executes close to midnight on the
+ # CI system, so we explicitly set it to a day earlier
+ create(:ci_empty_pipeline, project: project, duration: 120, created_at: today - 1.day)
end
it 'uses a utc time zone for range times' do
diff --git a/spec/lib/gitlab/ci/config/entry/bridge_spec.rb b/spec/lib/gitlab/ci/config/entry/bridge_spec.rb
index b3b7901074a..179578fe0a8 100644
--- a/spec/lib/gitlab/ci/config/entry/bridge_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/bridge_spec.rb
@@ -244,6 +244,52 @@ RSpec.describe Gitlab::Ci::Config::Entry::Bridge do
end
end
end
+
+ context 'when bridge config contains parallel' do
+ let(:config) { { trigger: 'some/project', parallel: parallel_config } }
+
+ context 'when parallel config is a number' do
+ let(:parallel_config) { 2 }
+
+ describe '#valid?' do
+ it { is_expected.not_to be_valid }
+ end
+
+ describe '#errors' do
+ it 'returns an error message' do
+ expect(subject.errors)
+ .to include(/cannot use "parallel: <number>"/)
+ end
+ end
+ end
+
+ context 'when parallel config is a matrix' do
+ let(:parallel_config) do
+ { matrix: [{ PROVIDER: 'aws', STACK: %w[monitoring app1] },
+ { PROVIDER: 'gcp', STACK: %w[data] }] }
+ end
+
+ describe '#valid?' do
+ it { is_expected.to be_valid }
+ end
+
+ describe '#value' do
+ it 'is returns a bridge job configuration' do
+ expect(subject.value).to eq(
+ name: :my_bridge,
+ trigger: { project: 'some/project' },
+ ignore: false,
+ stage: 'test',
+ only: { refs: %w[branches tags] },
+ parallel: { matrix: [{ 'PROVIDER' => ['aws'], 'STACK' => %w(monitoring app1) },
+ { 'PROVIDER' => ['gcp'], 'STACK' => %w(data) }] },
+ variables: {},
+ scheduling_type: :stage
+ )
+ end
+ end
+ end
+ end
end
describe '#manual_action?' do
diff --git a/spec/lib/gitlab/ci/config/entry/cache_spec.rb b/spec/lib/gitlab/ci/config/entry/cache_spec.rb
index 247f4b63910..064990667d5 100644
--- a/spec/lib/gitlab/ci/config/entry/cache_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/cache_spec.rb
@@ -7,225 +7,285 @@ RSpec.describe Gitlab::Ci::Config::Entry::Cache do
subject(:entry) { described_class.new(config) }
- describe 'validations' do
+ context 'with multiple caches' do
before do
entry.compose!
end
- context 'when entry config value is correct' do
- let(:policy) { nil }
- let(:key) { 'some key' }
- let(:when_config) { nil }
-
- let(:config) do
- {
- key: key,
- untracked: true,
- paths: ['some/path/']
- }.tap do |config|
- config[:policy] = policy if policy
- config[:when] = when_config if when_config
+ describe '#valid?' do
+ context 'when configuration is valid with a single cache' do
+ let(:config) { { key: 'key', paths: ["logs/"], untracked: true } }
+
+ it 'is valid' do
+ expect(entry).to be_valid
end
end
- describe '#value' do
- shared_examples 'hash key value' do
- it 'returns hash value' do
- expect(entry.value).to eq(key: key, untracked: true, paths: ['some/path/'], policy: 'pull-push', when: 'on_success')
- end
+ context 'when configuration is valid with multiple caches' do
+ let(:config) do
+ [
+ { key: 'key', paths: ["logs/"], untracked: true },
+ { key: 'key2', paths: ["logs/"], untracked: true },
+ { key: 'key3', paths: ["logs/"], untracked: true }
+ ]
end
- it_behaves_like 'hash key value'
+ it 'is valid' do
+ expect(entry).to be_valid
+ end
+ end
- context 'with files' do
- let(:key) { { files: %w[a-file other-file] } }
+ context 'when configuration is not a Hash or Array' do
+ let(:config) { 'invalid' }
- it_behaves_like 'hash key value'
+ it 'is invalid' do
+ expect(entry).not_to be_valid
end
+ end
- context 'with files and prefix' do
- let(:key) { { files: %w[a-file other-file], prefix: 'prefix-value' } }
+ context 'when entry values contain more than four caches' do
+ let(:config) do
+ [
+ { key: 'key', paths: ["logs/"], untracked: true },
+ { key: 'key2', paths: ["logs/"], untracked: true },
+ { key: 'key3', paths: ["logs/"], untracked: true },
+ { key: 'key4', paths: ["logs/"], untracked: true },
+ { key: 'key5', paths: ["logs/"], untracked: true }
+ ]
+ end
- it_behaves_like 'hash key value'
+ it 'is invalid' do
+ expect(entry.errors).to eq(["caches config no more than 4 caches can be created"])
+ expect(entry).not_to be_valid
end
+ end
+ end
+ end
+
+ context 'with a single cache' do
+ before do
+ stub_feature_flags(multiple_cache_per_job: false)
+ end
+ describe 'validations' do
+ before do
+ entry.compose!
+ end
- context 'with prefix' do
- let(:key) { { prefix: 'prefix-value' } }
+ context 'when entry config value is correct' do
+ let(:policy) { nil }
+ let(:key) { 'some key' }
+ let(:when_config) { nil }
- it 'key is nil' do
- expect(entry.value).to match(a_hash_including(key: nil))
+ let(:config) do
+ {
+ key: key,
+ untracked: true,
+ paths: ['some/path/']
+ }.tap do |config|
+ config[:policy] = policy if policy
+ config[:when] = when_config if when_config
end
end
- context 'with `policy`' do
- where(:policy, :result) do
- 'pull-push' | 'pull-push'
- 'push' | 'push'
- 'pull' | 'pull'
- 'unknown' | 'unknown' # invalid
+ describe '#value' do
+ shared_examples 'hash key value' do
+ it 'returns hash value' do
+ expect(entry.value).to eq(key: key, untracked: true, paths: ['some/path/'], policy: 'pull-push', when: 'on_success')
+ end
end
- with_them do
- it { expect(entry.value).to include(policy: result) }
+ it_behaves_like 'hash key value'
+
+ context 'with files' do
+ let(:key) { { files: %w[a-file other-file] } }
+
+ it_behaves_like 'hash key value'
end
- end
- context 'without `policy`' do
- it 'assigns policy to default' do
- expect(entry.value).to include(policy: 'pull-push')
+ context 'with files and prefix' do
+ let(:key) { { files: %w[a-file other-file], prefix: 'prefix-value' } }
+
+ it_behaves_like 'hash key value'
end
- end
- context 'with `when`' do
- where(:when_config, :result) do
- 'on_success' | 'on_success'
- 'on_failure' | 'on_failure'
- 'always' | 'always'
- 'unknown' | 'unknown' # invalid
+ context 'with prefix' do
+ let(:key) { { prefix: 'prefix-value' } }
+
+ it 'key is nil' do
+ expect(entry.value).to match(a_hash_including(key: nil))
+ end
end
- with_them do
- it { expect(entry.value).to include(when: result) }
+ context 'with `policy`' do
+ where(:policy, :result) do
+ 'pull-push' | 'pull-push'
+ 'push' | 'push'
+ 'pull' | 'pull'
+ 'unknown' | 'unknown' # invalid
+ end
+
+ with_them do
+ it { expect(entry.value).to include(policy: result) }
+ end
end
- end
- context 'without `when`' do
- it 'assigns when to default' do
- expect(entry.value).to include(when: 'on_success')
+ context 'without `policy`' do
+ it 'assigns policy to default' do
+ expect(entry.value).to include(policy: 'pull-push')
+ end
end
- end
- end
- describe '#valid?' do
- it { is_expected.to be_valid }
+ context 'with `when`' do
+ where(:when_config, :result) do
+ 'on_success' | 'on_success'
+ 'on_failure' | 'on_failure'
+ 'always' | 'always'
+ 'unknown' | 'unknown' # invalid
+ end
- context 'with files' do
- let(:key) { { files: %w[a-file other-file] } }
+ with_them do
+ it { expect(entry.value).to include(when: result) }
+ end
+ end
- it { is_expected.to be_valid }
+ context 'without `when`' do
+ it 'assigns when to default' do
+ expect(entry.value).to include(when: 'on_success')
+ end
+ end
end
- end
- context 'with `policy`' do
- where(:policy, :valid) do
- 'pull-push' | true
- 'push' | true
- 'pull' | true
- 'unknown' | false
- end
+ describe '#valid?' do
+ it { is_expected.to be_valid }
+
+ context 'with files' do
+ let(:key) { { files: %w[a-file other-file] } }
- with_them do
- it 'returns expected validity' do
- expect(entry.valid?).to eq(valid)
+ it { is_expected.to be_valid }
end
end
- end
- context 'with `when`' do
- where(:when_config, :valid) do
- 'on_success' | true
- 'on_failure' | true
- 'always' | true
- 'unknown' | false
- end
+ context 'with `policy`' do
+ where(:policy, :valid) do
+ 'pull-push' | true
+ 'push' | true
+ 'pull' | true
+ 'unknown' | false
+ end
- with_them do
- it 'returns expected validity' do
- expect(entry.valid?).to eq(valid)
+ with_them do
+ it 'returns expected validity' do
+ expect(entry.valid?).to eq(valid)
+ end
end
end
- end
- context 'with key missing' do
- let(:config) do
- { untracked: true,
- paths: ['some/path/'] }
+ context 'with `when`' do
+ where(:when_config, :valid) do
+ 'on_success' | true
+ 'on_failure' | true
+ 'always' | true
+ 'unknown' | false
+ end
+
+ with_them do
+ it 'returns expected validity' do
+ expect(entry.valid?).to eq(valid)
+ end
+ end
end
- describe '#value' do
- it 'sets key with the default' do
- expect(entry.value[:key])
- .to eq(Gitlab::Ci::Config::Entry::Key.default)
+ context 'with key missing' do
+ let(:config) do
+ { untracked: true,
+ paths: ['some/path/'] }
+ end
+
+ describe '#value' do
+ it 'sets key with the default' do
+ expect(entry.value[:key])
+ .to eq(Gitlab::Ci::Config::Entry::Key.default)
+ end
end
end
end
- end
- context 'when entry value is not correct' do
- describe '#errors' do
- subject { entry.errors }
+ context 'when entry value is not correct' do
+ describe '#errors' do
+ subject { entry.errors }
- context 'when is not a hash' do
- let(:config) { 'ls' }
+ context 'when is not a hash' do
+ let(:config) { 'ls' }
- it 'reports errors with config value' do
- is_expected.to include 'cache config should be a hash'
+ it 'reports errors with config value' do
+ is_expected.to include 'cache config should be a hash'
+ end
end
- end
- context 'when policy is unknown' do
- let(:config) { { policy: 'unknown' } }
+ context 'when policy is unknown' do
+ let(:config) { { policy: 'unknown' } }
- it 'reports error' do
- is_expected.to include('cache policy should be pull-push, push, or pull')
+ it 'reports error' do
+ is_expected.to include('cache policy should be pull-push, push, or pull')
+ end
end
- end
- context 'when `when` is unknown' do
- let(:config) { { when: 'unknown' } }
+ context 'when `when` is unknown' do
+ let(:config) { { when: 'unknown' } }
- it 'reports error' do
- is_expected.to include('cache when should be on_success, on_failure or always')
+ it 'reports error' do
+ is_expected.to include('cache when should be on_success, on_failure or always')
+ end
end
- end
- context 'when descendants are invalid' do
- context 'with invalid keys' do
- let(:config) { { key: 1 } }
+ context 'when descendants are invalid' do
+ context 'with invalid keys' do
+ let(:config) { { key: 1 } }
- it 'reports error with descendants' do
- is_expected.to include 'key should be a hash, a string or a symbol'
+ it 'reports error with descendants' do
+ is_expected.to include 'key should be a hash, a string or a symbol'
+ end
end
- end
- context 'with empty key' do
- let(:config) { { key: {} } }
+ context 'with empty key' do
+ let(:config) { { key: {} } }
- it 'reports error with descendants' do
- is_expected.to include 'key config missing required keys: files'
+ it 'reports error with descendants' do
+ is_expected.to include 'key config missing required keys: files'
+ end
end
- end
- context 'with invalid files' do
- let(:config) { { key: { files: 'a-file' } } }
+ context 'with invalid files' do
+ let(:config) { { key: { files: 'a-file' } } }
- it 'reports error with descendants' do
- is_expected.to include 'key:files config should be an array of strings'
+ it 'reports error with descendants' do
+ is_expected.to include 'key:files config should be an array of strings'
+ end
end
- end
- context 'with prefix without files' do
- let(:config) { { key: { prefix: 'a-prefix' } } }
+ context 'with prefix without files' do
+ let(:config) { { key: { prefix: 'a-prefix' } } }
- it 'reports error with descendants' do
- is_expected.to include 'key config missing required keys: files'
+ it 'reports error with descendants' do
+ is_expected.to include 'key config missing required keys: files'
+ end
end
- end
- context 'when there is an unknown key present' do
- let(:config) { { key: { unknown: 'a-file' } } }
+ context 'when there is an unknown key present' do
+ let(:config) { { key: { unknown: 'a-file' } } }
- it 'reports error with descendants' do
- is_expected.to include 'key config contains unknown keys: unknown'
+ it 'reports error with descendants' do
+ is_expected.to include 'key config contains unknown keys: unknown'
+ end
end
end
- end
- context 'when there is an unknown key present' do
- let(:config) { { invalid: true } }
+ context 'when there is an unknown key present' do
+ let(:config) { { invalid: true } }
- it 'reports error with descendants' do
- is_expected.to include 'cache config contains unknown keys: invalid'
+ it 'reports error with descendants' do
+ is_expected.to include 'cache config contains unknown keys: invalid'
+ end
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/environment_spec.rb b/spec/lib/gitlab/ci/config/entry/environment_spec.rb
index 0c18a7fb71e..dd8a79f0d84 100644
--- a/spec/lib/gitlab/ci/config/entry/environment_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/environment_spec.rb
@@ -305,4 +305,37 @@ RSpec.describe Gitlab::Ci::Config::Entry::Environment do
it { expect(entry).to be_valid }
end
end
+
+ describe 'deployment_tier' do
+ let(:config) do
+ { name: 'customer-portal', deployment_tier: deployment_tier }
+ end
+
+ context 'is a string' do
+ let(:deployment_tier) { 'production' }
+
+ it { expect(entry).to be_valid }
+ end
+
+ context 'is a hash' do
+ let(:deployment_tier) { Hash(tier: 'production') }
+
+ it { expect(entry).not_to be_valid }
+ end
+
+ context 'is nil' do
+ let(:deployment_tier) { nil }
+
+ it { expect(entry).to be_valid }
+ end
+
+ context 'is unknown value' do
+ let(:deployment_tier) { 'unknown' }
+
+ it 'is invalid and adds an error' do
+ expect(entry).not_to be_valid
+ expect(entry.errors).to include("environment deployment tier must be one of #{::Environment.tiers.keys.join(', ')}")
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb
index a3b5f32b9f9..a4167003987 100644
--- a/spec/lib/gitlab/ci/config/entry/job_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb
@@ -537,7 +537,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do
it 'overrides default config' do
expect(entry[:image].value).to eq(name: 'some_image')
- expect(entry[:cache].value).to eq(key: 'test', policy: 'pull-push', when: 'on_success')
+ expect(entry[:cache].value).to eq([key: 'test', policy: 'pull-push', when: 'on_success'])
end
end
@@ -552,7 +552,43 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do
it 'uses config from default entry' do
expect(entry[:image].value).to eq 'specified'
- expect(entry[:cache].value).to eq(key: 'test', policy: 'pull-push', when: 'on_success')
+ expect(entry[:cache].value).to eq([key: 'test', policy: 'pull-push', when: 'on_success'])
+ end
+ end
+
+ context 'with multiple_cache_per_job FF disabled' do
+ before do
+ stub_feature_flags(multiple_cache_per_job: false)
+ end
+
+ context 'when job config overrides default config' do
+ before do
+ entry.compose!(deps)
+ end
+
+ let(:config) do
+ { script: 'rspec', image: 'some_image', cache: { key: 'test' } }
+ end
+
+ it 'overrides default config' do
+ expect(entry[:image].value).to eq(name: 'some_image')
+ expect(entry[:cache].value).to eq(key: 'test', policy: 'pull-push', when: 'on_success')
+ end
+ end
+
+ context 'when job config does not override default config' do
+ before do
+ allow(default).to receive('[]').with(:image).and_return(specified)
+
+ entry.compose!(deps)
+ end
+
+ let(:config) { { script: 'ls', cache: { key: 'test' } } }
+
+ it 'uses config from default entry' do
+ expect(entry[:image].value).to eq 'specified'
+ expect(entry[:cache].value).to eq(key: 'test', policy: 'pull-push', when: 'on_success')
+ end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/need_spec.rb b/spec/lib/gitlab/ci/config/entry/need_spec.rb
index 983e95fae42..a0a5dd52ad4 100644
--- a/spec/lib/gitlab/ci/config/entry/need_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/need_spec.rb
@@ -23,7 +23,17 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Need do
describe '#value' do
it 'returns job needs configuration' do
- expect(need.value).to eq(name: 'job_name', artifacts: true)
+ expect(need.value).to eq(name: 'job_name', artifacts: true, optional: false)
+ end
+
+ context 'when the FF ci_needs_optional is disabled' do
+ before do
+ stub_feature_flags(ci_needs_optional: false)
+ end
+
+ it 'returns job needs configuration without `optional`' do
+ expect(need.value).to eq(name: 'job_name', artifacts: true)
+ end
end
end
@@ -58,7 +68,7 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Need do
describe '#value' do
it 'returns job needs configuration' do
- expect(need.value).to eq(name: 'job_name', artifacts: true)
+ expect(need.value).to eq(name: 'job_name', artifacts: true, optional: false)
end
end
@@ -74,7 +84,7 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Need do
describe '#value' do
it 'returns job needs configuration' do
- expect(need.value).to eq(name: 'job_name', artifacts: false)
+ expect(need.value).to eq(name: 'job_name', artifacts: false, optional: false)
end
end
@@ -90,7 +100,7 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Need do
describe '#value' do
it 'returns job needs configuration' do
- expect(need.value).to eq(name: 'job_name', artifacts: true)
+ expect(need.value).to eq(name: 'job_name', artifacts: true, optional: false)
end
end
@@ -106,11 +116,77 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Need do
describe '#value' do
it 'returns job needs configuration' do
- expect(need.value).to eq(name: 'job_name', artifacts: true)
+ expect(need.value).to eq(name: 'job_name', artifacts: true, optional: false)
+ end
+ end
+
+ it_behaves_like 'job type'
+ end
+
+ context 'with job name and optional true' do
+ let(:config) { { job: 'job_name', optional: true } }
+
+ it { is_expected.to be_valid }
+
+ it_behaves_like 'job type'
+
+ describe '#value' do
+ it 'returns job needs configuration' do
+ expect(need.value).to eq(name: 'job_name', artifacts: true, optional: true)
+ end
+
+ context 'when the FF ci_needs_optional is disabled' do
+ before do
+ stub_feature_flags(ci_needs_optional: false)
+ end
+
+ it 'returns job needs configuration without `optional`' do
+ expect(need.value).to eq(name: 'job_name', artifacts: true)
+ end
end
end
+ end
+
+ context 'with job name and optional false' do
+ let(:config) { { job: 'job_name', optional: false } }
+
+ it { is_expected.to be_valid }
it_behaves_like 'job type'
+
+ describe '#value' do
+ it 'returns job needs configuration' do
+ expect(need.value).to eq(name: 'job_name', artifacts: true, optional: false)
+ end
+ end
+ end
+
+ context 'with job name and optional nil' do
+ let(:config) { { job: 'job_name', optional: nil } }
+
+ it { is_expected.to be_valid }
+
+ it_behaves_like 'job type'
+
+ describe '#value' do
+ it 'returns job needs configuration' do
+ expect(need.value).to eq(name: 'job_name', artifacts: true, optional: false)
+ end
+ end
+ end
+
+ context 'without optional key' do
+ let(:config) { { job: 'job_name' } }
+
+ it { is_expected.to be_valid }
+
+ it_behaves_like 'job type'
+
+ describe '#value' do
+ it 'returns job needs configuration' do
+ expect(need.value).to eq(name: 'job_name', artifacts: true, optional: false)
+ end
+ end
end
context 'when job name is empty' do
diff --git a/spec/lib/gitlab/ci/config/entry/needs_spec.rb b/spec/lib/gitlab/ci/config/entry/needs_spec.rb
index f11f2a56f5f..489fbac68b2 100644
--- a/spec/lib/gitlab/ci/config/entry/needs_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/needs_spec.rb
@@ -111,8 +111,8 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Needs do
it 'returns key value' do
expect(needs.value).to eq(
job: [
- { name: 'first_job_name', artifacts: true },
- { name: 'second_job_name', artifacts: true }
+ { name: 'first_job_name', artifacts: true, optional: false },
+ { name: 'second_job_name', artifacts: true, optional: false }
]
)
end
@@ -124,8 +124,8 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Needs do
context 'with complex job entries composed' do
let(:config) do
[
- { job: 'first_job_name', artifacts: true },
- { job: 'second_job_name', artifacts: false }
+ { job: 'first_job_name', artifacts: true, optional: false },
+ { job: 'second_job_name', artifacts: false, optional: false }
]
end
@@ -137,8 +137,8 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Needs do
it 'returns key value' do
expect(needs.value).to eq(
job: [
- { name: 'first_job_name', artifacts: true },
- { name: 'second_job_name', artifacts: false }
+ { name: 'first_job_name', artifacts: true, optional: false },
+ { name: 'second_job_name', artifacts: false, optional: false }
]
)
end
@@ -163,8 +163,8 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Needs do
it 'returns key value' do
expect(needs.value).to eq(
job: [
- { name: 'first_job_name', artifacts: true },
- { name: 'second_job_name', artifacts: false }
+ { name: 'first_job_name', artifacts: true, optional: false },
+ { name: 'second_job_name', artifacts: false, optional: false }
]
)
end
diff --git a/spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb b/spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb
index bc09e20d748..937642f07e7 100644
--- a/spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb
@@ -4,21 +4,23 @@ require 'fast_spec_helper'
require_dependency 'active_model'
RSpec.describe ::Gitlab::Ci::Config::Entry::Product::Parallel do
- subject(:parallel) { described_class.new(config) }
+ let(:metadata) { {} }
- context 'with invalid config' do
- shared_examples 'invalid config' do |error_message|
- describe '#valid?' do
- it { is_expected.not_to be_valid }
- end
+ subject(:parallel) { described_class.new(config, **metadata) }
- describe '#errors' do
- it 'returns error about invalid type' do
- expect(parallel.errors).to match(a_collection_including(error_message))
- end
+ shared_examples 'invalid config' do |error_message|
+ describe '#valid?' do
+ it { is_expected.not_to be_valid }
+ end
+
+ describe '#errors' do
+ it 'returns error about invalid type' do
+ expect(parallel.errors).to match(a_collection_including(error_message))
end
end
+ end
+ context 'with invalid config' do
context 'when it is not a numeric value' do
let(:config) { true }
@@ -63,6 +65,12 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Product::Parallel do
expect(parallel.value).to match(number: config)
end
end
+
+ context 'when :numeric is not allowed' do
+ let(:metadata) { { allowed_strategies: [:matrix] } }
+
+ it_behaves_like 'invalid config', /cannot use "parallel: <number>"/
+ end
end
end
@@ -89,6 +97,12 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Product::Parallel do
])
end
end
+
+ context 'when :matrix is not allowed' do
+ let(:metadata) { { allowed_strategies: [:numeric] } }
+
+ it_behaves_like 'invalid config', /cannot use "parallel: matrix"/
+ end
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/root_spec.rb b/spec/lib/gitlab/ci/config/entry/root_spec.rb
index 54c7a5c3602..7b38c21788f 100644
--- a/spec/lib/gitlab/ci/config/entry/root_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/root_spec.rb
@@ -126,49 +126,105 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
expect(root.jobs_value.keys).to eq([:rspec, :spinach, :release])
expect(root.jobs_value[:rspec]).to eq(
{ name: :rspec,
- script: %w[rspec ls],
- before_script: %w(ls pwd),
- image: { name: 'ruby:2.7' },
- services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
- stage: 'test',
- cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' },
- variables: { 'VAR' => 'root', 'VAR2' => 'val 2' },
- ignore: false,
- after_script: ['make clean'],
- only: { refs: %w[branches tags] },
- scheduling_type: :stage }
+ script: %w[rspec ls],
+ before_script: %w(ls pwd),
+ image: { name: 'ruby:2.7' },
+ services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
+ stage: 'test',
+ cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }],
+ variables: { 'VAR' => 'root', 'VAR2' => 'val 2' },
+ ignore: false,
+ after_script: ['make clean'],
+ only: { refs: %w[branches tags] },
+ scheduling_type: :stage }
)
expect(root.jobs_value[:spinach]).to eq(
{ name: :spinach,
- before_script: [],
- script: %w[spinach],
- image: { name: 'ruby:2.7' },
- services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
- stage: 'test',
- cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' },
- variables: { 'VAR' => 'root', 'VAR2' => 'val 2' },
- ignore: false,
- after_script: ['make clean'],
- only: { refs: %w[branches tags] },
- scheduling_type: :stage }
+ before_script: [],
+ script: %w[spinach],
+ image: { name: 'ruby:2.7' },
+ services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
+ stage: 'test',
+ cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }],
+ variables: { 'VAR' => 'root', 'VAR2' => 'val 2' },
+ ignore: false,
+ after_script: ['make clean'],
+ only: { refs: %w[branches tags] },
+ scheduling_type: :stage }
)
expect(root.jobs_value[:release]).to eq(
{ name: :release,
- stage: 'release',
- 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" },
- 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) },
- variables: { 'VAR' => 'job', 'VAR2' => 'val 2' },
- after_script: [],
- ignore: false,
- scheduling_type: :stage }
+ stage: 'release',
+ 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" },
+ 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) },
+ variables: { 'VAR' => 'job', 'VAR2' => 'val 2' },
+ after_script: [],
+ ignore: false,
+ scheduling_type: :stage }
)
end
end
+
+ context 'with multuple_cache_per_job FF disabled' do
+ before do
+ stub_feature_flags(multiple_cache_per_job: false)
+ root.compose!
+ end
+
+ describe '#jobs_value' do
+ it 'returns jobs configuration' do
+ expect(root.jobs_value.keys).to eq([:rspec, :spinach, :release])
+ expect(root.jobs_value[:rspec]).to eq(
+ { name: :rspec,
+ script: %w[rspec ls],
+ before_script: %w(ls pwd),
+ image: { name: 'ruby:2.7' },
+ services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
+ stage: 'test',
+ cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' },
+ variables: { 'VAR' => 'root', 'VAR2' => 'val 2' },
+ ignore: false,
+ after_script: ['make clean'],
+ only: { refs: %w[branches tags] },
+ scheduling_type: :stage }
+ )
+ expect(root.jobs_value[:spinach]).to eq(
+ { name: :spinach,
+ before_script: [],
+ script: %w[spinach],
+ image: { name: 'ruby:2.7' },
+ services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
+ stage: 'test',
+ cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' },
+ variables: { 'VAR' => 'root', 'VAR2' => 'val 2' },
+ ignore: false,
+ after_script: ['make clean'],
+ only: { refs: %w[branches tags] },
+ scheduling_type: :stage }
+ )
+ expect(root.jobs_value[:release]).to eq(
+ { name: :release,
+ stage: 'release',
+ 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" },
+ 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) },
+ variables: { 'VAR' => 'job', 'VAR2' => 'val 2' },
+ after_script: [],
+ ignore: false,
+ scheduling_type: :stage }
+ )
+ end
+ end
+ end
end
end
@@ -187,6 +243,52 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
spinach: { before_script: [], variables: { VAR: 'job' }, script: 'spinach' } }
end
+ context 'with multiple_cache_per_job FF disabled' do
+ context 'when composed' do
+ before do
+ stub_feature_flags(multiple_cache_per_job: false)
+ root.compose!
+ end
+
+ describe '#errors' do
+ it 'has no errors' do
+ expect(root.errors).to be_empty
+ end
+ end
+
+ describe '#jobs_value' do
+ it 'returns jobs configuration' do
+ expect(root.jobs_value).to eq(
+ rspec: { name: :rspec,
+ script: %w[rspec ls],
+ before_script: %w(ls pwd),
+ image: { name: 'ruby:2.7' },
+ services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
+ stage: 'test',
+ cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' },
+ variables: { 'VAR' => 'root' },
+ ignore: false,
+ after_script: ['make clean'],
+ only: { refs: %w[branches tags] },
+ scheduling_type: :stage },
+ spinach: { name: :spinach,
+ before_script: [],
+ script: %w[spinach],
+ image: { name: 'ruby:2.7' },
+ services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
+ stage: 'test',
+ cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' },
+ variables: { 'VAR' => 'job' },
+ ignore: false,
+ after_script: ['make clean'],
+ only: { refs: %w[branches tags] },
+ scheduling_type: :stage }
+ )
+ end
+ end
+ end
+ end
+
context 'when composed' do
before do
root.compose!
@@ -202,29 +304,29 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
it 'returns jobs configuration' do
expect(root.jobs_value).to eq(
rspec: { name: :rspec,
- script: %w[rspec ls],
- before_script: %w(ls pwd),
- image: { name: 'ruby:2.7' },
- services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
- stage: 'test',
- cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' },
- variables: { 'VAR' => 'root' },
- ignore: false,
- after_script: ['make clean'],
- only: { refs: %w[branches tags] },
- scheduling_type: :stage },
+ script: %w[rspec ls],
+ before_script: %w(ls pwd),
+ image: { name: 'ruby:2.7' },
+ services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
+ stage: 'test',
+ cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }],
+ variables: { 'VAR' => 'root' },
+ ignore: false,
+ after_script: ['make clean'],
+ only: { refs: %w[branches tags] },
+ scheduling_type: :stage },
spinach: { name: :spinach,
- before_script: [],
- script: %w[spinach],
- image: { name: 'ruby:2.7' },
- services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
- stage: 'test',
- cache: { key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' },
- variables: { 'VAR' => 'job' },
- ignore: false,
- after_script: ['make clean'],
- only: { refs: %w[branches tags] },
- scheduling_type: :stage }
+ before_script: [],
+ script: %w[spinach],
+ image: { name: 'ruby:2.7' },
+ services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
+ stage: 'test',
+ cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }],
+ variables: { 'VAR' => 'job' },
+ ignore: false,
+ after_script: ['make clean'],
+ only: { refs: %w[branches tags] },
+ scheduling_type: :stage }
)
end
end
@@ -265,7 +367,20 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
describe '#cache_value' do
it 'returns correct cache definition' do
- expect(root.cache_value).to eq(key: 'a', policy: 'pull-push', when: 'on_success')
+ expect(root.cache_value).to eq([key: 'a', policy: 'pull-push', when: 'on_success'])
+ end
+ end
+
+ context 'with multiple_cache_per_job FF disabled' do
+ before do
+ stub_feature_flags(multiple_cache_per_job: false)
+ root.compose!
+ end
+
+ describe '#cache_value' do
+ it 'returns correct cache definition' do
+ expect(root.cache_value).to eq(key: 'a', policy: 'pull-push', when: 'on_success')
+ end
end
end
end
diff --git a/spec/lib/gitlab/ci/jwt_spec.rb b/spec/lib/gitlab/ci/jwt_spec.rb
index 342ca6b8b75..480a4a05379 100644
--- a/spec/lib/gitlab/ci/jwt_spec.rb
+++ b/spec/lib/gitlab/ci/jwt_spec.rb
@@ -114,17 +114,6 @@ RSpec.describe Gitlab::Ci::Jwt do
expect(payload[:environment]).to eq('production')
expect(payload[:environment_protected]).to eq('false')
end
-
- context ':ci_jwt_include_environment feature flag is disabled' do
- before do
- stub_feature_flags(ci_jwt_include_environment: false)
- end
-
- it 'does not include environment attributes' do
- expect(payload).not_to have_key(:environment)
- expect(payload).not_to have_key(:environment_protected)
- end
- end
end
end
diff --git a/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb
index cf3644c9ad5..ec7eebdc056 100644
--- a/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/expression/statement_spec.rb
@@ -3,17 +3,16 @@
require 'spec_helper'
RSpec.describe Gitlab::Ci::Pipeline::Expression::Statement do
- subject do
- described_class.new(text, variables)
+ let(:variables) do
+ Gitlab::Ci::Variables::Collection.new
+ .append(key: 'PRESENT_VARIABLE', value: 'my variable')
+ .append(key: 'PATH_VARIABLE', value: 'a/path/variable/value')
+ .append(key: 'FULL_PATH_VARIABLE', value: '/a/full/path/variable/value')
+ .append(key: 'EMPTY_VARIABLE', value: '')
end
- let(:variables) do
- {
- 'PRESENT_VARIABLE' => 'my variable',
- 'PATH_VARIABLE' => 'a/path/variable/value',
- 'FULL_PATH_VARIABLE' => '/a/full/path/variable/value',
- 'EMPTY_VARIABLE' => ''
- }
+ subject do
+ described_class.new(text, variables)
end
describe '.new' do
diff --git a/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb
index 570706bfaac..773cb61b946 100644
--- a/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/seed/build/cache_spec.rb
@@ -9,8 +9,255 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
let(:processor) { described_class.new(pipeline, config) }
- describe '#build_attributes' do
- subject { processor.build_attributes }
+ context 'with multiple_cache_per_job ff disabled' do
+ before do
+ stub_feature_flags(multiple_cache_per_job: false)
+ end
+
+ describe '#build_attributes' do
+ subject { processor.build_attributes }
+
+ context 'with cache:key' do
+ let(:config) do
+ {
+ key: 'a-key',
+ paths: ['vendor/ruby']
+ }
+ end
+
+ it { is_expected.to include(options: { cache: config }) }
+ end
+
+ context 'with cache:key as a symbol' do
+ let(:config) do
+ {
+ key: :a_key,
+ paths: ['vendor/ruby']
+ }
+ end
+
+ it { is_expected.to include(options: { cache: config.merge(key: "a_key") }) }
+ end
+
+ context 'with cache:key:files' do
+ shared_examples 'default key' do
+ let(:config) do
+ { key: { files: files } }
+ end
+
+ it 'uses default key' do
+ expected = { options: { cache: { key: 'default' } } }
+
+ is_expected.to include(expected)
+ end
+ end
+
+ shared_examples 'version and gemfile files' do
+ let(:config) do
+ {
+ key: {
+ files: files
+ },
+ paths: ['vendor/ruby']
+ }
+ end
+
+ it 'builds a string key' do
+ expected = {
+ options: {
+ cache: {
+ key: '703ecc8fef1635427a1f86a8a1a308831c122392',
+ paths: ['vendor/ruby']
+ }
+ }
+ }
+
+ is_expected.to include(expected)
+ end
+ end
+
+ context 'with existing files' do
+ let(:files) { ['VERSION', 'Gemfile.zip'] }
+
+ it_behaves_like 'version and gemfile files'
+ end
+
+ context 'with files starting with ./' do
+ let(:files) { ['Gemfile.zip', './VERSION'] }
+
+ it_behaves_like 'version and gemfile files'
+ end
+
+ context 'with files ending with /' do
+ let(:files) { ['Gemfile.zip/'] }
+
+ it_behaves_like 'default key'
+ end
+
+ context 'with new line in filenames' do
+ let(:files) { ["Gemfile.zip\nVERSION"] }
+
+ it_behaves_like 'default key'
+ end
+
+ context 'with missing files' do
+ let(:files) { ['project-gemfile.lock', ''] }
+
+ it_behaves_like 'default key'
+ end
+
+ context 'with directories' do
+ shared_examples 'foo/bar directory key' do
+ let(:config) do
+ {
+ key: {
+ files: files
+ }
+ }
+ end
+
+ it 'builds a string key' do
+ expected = {
+ options: {
+ cache: { key: '74bf43fb1090f161bdd4e265802775dbda2f03d1' }
+ }
+ }
+
+ is_expected.to include(expected)
+ end
+ end
+
+ context 'with directory' do
+ let(:files) { ['foo/bar'] }
+
+ it_behaves_like 'foo/bar directory key'
+ end
+
+ context 'with directory ending in slash' do
+ let(:files) { ['foo/bar/'] }
+
+ it_behaves_like 'foo/bar directory key'
+ end
+
+ context 'with directories ending in slash star' do
+ let(:files) { ['foo/bar/*'] }
+
+ it_behaves_like 'foo/bar directory key'
+ end
+ end
+ end
+
+ context 'with cache:key:prefix' do
+ context 'without files' do
+ let(:config) do
+ {
+ key: {
+ prefix: 'a-prefix'
+ },
+ paths: ['vendor/ruby']
+ }
+ end
+
+ it 'adds prefix to default key' do
+ expected = {
+ options: {
+ cache: {
+ key: 'a-prefix-default',
+ paths: ['vendor/ruby']
+ }
+ }
+ }
+
+ is_expected.to include(expected)
+ end
+ end
+
+ context 'with existing files' do
+ let(:config) do
+ {
+ key: {
+ files: ['VERSION', 'Gemfile.zip'],
+ prefix: 'a-prefix'
+ },
+ paths: ['vendor/ruby']
+ }
+ end
+
+ it 'adds prefix key' do
+ expected = {
+ options: {
+ cache: {
+ key: 'a-prefix-703ecc8fef1635427a1f86a8a1a308831c122392',
+ paths: ['vendor/ruby']
+ }
+ }
+ }
+
+ is_expected.to include(expected)
+ end
+ end
+
+ context 'with missing files' do
+ let(:config) do
+ {
+ key: {
+ files: ['project-gemfile.lock', ''],
+ prefix: 'a-prefix'
+ },
+ paths: ['vendor/ruby']
+ }
+ end
+
+ it 'adds prefix to default key' do
+ expected = {
+ options: {
+ cache: {
+ key: 'a-prefix-default',
+ paths: ['vendor/ruby']
+ }
+ }
+ }
+
+ is_expected.to include(expected)
+ end
+ end
+ end
+
+ context 'with all cache option keys' do
+ let(:config) do
+ {
+ key: 'a-key',
+ paths: ['vendor/ruby'],
+ untracked: true,
+ policy: 'push',
+ when: 'on_success'
+ }
+ end
+
+ it { is_expected.to include(options: { cache: config }) }
+ end
+
+ context 'with unknown cache option keys' do
+ let(:config) do
+ {
+ key: 'a-key',
+ unknown_key: true
+ }
+ end
+
+ it { expect { subject }.to raise_error(ArgumentError, /unknown_key/) }
+ end
+
+ context 'with empty config' do
+ let(:config) { {} }
+
+ it { is_expected.to include(options: {}) }
+ end
+ end
+ end
+
+ describe '#attributes' do
+ subject { processor.attributes }
context 'with cache:key' do
let(:config) do
@@ -20,7 +267,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
}
end
- it { is_expected.to include(options: { cache: config }) }
+ it { is_expected.to include(config) }
end
context 'with cache:key as a symbol' do
@@ -31,7 +278,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
}
end
- it { is_expected.to include(options: { cache: config.merge(key: "a_key") }) }
+ it { is_expected.to include(config.merge(key: "a_key")) }
end
context 'with cache:key:files' do
@@ -41,7 +288,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
end
it 'uses default key' do
- expected = { options: { cache: { key: 'default' } } }
+ expected = { key: 'default' }
is_expected.to include(expected)
end
@@ -59,13 +306,9 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
it 'builds a string key' do
expected = {
- options: {
- cache: {
key: '703ecc8fef1635427a1f86a8a1a308831c122392',
paths: ['vendor/ruby']
- }
}
- }
is_expected.to include(expected)
end
@@ -112,11 +355,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
end
it 'builds a string key' do
- expected = {
- options: {
- cache: { key: '74bf43fb1090f161bdd4e265802775dbda2f03d1' }
- }
- }
+ expected = { key: '74bf43fb1090f161bdd4e265802775dbda2f03d1' }
is_expected.to include(expected)
end
@@ -155,13 +394,9 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
it 'adds prefix to default key' do
expected = {
- options: {
- cache: {
key: 'a-prefix-default',
paths: ['vendor/ruby']
}
- }
- }
is_expected.to include(expected)
end
@@ -180,13 +415,9 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
it 'adds prefix key' do
expected = {
- options: {
- cache: {
key: 'a-prefix-703ecc8fef1635427a1f86a8a1a308831c122392',
paths: ['vendor/ruby']
}
- }
- }
is_expected.to include(expected)
end
@@ -205,13 +436,9 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
it 'adds prefix to default key' do
expected = {
- options: {
- cache: {
key: 'a-prefix-default',
paths: ['vendor/ruby']
}
- }
- }
is_expected.to include(expected)
end
@@ -229,7 +456,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
}
end
- it { is_expected.to include(options: { cache: config }) }
+ it { is_expected.to include(config) }
end
context 'with unknown cache option keys' do
@@ -242,11 +469,5 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build::Cache do
it { expect { subject }.to raise_error(ArgumentError, /unknown_key/) }
end
-
- context 'with empty config' do
- let(:config) { {} }
-
- it { is_expected.to include(options: {}) }
- end
end
end
diff --git a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
index 0efc7484699..7ec6949f852 100644
--- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
@@ -85,99 +85,169 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do
{ key: 'VAR2', value: 'var 2', public: true },
{ key: 'VAR3', value: 'var 3', public: true }])
end
+ end
- context 'when FF ci_rules_variables is disabled' do
- before do
- stub_feature_flags(ci_rules_variables: false)
- end
+ context 'with multiple_cache_per_job FF disabled' do
+ before do
+ stub_feature_flags(multiple_cache_per_job: false)
+ end
- it do
- is_expected.to include(yaml_variables: [{ key: 'VAR1', value: 'var 1', public: true },
- { key: 'VAR2', value: 'var 2', public: true }])
+ context 'with cache:key' do
+ let(:attributes) do
+ {
+ name: 'rspec',
+ ref: 'master',
+ cache: {
+ key: 'a-value'
+ }
+ }
end
+
+ it { is_expected.to include(options: { cache: { key: 'a-value' } }) }
end
- end
- context 'with cache:key' do
- let(:attributes) do
- {
- name: 'rspec',
- ref: 'master',
- cache: {
- key: 'a-value'
+ context 'with cache:key:files' do
+ let(:attributes) do
+ {
+ name: 'rspec',
+ ref: 'master',
+ cache: {
+ key: {
+ files: ['VERSION']
+ }
+ }
}
- }
- end
+ end
- it { is_expected.to include(options: { cache: { key: 'a-value' } }) }
- end
+ it 'includes cache options' do
+ cache_options = {
+ options: {
+ cache: { key: 'f155568ad0933d8358f66b846133614f76dd0ca4' }
+ }
+ }
- context 'with cache:key:files' do
- let(:attributes) do
- {
- name: 'rspec',
- ref: 'master',
- cache: {
- key: {
- files: ['VERSION']
+ is_expected.to include(cache_options)
+ end
+ end
+
+ context 'with cache:key:prefix' do
+ let(:attributes) do
+ {
+ name: 'rspec',
+ ref: 'master',
+ cache: {
+ key: {
+ prefix: 'something'
+ }
}
}
- }
+ end
+
+ it { is_expected.to include(options: { cache: { key: 'something-default' } }) }
end
- it 'includes cache options' do
- cache_options = {
- options: {
+ context 'with cache:key:files and prefix' do
+ let(:attributes) do
+ {
+ name: 'rspec',
+ ref: 'master',
cache: {
- key: 'f155568ad0933d8358f66b846133614f76dd0ca4'
+ key: {
+ files: ['VERSION'],
+ prefix: 'something'
+ }
}
}
- }
+ end
- is_expected.to include(cache_options)
+ it 'includes cache options' do
+ cache_options = {
+ options: {
+ cache: { key: 'something-f155568ad0933d8358f66b846133614f76dd0ca4' }
+ }
+ }
+
+ is_expected.to include(cache_options)
+ end
end
end
- context 'with cache:key:prefix' do
+ context 'with cache:key' do
let(:attributes) do
{
name: 'rspec',
ref: 'master',
- cache: {
- key: {
- prefix: 'something'
- }
- }
+ cache: [{
+ key: 'a-value'
+ }]
}
end
- it { is_expected.to include(options: { cache: { key: 'something-default' } }) }
- end
+ it { is_expected.to include(options: { cache: [a_hash_including(key: 'a-value')] }) }
- context 'with cache:key:files and prefix' do
- let(:attributes) do
- {
- name: 'rspec',
- ref: 'master',
- cache: {
- key: {
- files: ['VERSION'],
- prefix: 'something'
+ context 'with cache:key:files' do
+ let(:attributes) do
+ {
+ name: 'rspec',
+ ref: 'master',
+ cache: [{
+ key: {
+ files: ['VERSION']
+ }
+ }]
+ }
+ end
+
+ it 'includes cache options' do
+ cache_options = {
+ options: {
+ cache: [a_hash_including(key: 'f155568ad0933d8358f66b846133614f76dd0ca4')]
}
}
- }
+
+ is_expected.to include(cache_options)
+ end
end
- it 'includes cache options' do
- cache_options = {
- options: {
- cache: {
- key: 'something-f155568ad0933d8358f66b846133614f76dd0ca4'
+ context 'with cache:key:prefix' do
+ let(:attributes) do
+ {
+ name: 'rspec',
+ ref: 'master',
+ cache: [{
+ key: {
+ prefix: 'something'
+ }
+ }]
+ }
+ end
+
+ it { is_expected.to include(options: { cache: [a_hash_including( key: 'something-default' )] }) }
+ end
+
+ context 'with cache:key:files and prefix' do
+ let(:attributes) do
+ {
+ name: 'rspec',
+ ref: 'master',
+ cache: [{
+ key: {
+ files: ['VERSION'],
+ prefix: 'something'
+ }
+ }]
+ }
+ end
+
+ it 'includes cache options' do
+ cache_options = {
+ options: {
+ cache: [a_hash_including(key: 'something-f155568ad0933d8358f66b846133614f76dd0ca4')]
}
}
- }
- is_expected.to include(cache_options)
+ is_expected.to include(cache_options)
+ end
end
end
@@ -190,7 +260,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do
}
end
- it { is_expected.to include(options: {}) }
+ it { is_expected.to include({}) }
end
context 'with allow_failure' do
@@ -307,7 +377,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do
it 'does not have environment' do
expect(subject).not_to be_has_environment
expect(subject.environment).to be_nil
- expect(subject.metadata.expanded_environment_name).to be_nil
+ expect(subject.metadata).to be_nil
expect(Environment.exists?(name: expected_environment_name)).to eq(false)
end
end
@@ -979,6 +1049,25 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do
expect(subject.errors).to contain_exactly(
"'rspec' job needs 'build' job, but it was not added to the pipeline")
end
+
+ context 'when the needed job is optional' do
+ let(:needs_attributes) { [{ name: 'build', optional: true }] }
+
+ it "does not return an error" do
+ expect(subject.errors).to be_empty
+ end
+
+ context 'when the FF ci_needs_optional is disabled' do
+ before do
+ stub_feature_flags(ci_needs_optional: false)
+ end
+
+ it "returns an error" do
+ expect(subject.errors).to contain_exactly(
+ "'rspec' job needs 'build' job, but it was not added to the pipeline")
+ end
+ end
+ end
end
context 'when build job is part of prior stages' do
@@ -1036,4 +1125,75 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do
end
end
end
+
+ describe 'applying pipeline variables' do
+ subject { seed_build }
+
+ let(:pipeline_variables) { [] }
+ let(:pipeline) do
+ build(:ci_empty_pipeline, project: project, sha: head_sha, variables: pipeline_variables)
+ end
+
+ context 'containing variable references' do
+ let(:pipeline_variables) do
+ [
+ build(:ci_pipeline_variable, key: 'A', value: '$B'),
+ build(:ci_pipeline_variable, key: 'B', value: '$C')
+ ]
+ end
+
+ context 'when FF :variable_inside_variable is enabled' do
+ before do
+ stub_feature_flags(variable_inside_variable: [project])
+ end
+
+ it "does not have errors" do
+ expect(subject.errors).to be_empty
+ end
+ end
+ end
+
+ context 'containing cyclic reference' do
+ let(:pipeline_variables) do
+ [
+ build(:ci_pipeline_variable, key: 'A', value: '$B'),
+ build(:ci_pipeline_variable, key: 'B', value: '$C'),
+ build(:ci_pipeline_variable, key: 'C', value: '$A')
+ ]
+ end
+
+ context 'when FF :variable_inside_variable is disabled' do
+ before do
+ stub_feature_flags(variable_inside_variable: false)
+ end
+
+ it "does not have errors" do
+ expect(subject.errors).to be_empty
+ end
+ end
+
+ context 'when FF :variable_inside_variable is enabled' do
+ before do
+ stub_feature_flags(variable_inside_variable: [project])
+ end
+
+ it "returns an error" do
+ expect(subject.errors).to contain_exactly(
+ 'rspec: circular variable reference detected: ["A", "B", "C"]')
+ end
+
+ context 'with job:rules:[if:]' do
+ let(:attributes) { { name: 'rspec', ref: 'master', rules: [{ if: '$C != null', when: 'always' }] } }
+
+ it "included? does not raise" do
+ expect { subject.included? }.not_to raise_error
+ end
+
+ it "included? returns true" do
+ expect(subject.included?).to eq(true)
+ end
+ end
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb
index 664aaaedf7b..99196d393c6 100644
--- a/spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb
@@ -88,6 +88,55 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Environment do
end
end
+ context 'when job has deployment tier attribute' do
+ let(:attributes) do
+ {
+ environment: 'customer-portal',
+ options: {
+ environment: {
+ name: 'customer-portal',
+ deployment_tier: deployment_tier
+ }
+ }
+ }
+ end
+
+ let(:deployment_tier) { 'production' }
+
+ context 'when environment has not been created yet' do
+ it 'sets the specified deployment tier' do
+ is_expected.to be_production
+ end
+
+ context 'when deployment tier is staging' do
+ let(:deployment_tier) { 'staging' }
+
+ it 'sets the specified deployment tier' do
+ is_expected.to be_staging
+ end
+ end
+
+ context 'when deployment tier is unknown' do
+ let(:deployment_tier) { 'unknown' }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(ArgumentError, "'unknown' is not a valid tier")
+ end
+ end
+ end
+
+ context 'when environment has already been created' do
+ before do
+ create(:environment, :staging, project: project, name: 'customer-portal')
+ end
+
+ it 'does not overwrite the specified deployment tier' do
+ # This is to be updated when a deployment succeeded i.e. Deployments::UpdateEnvironmentService.
+ is_expected.to be_staging
+ end
+ end
+ end
+
context 'when job starts a review app' do
let(:environment_name) { 'review/$CI_COMMIT_REF_NAME' }
let(:expected_environment_name) { "review/#{job.ref}" }
diff --git a/spec/lib/gitlab/ci/reports/codequality_reports_comparer_spec.rb b/spec/lib/gitlab/ci/reports/codequality_reports_comparer_spec.rb
index 90188b56f5a..b322e55cb5a 100644
--- a/spec/lib/gitlab/ci/reports/codequality_reports_comparer_spec.rb
+++ b/spec/lib/gitlab/ci/reports/codequality_reports_comparer_spec.rb
@@ -27,6 +27,22 @@ RSpec.describe Gitlab::Ci::Reports::CodequalityReportsComparer do
expect(report_status).to eq(described_class::STATUS_SUCCESS)
end
end
+
+ context 'when head report does not exist' do
+ let(:head_report) { nil }
+
+ it 'returns status not found' do
+ expect(report_status).to eq(described_class::STATUS_NOT_FOUND)
+ end
+ end
+
+ context 'when base report does not exist' do
+ let(:base_report) { nil }
+
+ it 'returns status success' do
+ expect(report_status).to eq(described_class::STATUS_NOT_FOUND)
+ end
+ end
end
describe '#errors_count' do
@@ -93,6 +109,14 @@ RSpec.describe Gitlab::Ci::Reports::CodequalityReportsComparer do
expect(resolved_count).to be_zero
end
end
+
+ context 'when base report is nil' do
+ let(:base_report) { nil }
+
+ it 'returns zero' do
+ expect(resolved_count).to be_zero
+ end
+ end
end
describe '#total_count' do
@@ -140,6 +164,14 @@ RSpec.describe Gitlab::Ci::Reports::CodequalityReportsComparer do
expect(total_count).to eq(2)
end
end
+
+ context 'when base report is nil' do
+ let(:base_report) { nil }
+
+ it 'returns zero' do
+ expect(total_count).to be_zero
+ end
+ end
end
describe '#existing_errors' do
@@ -177,6 +209,14 @@ RSpec.describe Gitlab::Ci::Reports::CodequalityReportsComparer do
expect(existing_errors).to be_empty
end
end
+
+ context 'when base report is nil' do
+ let(:base_report) { nil }
+
+ it 'returns an empty array' do
+ expect(existing_errors).to be_empty
+ end
+ end
end
describe '#new_errors' do
@@ -213,6 +253,14 @@ RSpec.describe Gitlab::Ci::Reports::CodequalityReportsComparer do
expect(new_errors).to eq([degradation_1])
end
end
+
+ context 'when base report is nil' do
+ let(:base_report) { nil }
+
+ it 'returns an empty array' do
+ expect(new_errors).to be_empty
+ end
+ end
end
describe '#resolved_errors' do
@@ -250,5 +298,13 @@ RSpec.describe Gitlab::Ci::Reports::CodequalityReportsComparer do
expect(resolved_errors).to be_empty
end
end
+
+ context 'when base report is nil' do
+ let(:base_report) { nil }
+
+ it 'returns an empty array' do
+ expect(resolved_errors).to be_empty
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/ci/reports/reports_comparer_spec.rb b/spec/lib/gitlab/ci/reports/reports_comparer_spec.rb
index 1e5e4766583..7ed9270e9a0 100644
--- a/spec/lib/gitlab/ci/reports/reports_comparer_spec.rb
+++ b/spec/lib/gitlab/ci/reports/reports_comparer_spec.rb
@@ -45,6 +45,22 @@ RSpec.describe Gitlab::Ci::Reports::ReportsComparer do
expect(status).to eq('failed')
end
end
+
+ context 'when base_report is nil' do
+ let(:base_report) { nil }
+
+ it 'returns status not_found' do
+ expect(status).to eq('not_found')
+ end
+ end
+
+ context 'when head_report is nil' do
+ let(:head_report) { nil }
+
+ it 'returns status not_found' do
+ expect(status).to eq('not_found')
+ end
+ end
end
describe '#success?' do
@@ -94,4 +110,22 @@ RSpec.describe Gitlab::Ci::Reports::ReportsComparer do
expect { total_count }.to raise_error(NotImplementedError)
end
end
+
+ describe '#not_found?' do
+ subject(:not_found) { comparer.not_found? }
+
+ context 'when base report is nil' do
+ let(:base_report) { nil }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when base report exists' do
+ before do
+ allow(comparer).to receive(:success?).and_return(true)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/reports/test_suite_summary_spec.rb b/spec/lib/gitlab/ci/reports/test_suite_summary_spec.rb
index a98d3db4e82..9acea852832 100644
--- a/spec/lib/gitlab/ci/reports/test_suite_summary_spec.rb
+++ b/spec/lib/gitlab/ci/reports/test_suite_summary_spec.rb
@@ -87,12 +87,44 @@ RSpec.describe Gitlab::Ci::Reports::TestSuiteSummary do
end
end
+ describe '#suite_error' do
+ subject(:suite_error) { test_suite_summary.suite_error }
+
+ context 'when there are no build report results with suite errors' do
+ it { is_expected.to be_nil }
+ end
+
+ context 'when there are build report results with suite errors' do
+ let(:build_report_result_1) do
+ build(
+ :ci_build_report_result,
+ :with_junit_suite_error,
+ test_suite_name: 'karma',
+ test_suite_error: 'karma parsing error'
+ )
+ end
+
+ let(:build_report_result_2) do
+ build(
+ :ci_build_report_result,
+ :with_junit_suite_error,
+ test_suite_name: 'karma',
+ test_suite_error: 'another karma parsing error'
+ )
+ end
+
+ it 'includes the first suite error from the collection of build report results' do
+ expect(suite_error).to eq('karma parsing error')
+ end
+ end
+ end
+
describe '#to_h' do
subject { test_suite_summary.to_h }
context 'when test suite summary has several build report results' do
it 'returns the total as a hash' do
- expect(subject).to include(:time, :count, :success, :failed, :skipped, :error)
+ expect(subject).to include(:time, :count, :success, :failed, :skipped, :error, :suite_error)
end
end
end
diff --git a/spec/lib/gitlab/ci/status/composite_spec.rb b/spec/lib/gitlab/ci/status/composite_spec.rb
index bcfb9f19792..543cfe874ca 100644
--- a/spec/lib/gitlab/ci/status/composite_spec.rb
+++ b/spec/lib/gitlab/ci/status/composite_spec.rb
@@ -69,6 +69,8 @@ RSpec.describe Gitlab::Ci::Status::Composite do
%i(manual) | false | 'skipped' | false
%i(skipped failed) | false | 'success' | true
%i(skipped failed) | true | 'skipped' | true
+ %i(success manual) | true | 'skipped' | false
+ %i(success manual) | false | 'success' | false
%i(created failed) | false | 'created' | true
%i(preparing manual) | false | 'preparing' | false
end
@@ -80,6 +82,25 @@ RSpec.describe Gitlab::Ci::Status::Composite do
it_behaves_like 'compares status and warnings'
end
+
+ context 'when FF ci_fix_pipeline_status_for_dag_needs_manual is disabled' do
+ before do
+ stub_feature_flags(ci_fix_pipeline_status_for_dag_needs_manual: false)
+ end
+
+ where(:build_statuses, :dag, :result, :has_warnings) do
+ %i(success manual) | true | 'pending' | false
+ %i(success manual) | false | 'success' | false
+ end
+
+ with_them do
+ let(:all_statuses) do
+ build_statuses.map { |status| @statuses_with_allow_failure[status] }
+ end
+
+ it_behaves_like 'compares status and warnings'
+ end
+ end
end
end
end
diff --git a/spec/lib/gitlab/ci/status/factory_spec.rb b/spec/lib/gitlab/ci/status/factory_spec.rb
index 641cb0183d3..94a6255f1e2 100644
--- a/spec/lib/gitlab/ci/status/factory_spec.rb
+++ b/spec/lib/gitlab/ci/status/factory_spec.rb
@@ -134,4 +134,14 @@ RSpec.describe Gitlab::Ci::Status::Factory do
it_behaves_like 'compound decorator factory'
end
end
+
+ context 'behaviour of FactoryBot traits that create associations' do
+ context 'creating a namespace with an associated aggregation_schedule record' do
+ it 'creates only one Namespace record and one Namespace::AggregationSchedule record' do
+ expect { create(:namespace, :with_aggregation_schedule) }
+ .to change { Namespace.count }.by(1)
+ .and change { Namespace::AggregationSchedule.count }.by(1)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb
index f9d6fe24e70..6dfcecb853a 100644
--- a/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb
+++ b/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb
@@ -3,252 +3,260 @@
require 'spec_helper'
RSpec.describe 'Auto-DevOps.gitlab-ci.yml' do
+ using RSpec::Parameterized::TableSyntax
+
subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Auto-DevOps') }
- describe 'the created pipeline' do
- let(:default_branch) { 'master' }
- let(:pipeline_branch) { default_branch }
- let(:project) { create(:project, :auto_devops, :custom_repo, files: { 'README.md' => '' }) }
- let(:user) { project.owner }
- let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch ) }
- let(:pipeline) { service.execute!(:push) }
- let(:build_names) { pipeline.builds.pluck(:name) }
-
- before do
- stub_ci_pipeline_yaml_file(template.content)
- allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true)
- allow(project).to receive(:default_branch).and_return(default_branch)
- end
+ where(:default_branch) do
+ %w[master main]
+ end
- shared_examples 'no Kubernetes deployment job' do
- it 'does not create any Kubernetes deployment-related builds' do
- expect(build_names).not_to include('production')
- expect(build_names).not_to include('production_manual')
- expect(build_names).not_to include('staging')
- expect(build_names).not_to include('canary')
- expect(build_names).not_to include('review')
- expect(build_names).not_to include(a_string_matching(/rollout \d+%/))
- end
- end
+ with_them do
+ describe 'the created pipeline' do
+ let(:pipeline_branch) { default_branch }
+ let(:project) { create(:project, :auto_devops, :custom_repo, files: { 'README.md' => '' }) }
+ let(:user) { project.owner }
+ let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch ) }
+ let(:pipeline) { service.execute!(:push) }
+ let(:build_names) { pipeline.builds.pluck(:name) }
- it 'creates a build and a test job' do
- expect(build_names).to include('build', 'test')
- end
+ before do
+ stub_application_setting(default_branch_name: default_branch)
+ stub_ci_pipeline_yaml_file(template.content)
+ allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true)
+ end
- context 'when the project is set for deployment to AWS' do
- let(:platform_value) { 'ECS' }
- let(:review_prod_build_names) { build_names.select {|n| n.include?('review') || n.include?('production')} }
+ shared_examples 'no Kubernetes deployment job' do
+ it 'does not create any Kubernetes deployment-related builds' do
+ expect(build_names).not_to include('production')
+ expect(build_names).not_to include('production_manual')
+ expect(build_names).not_to include('staging')
+ expect(build_names).not_to include('canary')
+ expect(build_names).not_to include('review')
+ expect(build_names).not_to include(a_string_matching(/rollout \d+%/))
+ end
+ end
- before do
- create(:ci_variable, project: project, key: 'AUTO_DEVOPS_PLATFORM_TARGET', value: platform_value)
+ it 'creates a build and a test job' do
+ expect(build_names).to include('build', 'test')
end
- shared_examples 'no ECS job when AUTO_DEVOPS_PLATFORM_TARGET is not present' do |job_name|
- context 'when AUTO_DEVOPS_PLATFORM_TARGET is nil' do
- let(:platform_value) { nil }
+ context 'when the project is set for deployment to AWS' do
+ let(:platform_value) { 'ECS' }
+ let(:review_prod_build_names) { build_names.select {|n| n.include?('review') || n.include?('production')} }
- it 'does not trigger the job' do
- expect(build_names).not_to include(job_name)
- end
+ before do
+ create(:ci_variable, project: project, key: 'AUTO_DEVOPS_PLATFORM_TARGET', value: platform_value)
end
- context 'when AUTO_DEVOPS_PLATFORM_TARGET is empty' do
- let(:platform_value) { '' }
+ shared_examples 'no ECS job when AUTO_DEVOPS_PLATFORM_TARGET is not present' do |job_name|
+ context 'when AUTO_DEVOPS_PLATFORM_TARGET is nil' do
+ let(:platform_value) { nil }
- it 'does not trigger the job' do
- expect(build_names).not_to include(job_name)
+ it 'does not trigger the job' do
+ expect(build_names).not_to include(job_name)
+ end
end
- end
- end
- it_behaves_like 'no Kubernetes deployment job'
+ context 'when AUTO_DEVOPS_PLATFORM_TARGET is empty' do
+ let(:platform_value) { '' }
- it_behaves_like 'no ECS job when AUTO_DEVOPS_PLATFORM_TARGET is not present' do
- let(:job_name) { 'production_ecs' }
- end
+ it 'does not trigger the job' do
+ expect(build_names).not_to include(job_name)
+ end
+ end
+ end
- it 'creates an ECS deployment job for production only' do
- expect(review_prod_build_names).to contain_exactly('production_ecs')
- end
+ it_behaves_like 'no Kubernetes deployment job'
- context 'with FARGATE as a launch type' do
- let(:platform_value) { 'FARGATE' }
+ it_behaves_like 'no ECS job when AUTO_DEVOPS_PLATFORM_TARGET is not present' do
+ let(:job_name) { 'production_ecs' }
+ end
- it 'creates a FARGATE deployment job for production only' do
- expect(review_prod_build_names).to contain_exactly('production_fargate')
+ it 'creates an ECS deployment job for production only' do
+ expect(review_prod_build_names).to contain_exactly('production_ecs')
end
- end
- context 'and we are not on the default branch' do
- let(:platform_value) { 'ECS' }
- let(:pipeline_branch) { 'patch-1' }
+ context 'with FARGATE as a launch type' do
+ let(:platform_value) { 'FARGATE' }
- before do
- project.repository.create_branch(pipeline_branch)
+ it 'creates a FARGATE deployment job for production only' do
+ expect(review_prod_build_names).to contain_exactly('production_fargate')
+ end
end
- %w(review_ecs review_fargate).each do |job|
- it_behaves_like 'no ECS job when AUTO_DEVOPS_PLATFORM_TARGET is not present' do
- let(:job_name) { job }
+ context 'and we are not on the default branch' do
+ let(:platform_value) { 'ECS' }
+ let(:pipeline_branch) { 'patch-1' }
+
+ before do
+ project.repository.create_branch(pipeline_branch, default_branch)
end
- end
- it 'creates an ECS deployment job for review only' do
- expect(review_prod_build_names).to contain_exactly('review_ecs', 'stop_review_ecs')
- end
+ %w(review_ecs review_fargate).each do |job|
+ it_behaves_like 'no ECS job when AUTO_DEVOPS_PLATFORM_TARGET is not present' do
+ let(:job_name) { job }
+ end
+ end
- context 'with FARGATE as a launch type' do
- let(:platform_value) { 'FARGATE' }
+ it 'creates an ECS deployment job for review only' do
+ expect(review_prod_build_names).to contain_exactly('review_ecs', 'stop_review_ecs')
+ end
+
+ context 'with FARGATE as a launch type' do
+ let(:platform_value) { 'FARGATE' }
- it 'creates an FARGATE deployment job for review only' do
- expect(review_prod_build_names).to contain_exactly('review_fargate', 'stop_review_fargate')
+ it 'creates an FARGATE deployment job for review only' do
+ expect(review_prod_build_names).to contain_exactly('review_fargate', 'stop_review_fargate')
+ end
end
end
- end
- context 'and when the project has an active cluster' do
- let(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) }
+ context 'and when the project has an active cluster' do
+ let(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) }
- before do
- allow(cluster).to receive(:active?).and_return(true)
- end
+ before do
+ allow(cluster).to receive(:active?).and_return(true)
+ end
- context 'on default branch' do
- it 'triggers the deployment to Kubernetes, not to ECS' do
- expect(build_names).not_to include('review')
- expect(build_names).to include('production')
- expect(build_names).not_to include('production_ecs')
- expect(build_names).not_to include('review_ecs')
+ context 'on default branch' do
+ it 'triggers the deployment to Kubernetes, not to ECS' do
+ expect(build_names).not_to include('review')
+ expect(build_names).to include('production')
+ expect(build_names).not_to include('production_ecs')
+ expect(build_names).not_to include('review_ecs')
+ end
end
end
- end
- context 'when the platform target is EC2' do
- let(:platform_value) { 'EC2' }
+ context 'when the platform target is EC2' do
+ let(:platform_value) { 'EC2' }
- it 'contains the build_artifact job, not the build job' do
- expect(build_names).to include('build_artifact')
- expect(build_names).not_to include('build')
+ it 'contains the build_artifact job, not the build job' do
+ expect(build_names).to include('build_artifact')
+ expect(build_names).not_to include('build')
+ end
end
end
- end
-
- context 'when the project has no active cluster' do
- it 'only creates a build and a test stage' do
- expect(pipeline.stages_names).to eq(%w(build test))
- end
- it_behaves_like 'no Kubernetes deployment job'
- end
+ context 'when the project has no active cluster' do
+ it 'only creates a build and a test stage' do
+ expect(pipeline.stages_names).to eq(%w(build test))
+ end
- context 'when the project has an active cluster' do
- let!(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) }
-
- describe 'deployment-related builds' do
- context 'on default branch' do
- it 'does not include rollout jobs besides production' do
- expect(build_names).to include('production')
- expect(build_names).not_to include('production_manual')
- expect(build_names).not_to include('staging')
- expect(build_names).not_to include('canary')
- expect(build_names).not_to include('review')
- expect(build_names).not_to include(a_string_matching(/rollout \d+%/))
- end
+ it_behaves_like 'no Kubernetes deployment job'
+ end
- context 'when STAGING_ENABLED=1' do
- before do
- create(:ci_variable, project: project, key: 'STAGING_ENABLED', value: '1')
- end
+ context 'when the project has an active cluster' do
+ let!(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) }
- it 'includes a staging job and a production_manual job' do
- expect(build_names).not_to include('production')
- expect(build_names).to include('production_manual')
- expect(build_names).to include('staging')
+ describe 'deployment-related builds' do
+ context 'on default branch' do
+ it 'does not include rollout jobs besides production' do
+ expect(build_names).to include('production')
+ expect(build_names).not_to include('production_manual')
+ expect(build_names).not_to include('staging')
expect(build_names).not_to include('canary')
expect(build_names).not_to include('review')
expect(build_names).not_to include(a_string_matching(/rollout \d+%/))
end
+
+ context 'when STAGING_ENABLED=1' do
+ before do
+ create(:ci_variable, project: project, key: 'STAGING_ENABLED', value: '1')
+ end
+
+ it 'includes a staging job and a production_manual job' do
+ expect(build_names).not_to include('production')
+ expect(build_names).to include('production_manual')
+ expect(build_names).to include('staging')
+ expect(build_names).not_to include('canary')
+ expect(build_names).not_to include('review')
+ expect(build_names).not_to include(a_string_matching(/rollout \d+%/))
+ end
+ end
+
+ context 'when CANARY_ENABLED=1' do
+ before do
+ create(:ci_variable, project: project, key: 'CANARY_ENABLED', value: '1')
+ end
+
+ it 'includes a canary job and a production_manual job' do
+ expect(build_names).not_to include('production')
+ expect(build_names).to include('production_manual')
+ expect(build_names).not_to include('staging')
+ expect(build_names).to include('canary')
+ expect(build_names).not_to include('review')
+ expect(build_names).not_to include(a_string_matching(/rollout \d+%/))
+ end
+ end
end
- context 'when CANARY_ENABLED=1' do
+ context 'outside of default branch' do
+ let(:pipeline_branch) { 'patch-1' }
+
before do
- create(:ci_variable, project: project, key: 'CANARY_ENABLED', value: '1')
+ project.repository.create_branch(pipeline_branch, default_branch)
end
- it 'includes a canary job and a production_manual job' do
+ it 'does not include rollout jobs besides review' do
expect(build_names).not_to include('production')
- expect(build_names).to include('production_manual')
+ expect(build_names).not_to include('production_manual')
expect(build_names).not_to include('staging')
- expect(build_names).to include('canary')
- expect(build_names).not_to include('review')
+ expect(build_names).not_to include('canary')
+ expect(build_names).to include('review')
expect(build_names).not_to include(a_string_matching(/rollout \d+%/))
end
end
end
-
- context 'outside of default branch' do
- let(:pipeline_branch) { 'patch-1' }
-
- before do
- project.repository.create_branch(pipeline_branch)
- end
-
- it 'does not include rollout jobs besides review' do
- expect(build_names).not_to include('production')
- expect(build_names).not_to include('production_manual')
- expect(build_names).not_to include('staging')
- expect(build_names).not_to include('canary')
- expect(build_names).to include('review')
- expect(build_names).not_to include(a_string_matching(/rollout \d+%/))
- end
- end
end
end
- end
- describe 'build-pack detection' do
- using RSpec::Parameterized::TableSyntax
-
- where(:case_name, :files, :variables, :include_build_names, :not_include_build_names) do
- 'No match' | { 'README.md' => '' } | {} | %w() | %w(build test)
- 'Buildpack' | { 'README.md' => '' } | { 'BUILDPACK_URL' => 'http://example.com' } | %w(build test) | %w()
- 'Explicit set' | { 'README.md' => '' } | { 'AUTO_DEVOPS_EXPLICITLY_ENABLED' => '1' } | %w(build test) | %w()
- 'Explicit unset' | { 'README.md' => '' } | { 'AUTO_DEVOPS_EXPLICITLY_ENABLED' => '0' } | %w() | %w(build test)
- 'DOCKERFILE_PATH' | { 'README.md' => '' } | { 'DOCKERFILE_PATH' => 'Docker.file' } | %w(build test) | %w()
- 'Dockerfile' | { 'Dockerfile' => '' } | {} | %w(build test) | %w()
- 'Clojure' | { 'project.clj' => '' } | {} | %w(build test) | %w()
- 'Go modules' | { 'go.mod' => '' } | {} | %w(build test) | %w()
- 'Go gb' | { 'src/gitlab.com/gopackage.go' => '' } | {} | %w(build test) | %w()
- 'Gradle' | { 'gradlew' => '' } | {} | %w(build test) | %w()
- 'Java' | { 'pom.xml' => '' } | {} | %w(build test) | %w()
- 'Multi-buildpack' | { '.buildpacks' => '' } | {} | %w(build test) | %w()
- 'NodeJS' | { 'package.json' => '' } | {} | %w(build test) | %w()
- 'PHP' | { 'composer.json' => '' } | {} | %w(build test) | %w()
- 'Play' | { 'conf/application.conf' => '' } | {} | %w(build test) | %w()
- 'Python' | { 'Pipfile' => '' } | {} | %w(build test) | %w()
- 'Ruby' | { 'Gemfile' => '' } | {} | %w(build test) | %w()
- 'Scala' | { 'build.sbt' => '' } | {} | %w(build test) | %w()
- 'Static' | { '.static' => '' } | {} | %w(build test) | %w()
- end
+ describe 'build-pack detection' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:case_name, :files, :variables, :include_build_names, :not_include_build_names) do
+ 'No match' | { 'README.md' => '' } | {} | %w() | %w(build test)
+ 'Buildpack' | { 'README.md' => '' } | { 'BUILDPACK_URL' => 'http://example.com' } | %w(build test) | %w()
+ 'Explicit set' | { 'README.md' => '' } | { 'AUTO_DEVOPS_EXPLICITLY_ENABLED' => '1' } | %w(build test) | %w()
+ 'Explicit unset' | { 'README.md' => '' } | { 'AUTO_DEVOPS_EXPLICITLY_ENABLED' => '0' } | %w() | %w(build test)
+ 'DOCKERFILE_PATH' | { 'README.md' => '' } | { 'DOCKERFILE_PATH' => 'Docker.file' } | %w(build test) | %w()
+ 'Dockerfile' | { 'Dockerfile' => '' } | {} | %w(build test) | %w()
+ 'Clojure' | { 'project.clj' => '' } | {} | %w(build test) | %w()
+ 'Go modules' | { 'go.mod' => '' } | {} | %w(build test) | %w()
+ 'Go gb' | { 'src/gitlab.com/gopackage.go' => '' } | {} | %w(build test) | %w()
+ 'Gradle' | { 'gradlew' => '' } | {} | %w(build test) | %w()
+ 'Java' | { 'pom.xml' => '' } | {} | %w(build test) | %w()
+ 'Multi-buildpack' | { '.buildpacks' => '' } | {} | %w(build test) | %w()
+ 'NodeJS' | { 'package.json' => '' } | {} | %w(build test) | %w()
+ 'PHP' | { 'composer.json' => '' } | {} | %w(build test) | %w()
+ 'Play' | { 'conf/application.conf' => '' } | {} | %w(build test) | %w()
+ 'Python' | { 'Pipfile' => '' } | {} | %w(build test) | %w()
+ 'Ruby' | { 'Gemfile' => '' } | {} | %w(build test) | %w()
+ 'Scala' | { 'build.sbt' => '' } | {} | %w(build test) | %w()
+ 'Static' | { '.static' => '' } | {} | %w(build test) | %w()
+ end
- with_them do
- let(:project) { create(:project, :custom_repo, files: files) }
- let(:user) { project.owner }
- let(:service) { Ci::CreatePipelineService.new(project, user, ref: 'master' ) }
- let(:pipeline) { service.execute(:push) }
- let(:build_names) { pipeline.builds.pluck(:name) }
+ with_them do
+ let(:project) { create(:project, :custom_repo, files: files) }
+ let(:user) { project.owner }
+ let(:service) { Ci::CreatePipelineService.new(project, user, ref: default_branch ) }
+ let(:pipeline) { service.execute(:push) }
+ let(:build_names) { pipeline.builds.pluck(:name) }
- before do
- stub_ci_pipeline_yaml_file(template.content)
- allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true)
- variables.each do |(key, value)|
- create(:ci_variable, project: project, key: key, value: value)
+ before do
+ stub_application_setting(default_branch_name: default_branch)
+ stub_ci_pipeline_yaml_file(template.content)
+ allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true)
+ variables.each do |(key, value)|
+ create(:ci_variable, project: project, key: key, value: value)
+ end
end
- end
- it 'creates a pipeline with the expected jobs' do
- expect(build_names).to include(*include_build_names)
- expect(build_names).not_to include(*not_include_build_names)
+ it 'creates a pipeline with the expected jobs' do
+ expect(build_names).to include(*include_build_names)
+ expect(build_names).not_to include(*not_include_build_names)
+ end
end
end
end
diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb
index 92bf2519588..597e4ca9b03 100644
--- a/spec/lib/gitlab/ci/trace_spec.rb
+++ b/spec/lib/gitlab/ci/trace_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::Ci::Trace, :clean_gitlab_redis_shared_state, factory_default: :keep do
- let_it_be(:project) { create_default(:project) }
+ let_it_be(:project) { create_default(:project).freeze }
let_it_be_with_reload(:build) { create(:ci_build) }
let(:trace) { described_class.new(build) }
diff --git a/spec/lib/gitlab/ci/variables/collection/item_spec.rb b/spec/lib/gitlab/ci/variables/collection/item_spec.rb
index 2e43f22830a..ca9dc95711d 100644
--- a/spec/lib/gitlab/ci/variables/collection/item_spec.rb
+++ b/spec/lib/gitlab/ci/variables/collection/item_spec.rb
@@ -32,6 +32,7 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Item do
it 'saves given value' do
expect(subject[:key]).to eq variable_key
expect(subject[:value]).to eq expected_value
+ expect(subject.value).to eq expected_value
end
end
@@ -69,6 +70,47 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Item do
end
end
+ describe '#depends_on' do
+ let(:item) { Gitlab::Ci::Variables::Collection::Item.new(**variable) }
+
+ subject { item.depends_on }
+
+ context 'table tests' do
+ using RSpec::Parameterized::TableSyntax
+
+ where do
+ {
+ "no variable references": {
+ variable: { key: 'VAR', value: 'something' },
+ expected_depends_on: nil
+ },
+ "simple variable reference": {
+ variable: { key: 'VAR', value: 'something_$VAR2' },
+ expected_depends_on: %w(VAR2)
+ },
+ "complex expansion": {
+ variable: { key: 'VAR', value: 'something_${VAR2}_$VAR3' },
+ expected_depends_on: %w(VAR2 VAR3)
+ },
+ "complex expansion in raw variable": {
+ variable: { key: 'VAR', value: 'something_${VAR2}_$VAR3', raw: true },
+ expected_depends_on: nil
+ },
+ "complex expansions for Windows": {
+ variable: { key: 'variable3', value: 'key%variable%%variable2%' },
+ expected_depends_on: %w(variable variable2)
+ }
+ }
+ end
+
+ with_them do
+ it 'contains referenced variable names' do
+ is_expected.to eq(expected_depends_on)
+ end
+ end
+ end
+ end
+
describe '.fabricate' do
it 'supports using a hash' do
resource = described_class.fabricate(variable)
@@ -118,6 +160,26 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Item do
end
end
+ describe '#raw' do
+ it 'returns false when :raw is not specified' do
+ item = described_class.new(**variable)
+
+ expect(item.raw).to eq false
+ end
+
+ context 'when :raw is specified as true' do
+ let(:variable) do
+ { key: variable_key, value: variable_value, public: true, masked: false, raw: true }
+ end
+
+ it 'returns true' do
+ item = described_class.new(**variable)
+
+ expect(item.raw).to eq true
+ end
+ end
+ end
+
describe '#to_runner_variable' do
context 'when variable is not a file-related' do
it 'returns a runner-compatible hash representation' do
@@ -139,5 +201,47 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Item do
.to eq(key: 'VAR', value: 'value', public: true, file: true, masked: false)
end
end
+
+ context 'when variable is raw' do
+ it 'does not export raw value when it is false' do
+ runner_variable = described_class
+ .new(key: 'VAR', value: 'value', raw: false)
+ .to_runner_variable
+
+ expect(runner_variable)
+ .to eq(key: 'VAR', value: 'value', public: true, masked: false)
+ end
+
+ it 'exports raw value when it is true' do
+ runner_variable = described_class
+ .new(key: 'VAR', value: 'value', raw: true)
+ .to_runner_variable
+
+ expect(runner_variable)
+ .to eq(key: 'VAR', value: 'value', public: true, raw: true, masked: false)
+ end
+ end
+
+ context 'when referencing a variable' do
+ it '#depends_on contains names of dependencies' do
+ runner_variable = described_class.new(key: 'CI_VAR', value: '${CI_VAR_2}-123-$CI_VAR_3')
+
+ expect(runner_variable.depends_on).to eq(%w(CI_VAR_2 CI_VAR_3))
+ end
+ end
+
+ context 'when assigned the raw attribute' do
+ it 'retains a true raw attribute' do
+ runner_variable = described_class.new(key: 'CI_VAR', value: '123', raw: true)
+
+ expect(runner_variable).to eq(key: 'CI_VAR', value: '123', public: true, masked: false, raw: true)
+ end
+
+ it 'does not retain a false raw attribute' do
+ runner_variable = described_class.new(key: 'CI_VAR', value: '123', raw: false)
+
+ expect(runner_variable).to eq(key: 'CI_VAR', value: '123', public: true, masked: false)
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/ci/variables/collection/sort_spec.rb b/spec/lib/gitlab/ci/variables/collection/sort_spec.rb
new file mode 100644
index 00000000000..73cf0e19d00
--- /dev/null
+++ b/spec/lib/gitlab/ci/variables/collection/sort_spec.rb
@@ -0,0 +1,185 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Variables::Collection::Sort do
+ describe '#initialize with non-Collection value' do
+ context 'when FF :variable_inside_variable is disabled' do
+ subject { Gitlab::Ci::Variables::Collection::Sort.new([]) }
+
+ it 'raises ArgumentError' do
+ expect { subject }.to raise_error(ArgumentError, /Collection object was expected/)
+ end
+ end
+
+ context 'when FF :variable_inside_variable is enabled' do
+ subject { Gitlab::Ci::Variables::Collection::Sort.new([]) }
+
+ it 'raises ArgumentError' do
+ expect { subject }.to raise_error(ArgumentError, /Collection object was expected/)
+ end
+ end
+ end
+
+ describe '#errors' do
+ context 'table tests' do
+ using RSpec::Parameterized::TableSyntax
+
+ where do
+ {
+ "empty array": {
+ variables: [],
+ expected_errors: nil
+ },
+ "simple expansions": {
+ variables: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'result' },
+ { key: 'variable3', value: 'key$variable$variable2' }
+ ],
+ expected_errors: nil
+ },
+ "cyclic dependency": {
+ variables: [
+ { key: 'variable', value: '$variable2' },
+ { key: 'variable2', value: '$variable3' },
+ { key: 'variable3', value: 'key$variable$variable2' }
+ ],
+ expected_errors: 'circular variable reference detected: ["variable", "variable2", "variable3"]'
+ },
+ "array with raw variable": {
+ variables: [
+ { key: 'variable', value: '$variable2' },
+ { key: 'variable2', value: '$variable3' },
+ { key: 'variable3', value: 'key$variable$variable2', raw: true }
+ ],
+ expected_errors: nil
+ },
+ "variable containing escaped variable reference": {
+ variables: [
+ { key: 'variable_a', value: 'value' },
+ { key: 'variable_b', value: '$$variable_a' },
+ { key: 'variable_c', value: '$variable_b' }
+ ],
+ expected_errors: nil
+ }
+ }
+ end
+
+ with_them do
+ let(:collection) { Gitlab::Ci::Variables::Collection.new(variables) }
+
+ subject { Gitlab::Ci::Variables::Collection::Sort.new(collection) }
+
+ it 'errors matches expected errors' do
+ expect(subject.errors).to eq(expected_errors)
+ end
+
+ it 'valid? matches expected errors' do
+ expect(subject.valid?).to eq(expected_errors.nil?)
+ end
+
+ it 'does not raise' do
+ expect { subject }.not_to raise_error
+ end
+ end
+ end
+ end
+
+ describe '#tsort' do
+ context 'table tests' do
+ using RSpec::Parameterized::TableSyntax
+
+ where do
+ {
+ "empty array": {
+ variables: [],
+ result: []
+ },
+ "simple expansions, no reordering needed": {
+ variables: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'result' },
+ { key: 'variable3', value: 'key$variable$variable2' }
+ ],
+ result: %w[variable variable2 variable3]
+ },
+ "complex expansion, reordering needed": {
+ variables: [
+ { key: 'variable2', value: 'key${variable}' },
+ { key: 'variable', value: 'value' }
+ ],
+ result: %w[variable variable2]
+ },
+ "unused variables": {
+ variables: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable4', value: 'key$variable$variable3' },
+ { key: 'variable2', value: 'result2' },
+ { key: 'variable3', value: 'result3' }
+ ],
+ result: %w[variable variable3 variable4 variable2]
+ },
+ "missing variable": {
+ variables: [
+ { key: 'variable2', value: 'key$variable' }
+ ],
+ result: %w[variable2]
+ },
+ "complex expansions with missing variable": {
+ variables: [
+ { key: 'variable4', value: 'key${variable}${variable2}${variable3}' },
+ { key: 'variable', value: 'value' },
+ { key: 'variable3', value: 'value3' }
+ ],
+ result: %w[variable variable3 variable4]
+ },
+ "raw variable does not get resolved": {
+ variables: [
+ { key: 'variable', value: '$variable2' },
+ { key: 'variable2', value: '$variable3' },
+ { key: 'variable3', value: 'key$variable$variable2', raw: true }
+ ],
+ result: %w[variable3 variable2 variable]
+ },
+ "variable containing escaped variable reference": {
+ variables: [
+ { key: 'variable_c', value: '$variable_b' },
+ { key: 'variable_b', value: '$$variable_a' },
+ { key: 'variable_a', value: 'value' }
+ ],
+ result: %w[variable_a variable_b variable_c]
+ }
+ }
+ end
+
+ with_them do
+ let(:collection) { Gitlab::Ci::Variables::Collection.new(variables) }
+
+ subject { Gitlab::Ci::Variables::Collection::Sort.new(collection).tsort }
+
+ it 'returns correctly sorted variables' do
+ expect(subject.pluck(:key)).to eq(result)
+ end
+ end
+ end
+
+ context 'cyclic dependency' do
+ let(:variables) do
+ [
+ { key: 'variable2', value: '$variable3' },
+ { key: 'variable3', value: 'key$variable$variable2' },
+ { key: 'variable', value: '$variable2' }
+ ]
+ end
+
+ let(:collection) { Gitlab::Ci::Variables::Collection.new(variables) }
+
+ subject { Gitlab::Ci::Variables::Collection::Sort.new(collection).tsort }
+
+ it 'raises TSort::Cyclic' do
+ expect { subject }.to raise_error(TSort::Cyclic)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/variables/collection/sorted_spec.rb b/spec/lib/gitlab/ci/variables/collection/sorted_spec.rb
deleted file mode 100644
index 954273fd41e..00000000000
--- a/spec/lib/gitlab/ci/variables/collection/sorted_spec.rb
+++ /dev/null
@@ -1,259 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Ci::Variables::Collection::Sorted do
- describe '#errors' do
- context 'when FF :variable_inside_variable is disabled' do
- let_it_be(:project_with_flag_disabled) { create(:project) }
- let_it_be(:project_with_flag_enabled) { create(:project) }
-
- before do
- stub_feature_flags(variable_inside_variable: [project_with_flag_enabled])
- end
-
- context 'table tests' do
- using RSpec::Parameterized::TableSyntax
-
- where do
- {
- "empty array": {
- variables: []
- },
- "simple expansions": {
- variables: [
- { key: 'variable', value: 'value' },
- { key: 'variable2', value: 'result' },
- { key: 'variable3', value: 'key$variable$variable2' }
- ]
- },
- "complex expansion": {
- variables: [
- { key: 'variable', value: 'value' },
- { key: 'variable2', value: 'key${variable}' }
- ]
- },
- "complex expansions with missing variable for Windows": {
- variables: [
- { key: 'variable', value: 'value' },
- { key: 'variable3', value: 'key%variable%%variable2%' }
- ]
- },
- "out-of-order variable reference": {
- variables: [
- { key: 'variable2', value: 'key${variable}' },
- { key: 'variable', value: 'value' }
- ]
- },
- "array with cyclic dependency": {
- variables: [
- { key: 'variable', value: '$variable2' },
- { key: 'variable2', value: '$variable3' },
- { key: 'variable3', value: 'key$variable$variable2' }
- ]
- }
- }
- end
-
- with_them do
- subject { Gitlab::Ci::Variables::Collection::Sorted.new(variables, project_with_flag_disabled) }
-
- it 'does not report error' do
- expect(subject.errors).to eq(nil)
- end
-
- it 'valid? reports true' do
- expect(subject.valid?).to eq(true)
- end
- end
- end
- end
-
- context 'when FF :variable_inside_variable is enabled' do
- let_it_be(:project_with_flag_disabled) { create(:project) }
- let_it_be(:project_with_flag_enabled) { create(:project) }
-
- before do
- stub_feature_flags(variable_inside_variable: [project_with_flag_enabled])
- end
-
- context 'table tests' do
- using RSpec::Parameterized::TableSyntax
-
- where do
- {
- "empty array": {
- variables: [],
- validation_result: nil
- },
- "simple expansions": {
- variables: [
- { key: 'variable', value: 'value' },
- { key: 'variable2', value: 'result' },
- { key: 'variable3', value: 'key$variable$variable2' }
- ],
- validation_result: nil
- },
- "cyclic dependency": {
- variables: [
- { key: 'variable', value: '$variable2' },
- { key: 'variable2', value: '$variable3' },
- { key: 'variable3', value: 'key$variable$variable2' }
- ],
- validation_result: 'circular variable reference detected: ["variable", "variable2", "variable3"]'
- }
- }
- end
-
- with_them do
- subject { Gitlab::Ci::Variables::Collection::Sorted.new(variables, project_with_flag_enabled) }
-
- it 'errors matches expected validation result' do
- expect(subject.errors).to eq(validation_result)
- end
-
- it 'valid? matches expected validation result' do
- expect(subject.valid?).to eq(validation_result.nil?)
- end
- end
- end
- end
- end
-
- describe '#sort' do
- context 'when FF :variable_inside_variable is disabled' do
- before do
- stub_feature_flags(variable_inside_variable: false)
- end
-
- context 'table tests' do
- using RSpec::Parameterized::TableSyntax
-
- where do
- {
- "empty array": {
- variables: []
- },
- "simple expansions": {
- variables: [
- { key: 'variable', value: 'value' },
- { key: 'variable2', value: 'result' },
- { key: 'variable3', value: 'key$variable$variable2' }
- ]
- },
- "complex expansion": {
- variables: [
- { key: 'variable', value: 'value' },
- { key: 'variable2', value: 'key${variable}' }
- ]
- },
- "complex expansions with missing variable for Windows": {
- variables: [
- { key: 'variable', value: 'value' },
- { key: 'variable3', value: 'key%variable%%variable2%' }
- ]
- },
- "out-of-order variable reference": {
- variables: [
- { key: 'variable2', value: 'key${variable}' },
- { key: 'variable', value: 'value' }
- ]
- },
- "array with cyclic dependency": {
- variables: [
- { key: 'variable', value: '$variable2' },
- { key: 'variable2', value: '$variable3' },
- { key: 'variable3', value: 'key$variable$variable2' }
- ]
- }
- }
- end
-
- with_them do
- let_it_be(:project) { create(:project) }
- subject { Gitlab::Ci::Variables::Collection::Sorted.new(variables, project) }
-
- it 'does not expand variables' do
- expect(subject.sort).to eq(variables)
- end
- end
- end
- end
-
- context 'when FF :variable_inside_variable is enabled' do
- before do
- stub_licensed_features(group_saml_group_sync: true)
- stub_feature_flags(saml_group_links: true)
- stub_feature_flags(variable_inside_variable: true)
- end
-
- context 'table tests' do
- using RSpec::Parameterized::TableSyntax
-
- where do
- {
- "empty array": {
- variables: [],
- result: []
- },
- "simple expansions, no reordering needed": {
- variables: [
- { key: 'variable', value: 'value' },
- { key: 'variable2', value: 'result' },
- { key: 'variable3', value: 'key$variable$variable2' }
- ],
- result: %w[variable variable2 variable3]
- },
- "complex expansion, reordering needed": {
- variables: [
- { key: 'variable2', value: 'key${variable}' },
- { key: 'variable', value: 'value' }
- ],
- result: %w[variable variable2]
- },
- "unused variables": {
- variables: [
- { key: 'variable', value: 'value' },
- { key: 'variable4', value: 'key$variable$variable3' },
- { key: 'variable2', value: 'result2' },
- { key: 'variable3', value: 'result3' }
- ],
- result: %w[variable variable3 variable4 variable2]
- },
- "missing variable": {
- variables: [
- { key: 'variable2', value: 'key$variable' }
- ],
- result: %w[variable2]
- },
- "complex expansions with missing variable": {
- variables: [
- { key: 'variable4', value: 'key${variable}${variable2}${variable3}' },
- { key: 'variable', value: 'value' },
- { key: 'variable3', value: 'value3' }
- ],
- result: %w[variable variable3 variable4]
- },
- "cyclic dependency causes original array to be returned": {
- variables: [
- { key: 'variable2', value: '$variable3' },
- { key: 'variable3', value: 'key$variable$variable2' },
- { key: 'variable', value: '$variable2' }
- ],
- result: %w[variable2 variable3 variable]
- }
- }
- end
-
- with_them do
- let_it_be(:project) { create(:project) }
- subject { Gitlab::Ci::Variables::Collection::Sorted.new(variables, project) }
-
- it 'sort returns correctly sorted variables' do
- expect(subject.sort.map { |var| var[:key] }).to eq(result)
- end
- end
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/ci/variables/collection_spec.rb b/spec/lib/gitlab/ci/variables/collection_spec.rb
index ac84313ad9f..7b77754190a 100644
--- a/spec/lib/gitlab/ci/variables/collection_spec.rb
+++ b/spec/lib/gitlab/ci/variables/collection_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe Gitlab::Ci::Variables::Collection do
end
it 'can be initialized without an argument' do
- expect(subject).to be_none
+ is_expected.to be_none
end
end
@@ -21,13 +21,13 @@ RSpec.describe Gitlab::Ci::Variables::Collection do
it 'appends a hash' do
subject.append(key: 'VARIABLE', value: 'something')
- expect(subject).to be_one
+ is_expected.to be_one
end
it 'appends a Ci::Variable' do
subject.append(build(:ci_variable))
- expect(subject).to be_one
+ is_expected.to be_one
end
it 'appends an internal resource' do
@@ -35,7 +35,7 @@ RSpec.describe Gitlab::Ci::Variables::Collection do
subject.append(collection.first)
- expect(subject).to be_one
+ is_expected.to be_one
end
it 'returns self' do
@@ -98,6 +98,50 @@ RSpec.describe Gitlab::Ci::Variables::Collection do
end
end
+ describe '#[]' do
+ variable = { key: 'VAR', value: 'value', public: true, masked: false }
+
+ collection = described_class.new([variable])
+
+ it 'returns nil for a non-existent variable name' do
+ expect(collection['UNKNOWN_VAR']).to be_nil
+ end
+
+ it 'returns Item for an existent variable name' do
+ expect(collection['VAR']).to be_an_instance_of(Gitlab::Ci::Variables::Collection::Item)
+ expect(collection['VAR'].to_runner_variable).to eq(variable)
+ end
+ end
+
+ describe '#size' do
+ it 'returns zero for empty collection' do
+ collection = described_class.new([])
+
+ expect(collection.size).to eq(0)
+ end
+
+ it 'returns 2 for collection with 2 variables' do
+ collection = described_class.new(
+ [
+ { key: 'VAR1', value: 'value', public: true, masked: false },
+ { key: 'VAR2', value: 'value', public: true, masked: false }
+ ])
+
+ expect(collection.size).to eq(2)
+ end
+
+ it 'returns 3 for collection with 2 duplicate variables' do
+ collection = described_class.new(
+ [
+ { key: 'VAR1', value: 'value', public: true, masked: false },
+ { key: 'VAR2', value: 'value', public: true, masked: false },
+ { key: 'VAR1', value: 'value', public: true, masked: false }
+ ])
+
+ expect(collection.size).to eq(3)
+ end
+ end
+
describe '#to_runner_variables' do
it 'creates an array of hashes in a runner-compatible format' do
collection = described_class.new([{ key: 'TEST', value: '1' }])
@@ -121,4 +165,338 @@ RSpec.describe Gitlab::Ci::Variables::Collection do
expect(collection.to_hash).not_to include(TEST1: 'test-1')
end
end
+
+ describe '#reject' do
+ let(:collection) do
+ described_class.new
+ .append(key: 'CI_JOB_NAME', value: 'test-1')
+ .append(key: 'CI_BUILD_ID', value: '1')
+ .append(key: 'TEST1', value: 'test-3')
+ end
+
+ subject { collection.reject { |var| var[:key] =~ /\ACI_(JOB|BUILD)/ } }
+
+ it 'returns a Collection instance' do
+ is_expected.to be_an_instance_of(described_class)
+ end
+
+ it 'returns correctly filtered Collection' do
+ comp = collection.to_runner_variables.reject { |var| var[:key] =~ /\ACI_(JOB|BUILD)/ }
+ expect(subject.to_runner_variables).to eq(comp)
+ end
+ end
+
+ describe '#expand_value' do
+ let(:collection) do
+ Gitlab::Ci::Variables::Collection.new
+ .append(key: 'CI_JOB_NAME', value: 'test-1')
+ .append(key: 'CI_BUILD_ID', value: '1')
+ .append(key: 'RAW_VAR', value: '$TEST1', raw: true)
+ .append(key: 'TEST1', value: 'test-3')
+ end
+
+ context 'table tests' do
+ using RSpec::Parameterized::TableSyntax
+
+ where do
+ {
+ "empty value": {
+ value: '',
+ result: '',
+ keep_undefined: false
+ },
+ "simple expansions": {
+ value: 'key$TEST1-$CI_BUILD_ID',
+ result: 'keytest-3-1',
+ keep_undefined: false
+ },
+ "complex expansion": {
+ value: 'key${TEST1}-${CI_JOB_NAME}',
+ result: 'keytest-3-test-1',
+ keep_undefined: false
+ },
+ "complex expansions with raw variable": {
+ value: 'key${RAW_VAR}-${CI_JOB_NAME}',
+ result: 'key$TEST1-test-1',
+ keep_undefined: false
+ },
+ "missing variable not keeping original": {
+ value: 'key${MISSING_VAR}-${CI_JOB_NAME}',
+ result: 'key-test-1',
+ keep_undefined: false
+ },
+ "missing variable keeping original": {
+ value: 'key${MISSING_VAR}-${CI_JOB_NAME}',
+ result: 'key${MISSING_VAR}-test-1',
+ keep_undefined: true
+ }
+ }
+ end
+
+ with_them do
+ subject { collection.expand_value(value, keep_undefined: keep_undefined) }
+
+ it 'matches expected expansion' do
+ is_expected.to eq(result)
+ end
+ end
+ end
+ end
+
+ describe '#sort_and_expand_all' do
+ context 'when FF :variable_inside_variable is disabled' do
+ let_it_be(:project_with_flag_disabled) { create(:project) }
+ let_it_be(:project_with_flag_enabled) { create(:project) }
+
+ before do
+ stub_feature_flags(variable_inside_variable: [project_with_flag_enabled])
+ end
+
+ context 'table tests' do
+ using RSpec::Parameterized::TableSyntax
+
+ where do
+ {
+ "empty array": {
+ variables: [],
+ keep_undefined: false
+ },
+ "simple expansions": {
+ variables: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'result' },
+ { key: 'variable3', value: 'key$variable$variable2' }
+ ],
+ keep_undefined: false
+ },
+ "complex expansion": {
+ variables: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'key${variable}' }
+ ],
+ keep_undefined: false
+ },
+ "out-of-order variable reference": {
+ variables: [
+ { key: 'variable2', value: 'key${variable}' },
+ { key: 'variable', value: 'value' }
+ ],
+ keep_undefined: false
+ },
+ "complex expansions with raw variable": {
+ variables: [
+ { key: 'variable3', value: 'key_${variable}_${variable2}' },
+ { key: 'variable', value: '$variable2', raw: true },
+ { key: 'variable2', value: 'value2' }
+ ],
+ keep_undefined: false
+ },
+ "array with cyclic dependency": {
+ variables: [
+ { key: 'variable', value: '$variable2' },
+ { key: 'variable2', value: '$variable3' },
+ { key: 'variable3', value: 'key$variable$variable2' }
+ ],
+ keep_undefined: true
+ }
+ }
+ end
+
+ with_them do
+ let(:collection) { Gitlab::Ci::Variables::Collection.new(variables, keep_undefined: keep_undefined) }
+
+ subject { collection.sort_and_expand_all(project_with_flag_disabled) }
+
+ it 'returns Collection' do
+ is_expected.to be_an_instance_of(Gitlab::Ci::Variables::Collection)
+ end
+
+ it 'does not expand variables' do
+ var_hash = variables.pluck(:key, :value).to_h
+ expect(subject.to_hash).to eq(var_hash)
+ end
+ end
+ end
+ end
+
+ context 'when FF :variable_inside_variable is enabled' do
+ let_it_be(:project_with_flag_disabled) { create(:project) }
+ let_it_be(:project_with_flag_enabled) { create(:project) }
+
+ before do
+ stub_feature_flags(variable_inside_variable: [project_with_flag_enabled])
+ end
+
+ context 'table tests' do
+ using RSpec::Parameterized::TableSyntax
+
+ where do
+ {
+ "empty array": {
+ variables: [],
+ keep_undefined: false,
+ result: []
+ },
+ "simple expansions": {
+ variables: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'result' },
+ { key: 'variable3', value: 'key$variable$variable2' },
+ { key: 'variable4', value: 'key$variable$variable3' }
+ ],
+ keep_undefined: false,
+ result: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'result' },
+ { key: 'variable3', value: 'keyvalueresult' },
+ { key: 'variable4', value: 'keyvaluekeyvalueresult' }
+ ]
+ },
+ "complex expansion": {
+ variables: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'key${variable}' }
+ ],
+ keep_undefined: false,
+ result: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'keyvalue' }
+ ]
+ },
+ "unused variables": {
+ variables: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'result2' },
+ { key: 'variable3', value: 'result3' },
+ { key: 'variable4', value: 'key$variable$variable3' }
+ ],
+ keep_undefined: false,
+ result: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'result2' },
+ { key: 'variable3', value: 'result3' },
+ { key: 'variable4', value: 'keyvalueresult3' }
+ ]
+ },
+ "complex expansions": {
+ variables: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'result' },
+ { key: 'variable3', value: 'key${variable}${variable2}' }
+ ],
+ keep_undefined: false,
+ result: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'result' },
+ { key: 'variable3', value: 'keyvalueresult' }
+ ]
+ },
+ "out-of-order expansion": {
+ variables: [
+ { key: 'variable3', value: 'key$variable2$variable' },
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'result' }
+ ],
+ keep_undefined: false,
+ result: [
+ { key: 'variable2', value: 'result' },
+ { key: 'variable', value: 'value' },
+ { key: 'variable3', value: 'keyresultvalue' }
+ ]
+ },
+ "out-of-order complex expansion": {
+ variables: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'result' },
+ { key: 'variable3', value: 'key${variable2}${variable}' }
+ ],
+ keep_undefined: false,
+ result: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'result' },
+ { key: 'variable3', value: 'keyresultvalue' }
+ ]
+ },
+ "missing variable": {
+ variables: [
+ { key: 'variable2', value: 'key$variable' }
+ ],
+ keep_undefined: false,
+ result: [
+ { key: 'variable2', value: 'key' }
+ ]
+ },
+ "missing variable keeping original": {
+ variables: [
+ { key: 'variable2', value: 'key$variable' }
+ ],
+ keep_undefined: true,
+ result: [
+ { key: 'variable2', value: 'key$variable' }
+ ]
+ },
+ "complex expansions with missing variable keeping original": {
+ variables: [
+ { key: 'variable4', value: 'key${variable}${variable2}${variable3}' },
+ { key: 'variable', value: 'value' },
+ { key: 'variable3', value: 'value3' }
+ ],
+ keep_undefined: true,
+ result: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable3', value: 'value3' },
+ { key: 'variable4', value: 'keyvalue${variable2}value3' }
+ ]
+ },
+ "complex expansions with raw variable": {
+ variables: [
+ { key: 'variable3', value: 'key_${variable}_${variable2}' },
+ { key: 'variable', value: '$variable2', raw: true },
+ { key: 'variable2', value: 'value2' }
+ ],
+ keep_undefined: false,
+ result: [
+ { key: 'variable', value: '$variable2', raw: true },
+ { key: 'variable2', value: 'value2' },
+ { key: 'variable3', value: 'key_$variable2_value2' }
+ ]
+ },
+ "cyclic dependency causes original array to be returned": {
+ variables: [
+ { key: 'variable', value: '$variable2' },
+ { key: 'variable2', value: '$variable3' },
+ { key: 'variable3', value: 'key$variable$variable2' }
+ ],
+ keep_undefined: false,
+ result: [
+ { key: 'variable', value: '$variable2' },
+ { key: 'variable2', value: '$variable3' },
+ { key: 'variable3', value: 'key$variable$variable2' }
+ ]
+ }
+ }
+ end
+
+ with_them do
+ let(:collection) { Gitlab::Ci::Variables::Collection.new(variables) }
+
+ subject { collection.sort_and_expand_all(project_with_flag_enabled, keep_undefined: keep_undefined) }
+
+ it 'returns Collection' do
+ is_expected.to be_an_instance_of(Gitlab::Ci::Variables::Collection)
+ end
+
+ it 'expands variables' do
+ var_hash = result.to_h { |env| [env.fetch(:key), env.fetch(:value)] }
+ .with_indifferent_access
+ expect(subject.to_hash).to eq(var_hash)
+ end
+
+ it 'preserves raw attribute' do
+ expect(subject.pluck(:key, :raw).to_h).to eq(collection.pluck(:key, :raw).to_h)
+ end
+ end
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb
index 9498453852a..5462a587d16 100644
--- a/spec/lib/gitlab/ci/yaml_processor_spec.rb
+++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb
@@ -1368,6 +1368,155 @@ module Gitlab
end
end
+ context 'with multiple_cache_per_job FF disabled' do
+ before do
+ stub_feature_flags(multiple_cache_per_job: false)
+ end
+ describe 'cache' do
+ context 'when cache definition has unknown keys' do
+ let(:config) do
+ YAML.dump(
+ { cache: { untracked: true, invalid: 'key' },
+ rspec: { script: 'rspec' } })
+ end
+
+ it_behaves_like 'returns errors', 'cache config contains unknown keys: invalid'
+ end
+
+ it "returns cache when defined globally" do
+ config = YAML.dump({
+ cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'key' },
+ rspec: {
+ script: "rspec"
+ }
+ })
+
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
+
+ expect(config_processor.stage_builds_attributes("test").size).to eq(1)
+ expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq(
+ paths: ["logs/", "binaries/"],
+ untracked: true,
+ key: 'key',
+ policy: 'pull-push',
+ when: 'on_success'
+ )
+ end
+
+ it "returns cache when defined in default context" do
+ config = YAML.dump(
+ {
+ default: {
+ cache: { paths: ["logs/", "binaries/"], untracked: true, key: { files: ['file'] } }
+ },
+ rspec: {
+ script: "rspec"
+ }
+ })
+
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
+
+ expect(config_processor.stage_builds_attributes("test").size).to eq(1)
+ expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq(
+ paths: ["logs/", "binaries/"],
+ untracked: true,
+ key: { files: ['file'] },
+ policy: 'pull-push',
+ when: 'on_success'
+ )
+ end
+
+ it 'returns cache key when defined in a job' do
+ config = YAML.dump({
+ rspec: {
+ cache: { paths: ['logs/', 'binaries/'], untracked: true, key: 'key' },
+ script: 'rspec'
+ }
+ })
+
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
+
+ expect(config_processor.stage_builds_attributes('test').size).to eq(1)
+ expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq(
+ paths: ['logs/', 'binaries/'],
+ untracked: true,
+ key: 'key',
+ policy: 'pull-push',
+ when: 'on_success'
+ )
+ end
+
+ it 'returns cache files' do
+ config = YAML.dump(
+ rspec: {
+ cache: {
+ paths: ['logs/', 'binaries/'],
+ untracked: true,
+ key: { files: ['file'] }
+ },
+ script: 'rspec'
+ }
+ )
+
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
+
+ expect(config_processor.stage_builds_attributes('test').size).to eq(1)
+ expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq(
+ paths: ['logs/', 'binaries/'],
+ untracked: true,
+ key: { files: ['file'] },
+ policy: 'pull-push',
+ when: 'on_success'
+ )
+ end
+
+ it 'returns cache files with prefix' do
+ config = YAML.dump(
+ rspec: {
+ cache: {
+ paths: ['logs/', 'binaries/'],
+ untracked: true,
+ key: { files: ['file'], prefix: 'prefix' }
+ },
+ script: 'rspec'
+ }
+ )
+
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
+
+ expect(config_processor.stage_builds_attributes('test').size).to eq(1)
+ expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq(
+ paths: ['logs/', 'binaries/'],
+ untracked: true,
+ key: { files: ['file'], prefix: 'prefix' },
+ policy: 'pull-push',
+ when: 'on_success'
+ )
+ end
+
+ it "overwrite cache when defined for a job and globally" do
+ config = YAML.dump({
+ cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'global' },
+ rspec: {
+ script: "rspec",
+ cache: { paths: ["test/"], untracked: false, key: 'local' }
+ }
+ })
+
+ config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
+
+ expect(config_processor.stage_builds_attributes("test").size).to eq(1)
+ expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq(
+ paths: ["test/"],
+ untracked: false,
+ key: 'local',
+ policy: 'pull-push',
+ when: 'on_success'
+ )
+ end
+ end
+ end
+
describe 'cache' do
context 'when cache definition has unknown keys' do
let(:config) do
@@ -1381,22 +1530,22 @@ module Gitlab
it "returns cache when defined globally" do
config = YAML.dump({
- cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'key' },
- rspec: {
- script: "rspec"
- }
- })
+ cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'key' },
+ rspec: {
+ script: "rspec"
+ }
+ })
config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes("test").size).to eq(1)
- expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq(
+ expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq([
paths: ["logs/", "binaries/"],
untracked: true,
key: 'key',
policy: 'pull-push',
when: 'on_success'
- )
+ ])
end
it "returns cache when defined in default context" do
@@ -1413,32 +1562,46 @@ module Gitlab
config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes("test").size).to eq(1)
- expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq(
+ expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq([
paths: ["logs/", "binaries/"],
untracked: true,
key: { files: ['file'] },
policy: 'pull-push',
when: 'on_success'
- )
+ ])
end
- it 'returns cache key when defined in a job' do
+ it 'returns cache key/s when defined in a job' do
config = YAML.dump({
- rspec: {
- cache: { paths: ['logs/', 'binaries/'], untracked: true, key: 'key' },
- script: 'rspec'
- }
- })
+ rspec: {
+ cache: [
+ { paths: ['binaries/'], untracked: true, key: 'keya' },
+ { paths: ['logs/', 'binaries/'], untracked: true, key: 'key' }
+ ],
+ script: 'rspec'
+ }
+ })
config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes('test').size).to eq(1)
expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq(
- paths: ['logs/', 'binaries/'],
- untracked: true,
- key: 'key',
- policy: 'pull-push',
- when: 'on_success'
+ [
+ {
+ paths: ['binaries/'],
+ untracked: true,
+ key: 'keya',
+ policy: 'pull-push',
+ when: 'on_success'
+ },
+ {
+ paths: ['logs/', 'binaries/'],
+ untracked: true,
+ key: 'key',
+ policy: 'pull-push',
+ when: 'on_success'
+ }
+ ]
)
end
@@ -1446,10 +1609,10 @@ module Gitlab
config = YAML.dump(
rspec: {
cache: {
- paths: ['logs/', 'binaries/'],
- untracked: true,
- key: { files: ['file'] }
- },
+ paths: ['binaries/'],
+ untracked: true,
+ key: { files: ['file'] }
+ },
script: 'rspec'
}
)
@@ -1457,13 +1620,13 @@ module Gitlab
config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes('test').size).to eq(1)
- expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq(
- paths: ['logs/', 'binaries/'],
+ expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq([
+ paths: ['binaries/'],
untracked: true,
key: { files: ['file'] },
policy: 'pull-push',
when: 'on_success'
- )
+ ])
end
it 'returns cache files with prefix' do
@@ -1481,34 +1644,34 @@ module Gitlab
config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes('test').size).to eq(1)
- expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq(
+ expect(config_processor.stage_builds_attributes('test').first[:cache]).to eq([
paths: ['logs/', 'binaries/'],
untracked: true,
key: { files: ['file'], prefix: 'prefix' },
policy: 'pull-push',
when: 'on_success'
- )
+ ])
end
it "overwrite cache when defined for a job and globally" do
config = YAML.dump({
- cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'global' },
- rspec: {
- script: "rspec",
- cache: { paths: ["test/"], untracked: false, key: 'local' }
- }
- })
+ cache: { paths: ["logs/", "binaries/"], untracked: true, key: 'global' },
+ rspec: {
+ script: "rspec",
+ cache: { paths: ["test/"], untracked: false, key: 'local' }
+ }
+ })
config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
expect(config_processor.stage_builds_attributes("test").size).to eq(1)
- expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq(
+ expect(config_processor.stage_builds_attributes("test").first[:cache]).to eq([
paths: ["test/"],
untracked: false,
key: 'local',
policy: 'pull-push',
when: 'on_success'
- )
+ ])
end
end
@@ -1926,8 +2089,8 @@ module Gitlab
only: { refs: %w[branches tags] },
options: { script: ["test"] },
needs_attributes: [
- { name: "build1", artifacts: true },
- { name: "build2", artifacts: true }
+ { name: "build1", artifacts: true, optional: false },
+ { name: "build2", artifacts: true, optional: false }
],
when: "on_success",
allow_failure: false,
@@ -1941,7 +2104,7 @@ module Gitlab
let(:needs) do
[
{ job: 'parallel', artifacts: false },
- { job: 'build1', artifacts: true },
+ { job: 'build1', artifacts: true, optional: true },
'build2'
]
end
@@ -1968,10 +2131,10 @@ module Gitlab
only: { refs: %w[branches tags] },
options: { script: ["test"] },
needs_attributes: [
- { name: "parallel 1/2", artifacts: false },
- { name: "parallel 2/2", artifacts: false },
- { name: "build1", artifacts: true },
- { name: "build2", artifacts: true }
+ { name: "parallel 1/2", artifacts: false, optional: false },
+ { name: "parallel 2/2", artifacts: false, optional: false },
+ { name: "build1", artifacts: true, optional: true },
+ { name: "build2", artifacts: true, optional: false }
],
when: "on_success",
allow_failure: false,
@@ -1993,8 +2156,8 @@ module Gitlab
only: { refs: %w[branches tags] },
options: { script: ["test"] },
needs_attributes: [
- { name: "parallel 1/2", artifacts: true },
- { name: "parallel 2/2", artifacts: true }
+ { name: "parallel 1/2", artifacts: true, optional: false },
+ { name: "parallel 2/2", artifacts: true, optional: false }
],
when: "on_success",
allow_failure: false,
@@ -2022,10 +2185,10 @@ module Gitlab
only: { refs: %w[branches tags] },
options: { script: ["test"] },
needs_attributes: [
- { name: "build1", artifacts: true },
- { name: "build2", artifacts: true },
- { name: "parallel 1/2", artifacts: true },
- { name: "parallel 2/2", artifacts: true }
+ { name: "build1", artifacts: true, optional: false },
+ { name: "build2", artifacts: true, optional: false },
+ { name: "parallel 1/2", artifacts: true, optional: false },
+ { name: "parallel 2/2", artifacts: true, optional: false }
],
when: "on_success",
allow_failure: false,
diff --git a/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb b/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb
index 76578340f7b..2cdf95ea101 100644
--- a/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb
@@ -230,34 +230,13 @@ RSpec.describe Gitlab::CycleAnalytics::StageSummary do
end
context 'when `from` and `to` are within a day' do
- context 'when query_deploymenys_via_finished_at_in_vsa feature flag is off' do
- before do
- stub_feature_flags(query_deploymenys_via_finished_at_in_vsa: false)
- end
-
- it 'returns the number of deployments made on that day' do
- freeze_time do
- create(:deployment, :success, project: project)
- options[:from] = options[:to] = Time.zone.now
-
- expect(subject).to eq('1')
- end
- end
- end
-
- context 'when query_deploymenys_via_finished_at_in_vsa feature flag is off' do
- before do
- stub_feature_flags(query_deploymenys_via_finished_at_in_vsa: true)
- end
-
- it 'returns the number of deployments made on that day' do
- freeze_time do
- create(:deployment, :success, project: project, finished_at: Time.zone.now)
- options[:from] = Time.zone.now.at_beginning_of_day
- options[:to] = Time.zone.now.at_end_of_day
+ it 'returns the number of deployments made on that day' do
+ freeze_time do
+ create(:deployment, :success, project: project, finished_at: Time.zone.now)
+ options[:from] = Time.zone.now.at_beginning_of_day
+ options[:to] = Time.zone.now.at_end_of_day
- expect(subject).to eq('1')
- end
+ expect(subject).to eq('1')
end
end
end
diff --git a/spec/lib/gitlab/data_builder/build_spec.rb b/spec/lib/gitlab/data_builder/build_spec.rb
index 4242469b3db..ab1728414bb 100644
--- a/spec/lib/gitlab/data_builder/build_spec.rb
+++ b/spec/lib/gitlab/data_builder/build_spec.rb
@@ -38,6 +38,7 @@ RSpec.describe Gitlab::DataBuilder::Build do
it { expect(data[:runner][:id]).to eq(build.runner.id) }
it { expect(data[:runner][:tags]).to match_array(tag_names) }
it { expect(data[:runner][:description]).to eq(build.runner.description) }
+ it { expect(data[:environment]).to be_nil }
context 'commit author_url' do
context 'when no commit present' do
@@ -63,6 +64,13 @@ RSpec.describe Gitlab::DataBuilder::Build do
expect(data[:commit][:author_url]).to eq(Gitlab::Routing.url_helpers.user_url(username: build.commit.author.username))
end
end
+
+ context 'with environment' do
+ let(:build) { create(:ci_build, :teardown_environment) }
+
+ it { expect(data[:environment][:name]).to eq(build.expanded_environment_name) }
+ it { expect(data[:environment][:action]).to eq(build.environment_action) }
+ end
end
end
end
diff --git a/spec/lib/gitlab/data_builder/pipeline_spec.rb b/spec/lib/gitlab/data_builder/pipeline_spec.rb
index fd7cadeb89e..cf04f560ceb 100644
--- a/spec/lib/gitlab/data_builder/pipeline_spec.rb
+++ b/spec/lib/gitlab/data_builder/pipeline_spec.rb
@@ -37,6 +37,7 @@ RSpec.describe Gitlab::DataBuilder::Pipeline do
expect(build_data[:id]).to eq(build.id)
expect(build_data[:status]).to eq(build.status)
expect(build_data[:allow_failure]).to eq(build.allow_failure)
+ expect(build_data[:environment]).to be_nil
expect(runner_data).to eq(nil)
expect(project_data).to eq(project.hook_attrs(backward: false))
expect(data[:merge_request]).to be_nil
@@ -115,5 +116,12 @@ RSpec.describe Gitlab::DataBuilder::Pipeline do
expect(build_data[:id]).to eq(build.id)
end
end
+
+ context 'build with environment' do
+ let!(:build) { create(:ci_build, :teardown_environment, pipeline: pipeline) }
+
+ it { expect(build_data[:environment][:name]).to eq(build.expanded_environment_name) }
+ it { expect(build_data[:environment][:action]).to eq(build.environment_action) }
+ 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
new file mode 100644
index 00000000000..1020aafcf08
--- /dev/null
+++ b/spec/lib/gitlab/database/background_migration/batched_job_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model do
+ it_behaves_like 'having unique enum values'
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:batched_migration).with_foreign_key(:batched_background_migration_id) }
+ end
+
+ describe 'delegated batched_migration attributes' do
+ let(:batched_job) { build(:batched_background_migration_job) }
+ let(:batched_migration) { batched_job.batched_migration }
+
+ describe '#migration_aborted?' do
+ before do
+ batched_migration.status = :aborted
+ end
+
+ it 'returns the migration aborted?' do
+ expect(batched_job.migration_aborted?).to eq(batched_migration.aborted?)
+ end
+ end
+
+ describe '#migration_job_class' do
+ it 'returns the migration job_class' do
+ expect(batched_job.migration_job_class).to eq(batched_migration.job_class)
+ end
+ end
+
+ describe '#migration_table_name' do
+ it 'returns the migration table_name' do
+ expect(batched_job.migration_table_name).to eq(batched_migration.table_name)
+ end
+ end
+
+ describe '#migration_column_name' do
+ it 'returns the migration column_name' do
+ expect(batched_job.migration_column_name).to eq(batched_migration.column_name)
+ end
+ end
+
+ describe '#migration_job_arguments' do
+ it 'returns the migration job_arguments' do
+ expect(batched_job.migration_job_arguments).to eq(batched_migration.job_arguments)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb
new file mode 100644
index 00000000000..f4a939e7c1f
--- /dev/null
+++ b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb
@@ -0,0 +1,160 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :model do
+ it_behaves_like 'having unique enum values'
+
+ describe 'associations' do
+ it { is_expected.to have_many(:batched_jobs).with_foreign_key(:batched_background_migration_id) }
+
+ describe '#last_job' do
+ let!(:batched_migration) { create(:batched_background_migration) }
+ let!(:batched_job1) { create(:batched_background_migration_job, batched_migration: batched_migration) }
+ let!(:batched_job2) { create(:batched_background_migration_job, batched_migration: batched_migration) }
+
+ it 'returns the most recent (in order of id) batched job' do
+ expect(batched_migration.last_job).to eq(batched_job2)
+ end
+ end
+ end
+
+ describe '.queue_order' do
+ let!(:migration1) { create(:batched_background_migration) }
+ let!(:migration2) { create(:batched_background_migration) }
+ let!(:migration3) { create(:batched_background_migration) }
+
+ it 'returns batched migrations ordered by their id' do
+ expect(described_class.queue_order.all).to eq([migration1, migration2, migration3])
+ end
+ end
+
+ describe '#interval_elapsed?' do
+ context 'when the migration has no last_job' do
+ let(:batched_migration) { build(:batched_background_migration) }
+
+ it 'returns true' do
+ expect(batched_migration.interval_elapsed?).to eq(true)
+ end
+ end
+
+ context 'when the migration has a last_job' do
+ let(:interval) { 2.minutes }
+ let(:batched_migration) { create(:batched_background_migration, interval: interval) }
+
+ context 'when the last_job is less than an interval old' do
+ it 'returns false' do
+ freeze_time do
+ create(:batched_background_migration_job,
+ batched_migration: batched_migration,
+ created_at: Time.current - 1.minute)
+
+ expect(batched_migration.interval_elapsed?).to eq(false)
+ end
+ end
+ end
+
+ context 'when the last_job is exactly an interval old' do
+ it 'returns true' do
+ freeze_time do
+ create(:batched_background_migration_job,
+ batched_migration: batched_migration,
+ created_at: Time.current - 2.minutes)
+
+ expect(batched_migration.interval_elapsed?).to eq(true)
+ end
+ end
+ end
+
+ context 'when the last_job is more than an interval old' do
+ it 'returns true' do
+ freeze_time do
+ create(:batched_background_migration_job,
+ batched_migration: batched_migration,
+ created_at: Time.current - 3.minutes)
+
+ expect(batched_migration.interval_elapsed?).to eq(true)
+ end
+ end
+ end
+ end
+ end
+
+ describe '#create_batched_job!' do
+ let(:batched_migration) { create(:batched_background_migration) }
+
+ it 'creates a batched_job with the correct batch configuration' do
+ batched_job = batched_migration.create_batched_job!(1, 5)
+
+ expect(batched_job).to have_attributes(
+ min_value: 1,
+ max_value: 5,
+ batch_size: batched_migration.batch_size,
+ sub_batch_size: batched_migration.sub_batch_size)
+ end
+ end
+
+ describe '#next_min_value' do
+ let!(:batched_migration) { create(:batched_background_migration) }
+
+ context 'when a previous job exists' do
+ let!(:batched_job) { create(:batched_background_migration_job, batched_migration: batched_migration) }
+
+ it 'returns the next value after the previous maximum' do
+ expect(batched_migration.next_min_value).to eq(batched_job.max_value + 1)
+ end
+ end
+
+ context 'when a previous job does not exist' do
+ it 'returns the migration minimum value' do
+ expect(batched_migration.next_min_value).to eq(batched_migration.min_value)
+ end
+ end
+ end
+
+ describe '#job_class' do
+ let(:job_class) { Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJob }
+ let(:batched_migration) { build(:batched_background_migration) }
+
+ it 'returns the class of the job for the migration' do
+ expect(batched_migration.job_class).to eq(job_class)
+ end
+ end
+
+ describe '#batch_class' do
+ let(:batch_class) { Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchingStrategy}
+ let(:batched_migration) { build(:batched_background_migration) }
+
+ it 'returns the class of the batch strategy for the migration' do
+ expect(batched_migration.batch_class).to eq(batch_class)
+ end
+ end
+
+ shared_examples_for 'an attr_writer that demodulizes assigned class names' do |attribute_name|
+ let(:batched_migration) { build(:batched_background_migration) }
+
+ context 'when a module name exists' do
+ it 'removes the module name' do
+ batched_migration.public_send(:"#{attribute_name}=", '::Foo::Bar')
+
+ expect(batched_migration[attribute_name]).to eq('Bar')
+ end
+ end
+
+ context 'when a module name does not exist' do
+ it 'does not change the given class name' do
+ batched_migration.public_send(:"#{attribute_name}=", 'Bar')
+
+ expect(batched_migration[attribute_name]).to eq('Bar')
+ end
+ end
+ end
+
+ describe '#job_class_name=' do
+ it_behaves_like 'an attr_writer that demodulizes assigned class names', :job_class_name
+ end
+
+ describe '#batch_class_name=' do
+ it_behaves_like 'an attr_writer that demodulizes assigned class names', :batch_class_name
+ end
+end
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
new file mode 100644
index 00000000000..17cceb35ff7
--- /dev/null
+++ b/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '#perform' do
+ let(:migration_wrapper) { described_class.new }
+ let(:job_class) { Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJob }
+
+ let_it_be(:active_migration) { create(:batched_background_migration, :active, job_arguments: [:id, :other_id]) }
+
+ let!(:job_record) { create(:batched_background_migration_job, batched_migration: active_migration) }
+
+ it 'runs the migration job' do
+ expect_next_instance_of(job_class) do |job_instance|
+ expect(job_instance).to receive(:perform).with(1, 10, 'events', 'id', 1, 'id', 'other_id')
+ end
+
+ migration_wrapper.perform(job_record)
+ end
+
+ it 'updates the the tracking record in the database' do
+ expect(job_record).to receive(:update!).with(hash_including(attempts: 1, status: :running)).and_call_original
+
+ freeze_time do
+ migration_wrapper.perform(job_record)
+
+ reloaded_job_record = job_record.reload
+
+ expect(reloaded_job_record).not_to be_pending
+ expect(reloaded_job_record.attempts).to eq(1)
+ expect(reloaded_job_record.started_at).to eq(Time.current)
+ end
+ end
+
+ context 'when the migration job does not raise an error' do
+ it 'marks the tracking record as succeeded' do
+ expect_next_instance_of(job_class) do |job_instance|
+ expect(job_instance).to receive(:perform).with(1, 10, 'events', 'id', 1, 'id', 'other_id')
+ end
+
+ freeze_time do
+ migration_wrapper.perform(job_record)
+
+ reloaded_job_record = job_record.reload
+
+ expect(reloaded_job_record).to be_succeeded
+ expect(reloaded_job_record.finished_at).to eq(Time.current)
+ end
+ end
+ end
+
+ context 'when the migration job raises an error' do
+ it 'marks the tracking record as failed before raising the error' do
+ expect_next_instance_of(job_class) do |job_instance|
+ expect(job_instance).to receive(:perform)
+ .with(1, 10, 'events', 'id', 1, 'id', 'other_id')
+ .and_raise(RuntimeError, 'Something broke!')
+ end
+
+ freeze_time do
+ expect { migration_wrapper.perform(job_record) }.to raise_error(RuntimeError, 'Something broke!')
+
+ reloaded_job_record = job_record.reload
+
+ expect(reloaded_job_record).to be_failed
+ expect(reloaded_job_record.finished_at).to eq(Time.current)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/background_migration/scheduler_spec.rb b/spec/lib/gitlab/database/background_migration/scheduler_spec.rb
new file mode 100644
index 00000000000..ba745acdf8a
--- /dev/null
+++ b/spec/lib/gitlab/database/background_migration/scheduler_spec.rb
@@ -0,0 +1,182 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::BackgroundMigration::Scheduler, '#perform' do
+ let(:scheduler) { described_class.new }
+
+ shared_examples_for 'it has no jobs to run' do
+ it 'does not create and run a migration job' do
+ test_wrapper = double('test wrapper')
+
+ expect(test_wrapper).not_to receive(:perform)
+
+ expect do
+ scheduler.perform(migration_wrapper: test_wrapper)
+ end.not_to change { Gitlab::Database::BackgroundMigration::BatchedJob.count }
+ end
+ end
+
+ context 'when there are no active migrations' do
+ let!(:migration) { create(:batched_background_migration, :finished) }
+
+ it_behaves_like 'it has no jobs to run'
+ end
+
+ shared_examples_for 'it has completed the migration' do
+ it 'marks the migration as finished' do
+ relation = Gitlab::Database::BackgroundMigration::BatchedMigration.finished.where(id: first_migration.id)
+
+ expect { scheduler.perform }.to change { relation.count }.by(1)
+ end
+ end
+
+ context 'when there are active migrations' do
+ let!(:first_migration) { create(:batched_background_migration, :active, batch_size: 2) }
+ let!(:last_migration) { create(:batched_background_migration, :active) }
+
+ let(:job_relation) do
+ Gitlab::Database::BackgroundMigration::BatchedJob.where(batched_background_migration_id: first_migration.id)
+ end
+
+ context 'when the migration interval has not elapsed' do
+ before do
+ expect_next_found_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigration) do |migration|
+ expect(migration).to receive(:interval_elapsed?).and_return(false)
+ end
+ end
+
+ it_behaves_like 'it has no jobs to run'
+ end
+
+ context 'when the interval has elapsed' do
+ before do
+ expect_next_found_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigration) do |migration|
+ expect(migration).to receive(:interval_elapsed?).and_return(true)
+ end
+ end
+
+ context 'when the first migration has no previous jobs' do
+ context 'when the migration has batches to process' do
+ let!(:event1) { create(:event) }
+ let!(:event2) { create(:event) }
+ let!(:event3) { create(:event) }
+
+ it 'runs the job for the first batch' do
+ first_migration.update!(min_value: event1.id, max_value: event3.id)
+
+ expect_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper) do |wrapper|
+ expect(wrapper).to receive(:perform).and_wrap_original do |_, job_record|
+ expect(job_record).to eq(job_relation.first)
+ end
+ end
+
+ expect { scheduler.perform }.to change { job_relation.count }.by(1)
+
+ expect(job_relation.first).to have_attributes(
+ min_value: event1.id,
+ max_value: event2.id,
+ batch_size: first_migration.batch_size,
+ sub_batch_size: first_migration.sub_batch_size)
+ end
+ end
+
+ context 'when the migration has no batches to process' do
+ it_behaves_like 'it has no jobs to run'
+ it_behaves_like 'it has completed the migration'
+ end
+ end
+
+ context 'when the first migration has previous jobs' do
+ let!(:event1) { create(:event) }
+ let!(:event2) { create(:event) }
+ let!(:event3) { create(:event) }
+
+ let!(:previous_job) do
+ create(:batched_background_migration_job,
+ batched_migration: first_migration,
+ min_value: event1.id,
+ max_value: event2.id,
+ batch_size: 2,
+ sub_batch_size: 1)
+ end
+
+ context 'when the migration is ready to process another job' do
+ it 'runs the migration job for the next batch' do
+ first_migration.update!(min_value: event1.id, max_value: event3.id)
+
+ expect_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper) do |wrapper|
+ expect(wrapper).to receive(:perform).and_wrap_original do |_, job_record|
+ expect(job_record).to eq(job_relation.last)
+ end
+ end
+
+ expect { scheduler.perform }.to change { job_relation.count }.by(1)
+
+ expect(job_relation.last).to have_attributes(
+ min_value: event3.id,
+ max_value: event3.id,
+ batch_size: first_migration.batch_size,
+ sub_batch_size: first_migration.sub_batch_size)
+ end
+ end
+
+ context 'when the migration has no batches remaining' do
+ let!(:final_job) do
+ create(:batched_background_migration_job,
+ batched_migration: first_migration,
+ min_value: event3.id,
+ max_value: event3.id,
+ batch_size: 2,
+ sub_batch_size: 1)
+ end
+
+ it_behaves_like 'it has no jobs to run'
+ it_behaves_like 'it has completed the migration'
+ end
+ end
+
+ context 'when the bounds of the next batch exceed the migration maximum value' do
+ let!(:events) { create_list(:event, 3) }
+ let(:event1) { events[0] }
+ let(:event2) { events[1] }
+
+ context 'when the batch maximum exceeds the migration maximum' do
+ it 'clamps the batch maximum to the migration maximum' do
+ first_migration.update!(batch_size: 5, min_value: event1.id, max_value: event2.id)
+
+ expect_next_instance_of(Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper) do |wrapper|
+ expect(wrapper).to receive(:perform)
+ end
+
+ expect { scheduler.perform }.to change { job_relation.count }.by(1)
+
+ expect(job_relation.first).to have_attributes(
+ min_value: event1.id,
+ max_value: event2.id,
+ batch_size: first_migration.batch_size,
+ sub_batch_size: first_migration.sub_batch_size)
+ end
+ end
+
+ context 'when the batch minimum exceeds the migration maximum' do
+ let!(:previous_job) do
+ create(:batched_background_migration_job,
+ batched_migration: first_migration,
+ min_value: event1.id,
+ max_value: event2.id,
+ batch_size: 5,
+ sub_batch_size: 1)
+ end
+
+ before do
+ first_migration.update!(batch_size: 5, min_value: 1, max_value: event2.id)
+ end
+
+ it_behaves_like 'it has no jobs to run'
+ it_behaves_like 'it has completed the migration'
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/bulk_update_spec.rb b/spec/lib/gitlab/database/bulk_update_spec.rb
index f2a7d6e69d8..dbafada26ca 100644
--- a/spec/lib/gitlab/database/bulk_update_spec.rb
+++ b/spec/lib/gitlab/database/bulk_update_spec.rb
@@ -13,8 +13,8 @@ RSpec.describe Gitlab::Database::BulkUpdate do
i_a, i_b = create_list(:issue, 2)
{
- i_a => { title: 'Issue a' },
- i_b => { title: 'Issue b' }
+ i_a => { title: 'Issue a' },
+ i_b => { title: 'Issue b' }
}
end
@@ -51,7 +51,7 @@ RSpec.describe Gitlab::Database::BulkUpdate do
it 'is possible to update all objects in a single query' do
users = create_list(:user, 3)
- mapping = users.zip(%w(foo bar baz)).to_h do |u, name|
+ mapping = users.zip(%w[foo bar baz]).to_h do |u, name|
[u, { username: name, admin: true }]
end
@@ -61,13 +61,13 @@ RSpec.describe Gitlab::Database::BulkUpdate do
# We have optimistically updated the values
expect(users).to all(be_admin)
- expect(users.map(&:username)).to eq(%w(foo bar baz))
+ expect(users.map(&:username)).to eq(%w[foo bar baz])
users.each(&:reset)
# The values are correct on reset
expect(users).to all(be_admin)
- expect(users.map(&:username)).to eq(%w(foo bar baz))
+ expect(users.map(&:username)).to eq(%w[foo bar baz])
end
it 'is possible to update heterogeneous sets' do
@@ -79,8 +79,8 @@ RSpec.describe Gitlab::Database::BulkUpdate do
mapping = {
mr_a => { title: 'MR a' },
- i_a => { title: 'Issue a' },
- i_b => { title: 'Issue b' }
+ i_a => { title: 'Issue a' },
+ i_b => { title: 'Issue b' }
}
expect do
@@ -99,8 +99,8 @@ RSpec.describe Gitlab::Database::BulkUpdate do
i_a, i_b = create_list(:issue, 2)
mapping = {
- i_a => { title: 'Issue a' },
- i_b => { title: 'Issue b' }
+ i_a => { title: 'Issue a' },
+ i_b => { title: 'Issue b' }
}
described_class.execute(%i[title], mapping)
@@ -113,23 +113,19 @@ RSpec.describe Gitlab::Database::BulkUpdate do
include_examples 'basic functionality'
context 'when prepared statements are configured differently to the normal test environment' do
- # rubocop: disable RSpec/LeakyConstantDeclaration
- # This cop is disabled because you cannot call establish_connection on
- # an anonymous class.
- class ActiveRecordBasePreparedStatementsInverted < ActiveRecord::Base
- def self.abstract_class?
- true # So it gets its own connection
+ before do
+ klass = Class.new(ActiveRecord::Base) do
+ def self.abstract_class?
+ true # So it gets its own connection
+ end
end
- end
- # rubocop: enable RSpec/LeakyConstantDeclaration
- before_all do
+ stub_const('ActiveRecordBasePreparedStatementsInverted', klass)
+
c = ActiveRecord::Base.connection.instance_variable_get(:@config)
inverted = c.merge(prepared_statements: !ActiveRecord::Base.connection.prepared_statements)
ActiveRecordBasePreparedStatementsInverted.establish_connection(inverted)
- end
- before do
allow(ActiveRecord::Base).to receive(:connection_specification_name)
.and_return(ActiveRecordBasePreparedStatementsInverted.connection_specification_name)
end
diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb
index 6de7fc3a50e..9178707a3d0 100644
--- a/spec/lib/gitlab/database/migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers_spec.rb
@@ -180,6 +180,32 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
end
+ context 'when with_lock_retries re-runs the block' do
+ it 'only creates constraint for unique definitions' do
+ expected_sql = <<~SQL
+ ALTER TABLE "#{table_name}"\nADD CONSTRAINT "check_cda6f69506" CHECK (char_length("name") <= 255)
+ SQL
+
+ expect(model).to receive(:create_table).twice.and_call_original
+
+ expect(model).to receive(:execute).with(expected_sql).and_raise(ActiveRecord::LockWaitTimeout)
+ expect(model).to receive(:execute).with(expected_sql).and_call_original
+
+ model.create_table_with_constraints table_name do |t|
+ t.timestamps_with_timezone
+ t.integer :some_id, null: false
+ t.boolean :active, null: false, default: true
+ t.text :name
+
+ t.text_limit :name, 255
+ end
+
+ expect_table_columns_to_match(column_attributes, table_name)
+
+ expect_check_constraint(table_name, 'check_cda6f69506', 'char_length(name) <= 255')
+ end
+ end
+
context 'when constraints are given invalid names' do
let(:expected_max_length) { described_class::MAX_IDENTIFIER_NAME_LENGTH }
let(:expected_error_message) { "The maximum allowed constraint name is #{expected_max_length} characters" }
@@ -1720,7 +1746,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
.with(
2.minutes,
'CopyColumnUsingBackgroundMigrationJob',
- [event.id, event.id, :events, :id, :id, 'id_convert_to_bigint', 100]
+ [event.id, event.id, :events, :id, 100, :id, 'id_convert_to_bigint']
)
expect(Gitlab::BackgroundMigration)
diff --git a/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb b/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb
index 3e8563376ce..e25e4af2e86 100644
--- a/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb
@@ -21,7 +21,7 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
context 'with enough rows to bulk queue jobs more than once' do
before do
- stub_const('Gitlab::Database::Migrations::BackgroundMigrationHelpers::BACKGROUND_MIGRATION_JOB_BUFFER_SIZE', 1)
+ stub_const('Gitlab::Database::Migrations::BackgroundMigrationHelpers::JOB_BUFFER_SIZE', 1)
end
it 'queues jobs correctly' do
@@ -262,6 +262,120 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
end
end
+ describe '#queue_batched_background_migration' do
+ it 'creates the database record for the migration' do
+ expect do
+ model.queue_batched_background_migration(
+ 'MyJobClass',
+ :projects,
+ :id,
+ job_interval: 5.minutes,
+ batch_min_value: 5,
+ batch_max_value: 1000,
+ batch_class_name: 'MyBatchClass',
+ batch_size: 100,
+ sub_batch_size: 10)
+ end.to change { Gitlab::Database::BackgroundMigration::BatchedMigration.count }.by(1)
+
+ expect(Gitlab::Database::BackgroundMigration::BatchedMigration.last).to have_attributes(
+ job_class_name: 'MyJobClass',
+ table_name: 'projects',
+ column_name: 'id',
+ interval: 300,
+ min_value: 5,
+ max_value: 1000,
+ batch_class_name: 'MyBatchClass',
+ batch_size: 100,
+ sub_batch_size: 10,
+ job_arguments: %w[],
+ status: 'active')
+ end
+
+ context 'when the job interval is lower than the minimum' do
+ let(:minimum_delay) { described_class::BATCH_MIN_DELAY }
+
+ it 'sets the job interval to the minimum value' do
+ expect do
+ model.queue_batched_background_migration('MyJobClass', :events, :id, job_interval: minimum_delay - 1.minute)
+ end.to change { Gitlab::Database::BackgroundMigration::BatchedMigration.count }.by(1)
+
+ created_migration = Gitlab::Database::BackgroundMigration::BatchedMigration.last
+
+ expect(created_migration.interval).to eq(minimum_delay)
+ end
+ end
+
+ context 'when additional arguments are passed to the method' do
+ it 'saves the arguments on the database record' do
+ expect do
+ model.queue_batched_background_migration(
+ 'MyJobClass',
+ :projects,
+ :id,
+ 'my',
+ 'arguments',
+ job_interval: 5.minutes,
+ batch_max_value: 1000)
+ end.to change { Gitlab::Database::BackgroundMigration::BatchedMigration.count }.by(1)
+
+ expect(Gitlab::Database::BackgroundMigration::BatchedMigration.last).to have_attributes(
+ job_class_name: 'MyJobClass',
+ table_name: 'projects',
+ column_name: 'id',
+ interval: 300,
+ min_value: 1,
+ max_value: 1000,
+ job_arguments: %w[my arguments])
+ end
+ end
+
+ context 'when the max_value is not given' do
+ context 'when records exist in the database' do
+ let!(:event1) { create(:event) }
+ let!(:event2) { create(:event) }
+ let!(:event3) { create(:event) }
+
+ it 'creates the record with the current max value' do
+ expect do
+ model.queue_batched_background_migration('MyJobClass', :events, :id, job_interval: 5.minutes)
+ end.to change { Gitlab::Database::BackgroundMigration::BatchedMigration.count }.by(1)
+
+ created_migration = Gitlab::Database::BackgroundMigration::BatchedMigration.last
+
+ expect(created_migration.max_value).to eq(event3.id)
+ end
+
+ it 'creates the record with an active status' do
+ expect do
+ model.queue_batched_background_migration('MyJobClass', :events, :id, job_interval: 5.minutes)
+ end.to change { Gitlab::Database::BackgroundMigration::BatchedMigration.count }.by(1)
+
+ expect(Gitlab::Database::BackgroundMigration::BatchedMigration.last).to be_active
+ end
+ end
+
+ context 'when the database is empty' do
+ it 'sets the max value to the min value' do
+ expect do
+ model.queue_batched_background_migration('MyJobClass', :events, :id, job_interval: 5.minutes)
+ end.to change { Gitlab::Database::BackgroundMigration::BatchedMigration.count }.by(1)
+
+ created_migration = Gitlab::Database::BackgroundMigration::BatchedMigration.last
+
+ expect(created_migration.max_value).to eq(created_migration.min_value)
+ end
+
+ it 'creates the record with a finished status' do
+ expect do
+ model.queue_batched_background_migration('MyJobClass', :projects, :id, job_interval: 5.minutes)
+ end.to change { Gitlab::Database::BackgroundMigration::BatchedMigration.count }.by(1)
+
+ expect(Gitlab::Database::BackgroundMigration::BatchedMigration.last).to be_finished
+ end
+ end
+ end
+ end
+
describe '#migrate_async' do
it 'calls BackgroundMigrationWorker.perform_async' do
expect(BackgroundMigrationWorker).to receive(:perform_async).with("Class", "hello", "world")
diff --git a/spec/lib/gitlab/database/migrations/observers/query_statistics_spec.rb b/spec/lib/gitlab/database/migrations/observers/query_statistics_spec.rb
new file mode 100644
index 00000000000..a3b03050b33
--- /dev/null
+++ b/spec/lib/gitlab/database/migrations/observers/query_statistics_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Migrations::Observers::QueryStatistics do
+ subject { described_class.new }
+
+ let(:connection) { ActiveRecord::Base.connection }
+
+ def mock_pgss(enabled: true)
+ if enabled
+ allow(subject).to receive(:function_exists?).with(:pg_stat_statements_reset).and_return(true)
+ allow(connection).to receive(:view_exists?).with(:pg_stat_statements).and_return(true)
+ else
+ allow(subject).to receive(:function_exists?).with(:pg_stat_statements_reset).and_return(false)
+ allow(connection).to receive(:view_exists?).with(:pg_stat_statements).and_return(false)
+ end
+ end
+
+ describe '#before' do
+ context 'with pgss available' do
+ it 'resets pg_stat_statements' do
+ mock_pgss(enabled: true)
+ expect(connection).to receive(:execute).with('select pg_stat_statements_reset()').once
+
+ subject.before
+ end
+ end
+
+ context 'without pgss available' do
+ it 'executes nothing' do
+ mock_pgss(enabled: false)
+ expect(connection).not_to receive(:execute)
+
+ subject.before
+ end
+ end
+ end
+
+ describe '#record' do
+ let(:observation) { Gitlab::Database::Migrations::Observation.new }
+ let(:result) { double }
+ let(:pgss_query) do
+ <<~SQL
+ SELECT query, calls, total_time, max_time, mean_time, rows
+ FROM pg_stat_statements
+ ORDER BY total_time DESC
+ SQL
+ end
+
+ context 'with pgss available' do
+ it 'fetches data from pg_stat_statements and stores on the observation' do
+ mock_pgss(enabled: true)
+ expect(connection).to receive(:execute).with(pgss_query).once.and_return(result)
+
+ expect { subject.record(observation) }.to change { observation.query_statistics }.from(nil).to(result)
+ end
+ end
+
+ context 'without pgss available' do
+ it 'executes nothing' do
+ mock_pgss(enabled: false)
+ expect(connection).not_to receive(:execute)
+
+ expect { subject.record(observation) }.not_to change { observation.query_statistics }
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb
index 76b1be1e497..757da2d9092 100644
--- a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb
+++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb
@@ -81,7 +81,7 @@ RSpec.describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase, :
end
describe '#rename_path_for_routable' do
- context 'for namespaces' do
+ context 'for personal namespaces' do
let(:namespace) { create(:namespace, path: 'the-path') }
it "renames namespaces called the-path" do
@@ -119,13 +119,16 @@ RSpec.describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase, :
expect(project.route.reload.path).to eq('the-path-but-not-really/the-project')
end
+ end
- context "the-path namespace -> subgroup -> the-path0 project" do
+ context 'for groups' do
+ context "the-path group -> subgroup -> the-path0 project" do
it "updates the route of the project correctly" do
- subgroup = create(:group, path: "subgroup", parent: namespace)
+ group = create(:group, path: 'the-path')
+ subgroup = create(:group, path: "subgroup", parent: group)
project = create(:project, :repository, path: "the-path0", namespace: subgroup)
- subject.rename_path_for_routable(migration_namespace(namespace))
+ subject.rename_path_for_routable(migration_namespace(group))
expect(project.route.reload.path).to eq("the-path0/subgroup/the-path0")
end
@@ -158,23 +161,27 @@ RSpec.describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase, :
end
describe '#perform_rename' do
- describe 'for namespaces' do
- let(:namespace) { create(:namespace, path: 'the-path') }
-
+ context 'for personal namespaces' do
it 'renames the path' do
+ namespace = create(:namespace, path: 'the-path')
+
subject.perform_rename(migration_namespace(namespace), 'the-path', 'renamed')
expect(namespace.reload.path).to eq('renamed')
+ expect(namespace.reload.route.path).to eq('renamed')
end
+ end
- it 'renames all the routes for the namespace' do
- child = create(:group, path: 'child', parent: namespace)
+ context 'for groups' do
+ it 'renames all the routes for the group' do
+ group = create(:group, path: 'the-path')
+ child = create(:group, path: 'child', parent: group)
project = create(:project, :repository, namespace: child, path: 'the-project')
- other_one = create(:namespace, path: 'the-path-is-similar')
+ other_one = create(:group, path: 'the-path-is-similar')
- subject.perform_rename(migration_namespace(namespace), 'the-path', 'renamed')
+ subject.perform_rename(migration_namespace(group), 'the-path', 'renamed')
- expect(namespace.reload.route.path).to eq('renamed')
+ expect(group.reload.route.path).to eq('renamed')
expect(child.reload.route.path).to eq('renamed/child')
expect(project.reload.route.path).to eq('renamed/child/the-project')
expect(other_one.reload.route.path).to eq('the-path-is-similar')
diff --git a/spec/lib/gitlab/database/similarity_score_spec.rb b/spec/lib/gitlab/database/similarity_score_spec.rb
index cf75e5a72d9..b7b66494390 100644
--- a/spec/lib/gitlab/database/similarity_score_spec.rb
+++ b/spec/lib/gitlab/database/similarity_score_spec.rb
@@ -71,7 +71,7 @@ RSpec.describe Gitlab::Database::SimilarityScore do
let(:search) { 'xyz' }
it 'results have 0 similarity score' do
- expect(query_result.map { |row| row['similarity'] }).to all(eq(0))
+ expect(query_result.map { |row| row['similarity'].to_f }).to all(eq(0))
end
end
end
diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb
index 3175040167b..1553a989dba 100644
--- a/spec/lib/gitlab/database_spec.rb
+++ b/spec/lib/gitlab/database_spec.rb
@@ -441,4 +441,112 @@ RSpec.describe Gitlab::Database do
end
end
end
+
+ describe 'ActiveRecordBaseTransactionMetrics' do
+ def subscribe_events
+ events = []
+
+ begin
+ subscriber = ActiveSupport::Notifications.subscribe('transaction.active_record') do |e|
+ events << e
+ end
+
+ yield
+ ensure
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
+ end
+
+ events
+ end
+
+ context 'without a transaction block' do
+ it 'does not publish a transaction event' do
+ events = subscribe_events do
+ User.first
+ end
+
+ expect(events).to be_empty
+ end
+ end
+
+ context 'within a transaction block' do
+ it 'publishes a transaction event' do
+ events = subscribe_events do
+ ActiveRecord::Base.transaction do
+ User.first
+ end
+ end
+
+ expect(events.length).to be(1)
+
+ event = events.first
+ expect(event).not_to be_nil
+ expect(event.duration).to be > 0.0
+ expect(event.payload).to a_hash_including(
+ connection: be_a(ActiveRecord::ConnectionAdapters::AbstractAdapter)
+ )
+ end
+ end
+
+ context 'within an empty transaction block' do
+ it 'publishes a transaction event' do
+ events = subscribe_events do
+ ActiveRecord::Base.transaction {}
+ end
+
+ expect(events.length).to be(1)
+
+ event = events.first
+ expect(event).not_to be_nil
+ expect(event.duration).to be > 0.0
+ expect(event.payload).to a_hash_including(
+ connection: be_a(ActiveRecord::ConnectionAdapters::AbstractAdapter)
+ )
+ end
+ end
+
+ context 'within a nested transaction block' do
+ it 'publishes multiple transaction events' do
+ events = subscribe_events do
+ ActiveRecord::Base.transaction do
+ ActiveRecord::Base.transaction do
+ ActiveRecord::Base.transaction do
+ User.first
+ end
+ end
+ end
+ end
+
+ expect(events.length).to be(3)
+
+ events.each do |event|
+ expect(event).not_to be_nil
+ expect(event.duration).to be > 0.0
+ expect(event.payload).to a_hash_including(
+ connection: be_a(ActiveRecord::ConnectionAdapters::AbstractAdapter)
+ )
+ end
+ end
+ end
+
+ context 'within a cancelled transaction block' do
+ it 'publishes multiple transaction events' do
+ events = subscribe_events do
+ ActiveRecord::Base.transaction do
+ User.first
+ raise ActiveRecord::Rollback
+ end
+ end
+
+ expect(events.length).to be(1)
+
+ event = events.first
+ expect(event).not_to be_nil
+ expect(event.duration).to be > 0.0
+ expect(event.payload).to a_hash_including(
+ connection: be_a(ActiveRecord::ConnectionAdapters::AbstractAdapter)
+ )
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/diff/highlight_cache_spec.rb b/spec/lib/gitlab/diff/highlight_cache_spec.rb
index 94717152488..d26bc5fc9a8 100644
--- a/spec/lib/gitlab/diff/highlight_cache_spec.rb
+++ b/spec/lib/gitlab/diff/highlight_cache_spec.rb
@@ -237,17 +237,17 @@ RSpec.describe Gitlab::Diff::HighlightCache, :clean_gitlab_redis_cache do
describe '#key' do
subject { cache.key }
- it 'returns the next version of the cache' do
- is_expected.to start_with("highlighted-diff-files:#{cache.diffable.cache_key}:2")
+ it 'returns cache key' do
+ is_expected.to eq("highlighted-diff-files:#{cache.diffable.cache_key}:2:#{cache.diff_options}:true")
end
context 'when feature flag is disabled' do
before do
- stub_feature_flags(improved_merge_diff_highlighting: false)
+ stub_feature_flags(introduce_marker_ranges: false)
end
it 'returns the original version of the cache' do
- is_expected.to start_with("highlighted-diff-files:#{cache.diffable.cache_key}:1")
+ is_expected.to eq("highlighted-diff-files:#{cache.diffable.cache_key}:2:#{cache.diff_options}:false")
end
end
end
diff --git a/spec/lib/gitlab/diff/highlight_spec.rb b/spec/lib/gitlab/diff/highlight_spec.rb
index 283437e7fbd..e613674af3a 100644
--- a/spec/lib/gitlab/diff/highlight_spec.rb
+++ b/spec/lib/gitlab/diff/highlight_spec.rb
@@ -50,11 +50,23 @@ RSpec.describe Gitlab::Diff::Highlight do
end
it 'highlights and marks added lines' do
- code = %Q{+<span id="LC9" class="line" lang="ruby"> <span class="k">raise</span> <span class="no"><span class="idiff left">RuntimeError</span></span><span class="p"><span class="idiff">,</span></span><span class="idiff right"> </span><span class="s2">"System commands must be given as an array of strings"</span></span>\n}
+ code = %Q{+<span id="LC9" class="line" lang="ruby"> <span class="k">raise</span> <span class="no"><span class="idiff left addition">RuntimeError</span></span><span class="p"><span class="idiff addition">,</span></span><span class="idiff right addition"> </span><span class="s2">"System commands must be given as an array of strings"</span></span>\n}
expect(subject[5].rich_text).to eq(code)
end
+ context 'when introduce_marker_ranges is false' do
+ before do
+ stub_feature_flags(introduce_marker_ranges: false)
+ end
+
+ it 'keeps the old bevavior (without mode classes)' do
+ code = %Q{+<span id="LC9" class="line" lang="ruby"> <span class="k">raise</span> <span class="no"><span class="idiff left">RuntimeError</span></span><span class="p"><span class="idiff">,</span></span><span class="idiff right"> </span><span class="s2">"System commands must be given as an array of strings"</span></span>\n}
+
+ expect(subject[5].rich_text).to eq(code)
+ end
+ end
+
context 'when no diff_refs' do
before do
allow(diff_file).to receive(:diff_refs).and_return(nil)
@@ -93,7 +105,7 @@ RSpec.describe Gitlab::Diff::Highlight do
end
it 'marks added lines' do
- code = %q{+ raise <span class="idiff left right">RuntimeError, </span>&quot;System commands must be given as an array of strings&quot;}
+ code = %q{+ raise <span class="idiff left right addition">RuntimeError, </span>&quot;System commands must be given as an array of strings&quot;}
expect(subject[5].rich_text).to eq(code)
expect(subject[5].rich_text).to be_html_safe
diff --git a/spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb b/spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb
index 60f7f3a103f..3670074cc21 100644
--- a/spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb
+++ b/spec/lib/gitlab/diff/inline_diff_markdown_marker_spec.rb
@@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe Gitlab::Diff::InlineDiffMarkdownMarker do
describe '#mark' do
let(:raw) { "abc 'def'" }
- let(:inline_diffs) { [2..5] }
- let(:subject) { described_class.new(raw).mark(inline_diffs, mode: :deletion) }
+ let(:inline_diffs) { [Gitlab::MarkerRange.new(2, 5, mode: Gitlab::MarkerRange::DELETION)] }
+ let(:subject) { described_class.new(raw).mark(inline_diffs) }
it 'does not escape html etities and marks the range' do
expect(subject).to eq("ab{-c 'd-}ef'")
diff --git a/spec/lib/gitlab/diff/inline_diff_spec.rb b/spec/lib/gitlab/diff/inline_diff_spec.rb
index dce655d5690..714b5d813c4 100644
--- a/spec/lib/gitlab/diff/inline_diff_spec.rb
+++ b/spec/lib/gitlab/diff/inline_diff_spec.rb
@@ -52,17 +52,6 @@ RSpec.describe Gitlab::Diff::InlineDiff do
expect(subject[0]).to eq([3..6])
expect(subject[1]).to eq([3..3, 17..22])
end
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(improved_merge_diff_highlighting: false)
- end
-
- it 'finds all inline diffs' do
- expect(subject[0]).to eq([3..19])
- expect(subject[1]).to eq([3..22])
- end
- end
end
end
diff --git a/spec/lib/gitlab/diff/pair_selector_spec.rb b/spec/lib/gitlab/diff/pair_selector_spec.rb
new file mode 100644
index 00000000000..da5707bc377
--- /dev/null
+++ b/spec/lib/gitlab/diff/pair_selector_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Gitlab::Diff::PairSelector do
+ subject(:selector) { described_class.new(lines) }
+
+ describe '#to_a' do
+ subject { selector.to_a }
+
+ let(:lines) { diff.lines }
+
+ let(:diff) do
+ <<-EOF.strip_heredoc
+ class Test # 0
+ - def initialize(test = true) # 1
+ + def initialize(test = false) # 2
+ @test = test # 3
+ - if true # 4
+ - @foo = "bar" # 5
+ + unless false # 6
+ + @foo = "baz" # 7
+ end
+ end
+ end
+ EOF
+ end
+
+ it 'finds all pairs' do
+ is_expected.to match_array([[1, 2], [4, 6], [5, 7]])
+ end
+
+ context 'when there are empty lines' do
+ let(:lines) { ['- bar', '+ baz', ''] }
+
+ it { expect { subject }.not_to raise_error }
+ end
+
+ context 'when there are only removals' do
+ let(:diff) do
+ <<-EOF.strip_heredoc
+ - class Test
+ - def initialize(test = true)
+ - end
+ - end
+ EOF
+ end
+
+ it 'returns empty collection' do
+ is_expected.to eq([])
+ end
+ end
+
+ context 'when there are only additions' do
+ let(:diff) do
+ <<-EOF.strip_heredoc
+ + class Test
+ + def initialize(test = true)
+ + end
+ + end
+ EOF
+ end
+
+ it 'returns empty collection' do
+ is_expected.to eq([])
+ end
+ end
+
+ context 'when there are no changes' do
+ let(:diff) do
+ <<-EOF.strip_heredoc
+ class Test
+ def initialize(test = true)
+ end
+ end
+ EOF
+ end
+
+ it 'returns empty collection' do
+ is_expected.to eq([])
+ end
+ end
+ end
+end
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 eb11c051adc..7436765e8ee 100644
--- a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb
@@ -36,7 +36,7 @@ RSpec.describe Gitlab::Email::Handler::ServiceDeskHandler do
expect(new_issue.author).to eql(User.support_bot)
expect(new_issue.confidential?).to be true
expect(new_issue.all_references.all).to be_empty
- expect(new_issue.title).to eq("Service Desk (from jake@adventuretime.ooo): The message subject! @all")
+ expect(new_issue.title).to eq("The message subject! @all")
expect(new_issue.description).to eq(expected_description.strip)
end
diff --git a/spec/lib/gitlab/error_tracking/context_payload_generator_spec.rb b/spec/lib/gitlab/error_tracking/context_payload_generator_spec.rb
new file mode 100644
index 00000000000..0e72dd7ec5e
--- /dev/null
+++ b/spec/lib/gitlab/error_tracking/context_payload_generator_spec.rb
@@ -0,0 +1,176 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'rspec-parameterized'
+
+RSpec.describe Gitlab::ErrorTracking::ContextPayloadGenerator do
+ subject(:generator) { described_class.new }
+
+ let(:extra) do
+ {
+ some_other_info: 'info',
+ issue_url: 'http://gitlab.com/gitlab-org/gitlab-foss/-/issues/1'
+ }
+ end
+
+ let(:exception) { StandardError.new("Dummy exception") }
+
+ before do
+ allow(Labkit::Correlation::CorrelationId).to receive(:current_id).and_return('cid')
+ allow(I18n).to receive(:locale).and_return('en')
+ end
+
+ context 'user metadata' do
+ let(:user) { create(:user) }
+
+ it 'appends user metadata to the payload' do
+ payload = {}
+
+ Gitlab::ApplicationContext.with_context(user: user) do
+ payload = generator.generate(exception, extra)
+ end
+
+ expect(payload[:user]).to eql(
+ username: user.username
+ )
+ end
+ end
+
+ context 'tags metadata' do
+ context 'when the GITLAB_SENTRY_EXTRA_TAGS env is not set' do
+ before do
+ stub_env('GITLAB_SENTRY_EXTRA_TAGS', nil)
+ end
+
+ it 'does not log into AppLogger' do
+ expect(Gitlab::AppLogger).not_to receive(:debug)
+
+ generator.generate(exception, extra)
+ end
+
+ it 'does not send any extra tags' do
+ payload = {}
+
+ Gitlab::ApplicationContext.with_context(feature_category: 'feature_a') do
+ payload = generator.generate(exception, extra)
+ end
+
+ expect(payload[:tags]).to eql(
+ correlation_id: 'cid',
+ locale: 'en',
+ program: 'test',
+ feature_category: 'feature_a'
+ )
+ end
+ end
+
+ context 'when the GITLAB_SENTRY_EXTRA_TAGS env is a JSON hash' do
+ it 'includes those tags in all events' do
+ stub_env('GITLAB_SENTRY_EXTRA_TAGS', { foo: 'bar', baz: 'quux' }.to_json)
+ payload = {}
+
+ Gitlab::ApplicationContext.with_context(feature_category: 'feature_a') do
+ payload = generator.generate(exception, extra)
+ end
+
+ expect(payload[:tags]).to eql(
+ correlation_id: 'cid',
+ locale: 'en',
+ program: 'test',
+ feature_category: 'feature_a',
+ 'foo' => 'bar',
+ 'baz' => 'quux'
+ )
+ end
+
+ it 'does not log into AppLogger' do
+ expect(Gitlab::AppLogger).not_to receive(:debug)
+
+ generator.generate(exception, extra)
+ end
+ end
+
+ context 'when the GITLAB_SENTRY_EXTRA_TAGS env is not a JSON hash' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:env_var, :error) do
+ { foo: 'bar', baz: 'quux' }.inspect | 'JSON::ParserError'
+ [].to_json | 'NoMethodError'
+ [%w[foo bar]].to_json | 'NoMethodError'
+ %w[foo bar].to_json | 'NoMethodError'
+ '"string"' | 'NoMethodError'
+ end
+
+ with_them do
+ before do
+ stub_env('GITLAB_SENTRY_EXTRA_TAGS', env_var)
+ end
+
+ it 'logs into AppLogger' do
+ expect(Gitlab::AppLogger).to receive(:debug).with(a_string_matching(error))
+
+ generator.generate({})
+ end
+
+ it 'does not include any extra tags' do
+ payload = {}
+
+ Gitlab::ApplicationContext.with_context(feature_category: 'feature_a') do
+ payload = generator.generate(exception, extra)
+ end
+
+ expect(payload[:tags]).to eql(
+ correlation_id: 'cid',
+ locale: 'en',
+ program: 'test',
+ feature_category: 'feature_a'
+ )
+ end
+ end
+ end
+ end
+
+ context 'extra metadata' do
+ it 'appends extra metadata to the payload' do
+ payload = generator.generate(exception, extra)
+
+ expect(payload[:extra]).to eql(
+ some_other_info: 'info',
+ issue_url: 'http://gitlab.com/gitlab-org/gitlab-foss/-/issues/1'
+ )
+ end
+
+ it 'appends exception embedded extra metadata to the payload' do
+ allow(exception).to receive(:sentry_extra_data).and_return(
+ some_other_info: 'another_info',
+ mr_url: 'https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/1'
+ )
+
+ payload = generator.generate(exception, extra)
+
+ expect(payload[:extra]).to eql(
+ some_other_info: 'another_info',
+ issue_url: 'http://gitlab.com/gitlab-org/gitlab-foss/-/issues/1',
+ mr_url: 'https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/1'
+ )
+ end
+
+ it 'filters sensitive extra info' do
+ extra[:my_token] = '456'
+ allow(exception).to receive(:sentry_extra_data).and_return(
+ mr_url: 'https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/1',
+ another_token: '1234'
+ )
+
+ payload = generator.generate(exception, extra)
+
+ expect(payload[:extra]).to eql(
+ some_other_info: 'info',
+ issue_url: 'http://gitlab.com/gitlab-org/gitlab-foss/-/issues/1',
+ mr_url: 'https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/1',
+ my_token: '[FILTERED]',
+ another_token: '[FILTERED]'
+ )
+ end
+ end
+end
diff --git a/spec/lib/gitlab/error_tracking/log_formatter_spec.rb b/spec/lib/gitlab/error_tracking/log_formatter_spec.rb
new file mode 100644
index 00000000000..188ccd000a1
--- /dev/null
+++ b/spec/lib/gitlab/error_tracking/log_formatter_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Gitlab::ErrorTracking::LogFormatter do
+ let(:exception) { StandardError.new('boom') }
+ let(:context_payload) do
+ {
+ server: 'local-hostname-of-the-server',
+ user: {
+ ip_address: '127.0.0.1',
+ username: 'root'
+ },
+ tags: {
+ locale: 'en',
+ feature_category: 'category_a'
+ },
+ extra: {
+ some_other_info: 'other_info',
+ sidekiq: {
+ 'class' => 'HelloWorker',
+ 'args' => ['senstive string', 1, 2],
+ 'another_field' => 'field'
+ }
+ }
+ }
+ end
+
+ before do
+ Raven.context.user[:user_flag] = 'flag'
+ Raven.context.tags[:shard] = 'catchall'
+ Raven.context.extra[:some_info] = 'info'
+
+ allow(exception).to receive(:backtrace).and_return(
+ [
+ 'lib/gitlab/file_a.rb:1',
+ 'lib/gitlab/file_b.rb:2'
+ ]
+ )
+ end
+
+ after do
+ ::Raven::Context.clear!
+ end
+
+ it 'appends error-related log fields and filters sensitive Sidekiq arguments' do
+ payload = described_class.new.generate_log(exception, context_payload)
+
+ expect(payload).to eql(
+ 'exception.class' => 'StandardError',
+ 'exception.message' => 'boom',
+ 'exception.backtrace' => [
+ 'lib/gitlab/file_a.rb:1',
+ 'lib/gitlab/file_b.rb:2'
+ ],
+ 'user.ip_address' => '127.0.0.1',
+ 'user.username' => 'root',
+ 'user.user_flag' => 'flag',
+ 'tags.locale' => 'en',
+ 'tags.feature_category' => 'category_a',
+ 'tags.shard' => 'catchall',
+ 'extra.some_other_info' => 'other_info',
+ 'extra.some_info' => 'info',
+ "extra.sidekiq" => {
+ "another_field" => "field",
+ "args" => ["[FILTERED]", "1", "2"],
+ "class" => "HelloWorker"
+ }
+ )
+ end
+end
diff --git a/spec/lib/gitlab/error_tracking/processor/context_payload_processor_spec.rb b/spec/lib/gitlab/error_tracking/processor/context_payload_processor_spec.rb
new file mode 100644
index 00000000000..0db40eca989
--- /dev/null
+++ b/spec/lib/gitlab/error_tracking/processor/context_payload_processor_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Gitlab::ErrorTracking::Processor::ContextPayloadProcessor do
+ subject(:processor) { described_class.new }
+
+ before do
+ allow_next_instance_of(Gitlab::ErrorTracking::ContextPayloadGenerator) do |generator|
+ allow(generator).to receive(:generate).and_return(
+ user: { username: 'root' },
+ tags: { locale: 'en', program: 'test', feature_category: 'feature_a', correlation_id: 'cid' },
+ extra: { some_info: 'info' }
+ )
+ end
+ end
+
+ it 'merges the context payload into event payload' do
+ payload = {
+ user: { ip_address: '127.0.0.1' },
+ tags: { priority: 'high' },
+ extra: { sidekiq: { class: 'SomeWorker', args: ['[FILTERED]', 1, 2] } }
+ }
+
+ processor.process(payload)
+
+ expect(payload).to eql(
+ user: {
+ ip_address: '127.0.0.1',
+ username: 'root'
+ },
+ tags: {
+ priority: 'high',
+ locale: 'en',
+ program: 'test',
+ feature_category: 'feature_a',
+ correlation_id: 'cid'
+ },
+ extra: {
+ some_info: 'info',
+ sidekiq: { class: 'SomeWorker', args: ['[FILTERED]', 1, 2] }
+ }
+ )
+ end
+end
diff --git a/spec/lib/gitlab/error_tracking_spec.rb b/spec/lib/gitlab/error_tracking_spec.rb
index 764478ad1d7..a905b9f8d40 100644
--- a/spec/lib/gitlab/error_tracking_spec.rb
+++ b/spec/lib/gitlab/error_tracking_spec.rb
@@ -8,116 +8,55 @@ RSpec.describe Gitlab::ErrorTracking do
let(:exception) { RuntimeError.new('boom') }
let(:issue_url) { 'http://gitlab.com/gitlab-org/gitlab-foss/issues/1' }
- let(:expected_payload_includes) do
- [
- { 'exception.class' => 'RuntimeError' },
- { 'exception.message' => 'boom' },
- { 'tags.correlation_id' => 'cid' },
- { 'extra.some_other_info' => 'info' },
- { 'extra.issue_url' => 'http://gitlab.com/gitlab-org/gitlab-foss/issues/1' }
- ]
+ let(:user) { create(:user) }
+
+ let(:sentry_payload) do
+ {
+ tags: {
+ program: 'test',
+ locale: 'en',
+ feature_category: 'feature_a',
+ correlation_id: 'cid'
+ },
+ user: {
+ username: user.username
+ },
+ extra: {
+ some_other_info: 'info',
+ issue_url: 'http://gitlab.com/gitlab-org/gitlab-foss/issues/1'
+ }
+ }
end
- let(:sentry_event) { Gitlab::Json.parse(Raven.client.transport.events.last[1]) }
+ let(:logger_payload) do
+ {
+ 'exception.class' => 'RuntimeError',
+ 'exception.message' => 'boom',
+ 'tags.program' => 'test',
+ 'tags.locale' => 'en',
+ 'tags.feature_category' => 'feature_a',
+ 'tags.correlation_id' => 'cid',
+ 'user.username' => user.username,
+ 'extra.some_other_info' => 'info',
+ 'extra.issue_url' => 'http://gitlab.com/gitlab-org/gitlab-foss/issues/1'
+ }
+ end
before do
stub_sentry_settings
allow(described_class).to receive(:sentry_dsn).and_return(Gitlab.config.sentry.dsn)
allow(Labkit::Correlation::CorrelationId).to receive(:current_id).and_return('cid')
+ allow(I18n).to receive(:locale).and_return('en')
described_class.configure do |config|
config.encoding = 'json'
end
end
- describe '.configure' do
- context 'default tags from GITLAB_SENTRY_EXTRA_TAGS' do
- context 'when the value is a JSON hash' do
- it 'includes those tags in all events' do
- stub_env('GITLAB_SENTRY_EXTRA_TAGS', { foo: 'bar', baz: 'quux' }.to_json)
-
- described_class.configure do |config|
- config.encoding = 'json'
- end
-
- described_class.track_exception(StandardError.new)
-
- expect(sentry_event['tags'].except('correlation_id', 'locale', 'program'))
- .to eq('foo' => 'bar', 'baz' => 'quux')
- end
- end
-
- context 'when the value is not set' do
- before do
- stub_env('GITLAB_SENTRY_EXTRA_TAGS', nil)
- end
-
- it 'does not log an error' do
- expect(Gitlab::AppLogger).not_to receive(:debug)
-
- described_class.configure do |config|
- config.encoding = 'json'
- end
- end
-
- it 'does not send any extra tags' do
- described_class.configure do |config|
- config.encoding = 'json'
- end
-
- described_class.track_exception(StandardError.new)
-
- expect(sentry_event['tags'].keys).to contain_exactly('correlation_id', 'locale', 'program')
- end
- end
-
- context 'when the value is not a JSON hash' do
- using RSpec::Parameterized::TableSyntax
-
- where(:env_var, :error) do
- { foo: 'bar', baz: 'quux' }.inspect | 'JSON::ParserError'
- [].to_json | 'NoMethodError'
- [%w[foo bar]].to_json | 'NoMethodError'
- %w[foo bar].to_json | 'NoMethodError'
- '"string"' | 'NoMethodError'
- end
-
- with_them do
- before do
- stub_env('GITLAB_SENTRY_EXTRA_TAGS', env_var)
- end
-
- it 'does not include any extra tags' do
- described_class.configure do |config|
- config.encoding = 'json'
- end
-
- described_class.track_exception(StandardError.new)
-
- expect(sentry_event['tags'].except('correlation_id', 'locale', 'program'))
- .to be_empty
- end
-
- it 'logs the error class' do
- expect(Gitlab::AppLogger).to receive(:debug).with(a_string_matching(error))
-
- described_class.configure do |config|
- config.encoding = 'json'
- end
- end
- end
- end
- end
- end
-
- describe '.with_context' do
- it 'adds the expected tags' do
- described_class.with_context {}
-
- expect(Raven.tags_context[:locale].to_s).to eq(I18n.locale.to_s)
- expect(Raven.tags_context[Labkit::Correlation::CorrelationId::LOG_KEY.to_sym].to_s)
- .to eq('cid')
+ around do |example|
+ Gitlab::ApplicationContext.with_context(user: user, feature_category: 'feature_a') do
+ example.run
end
end
@@ -128,10 +67,15 @@ RSpec.describe Gitlab::ErrorTracking do
end
it 'raises the exception' do
- expect(Raven).to receive(:capture_exception)
-
- expect { described_class.track_and_raise_for_dev_exception(exception) }
- .to raise_error(RuntimeError)
+ expect(Raven).to receive(:capture_exception).with(exception, sentry_payload)
+
+ expect do
+ described_class.track_and_raise_for_dev_exception(
+ exception,
+ issue_url: issue_url,
+ some_other_info: 'info'
+ )
+ end.to raise_error(RuntimeError, /boom/)
end
end
@@ -141,19 +85,7 @@ RSpec.describe Gitlab::ErrorTracking do
end
it 'logs the exception with all attributes passed' do
- expected_extras = {
- some_other_info: 'info',
- issue_url: 'http://gitlab.com/gitlab-org/gitlab-foss/issues/1'
- }
-
- expected_tags = {
- correlation_id: 'cid'
- }
-
- expect(Raven).to receive(:capture_exception)
- .with(exception,
- tags: a_hash_including(expected_tags),
- extra: a_hash_including(expected_extras))
+ expect(Raven).to receive(:capture_exception).with(exception, sentry_payload)
described_class.track_and_raise_for_dev_exception(
exception,
@@ -163,8 +95,7 @@ RSpec.describe Gitlab::ErrorTracking do
end
it 'calls Gitlab::ErrorTracking::Logger.error with formatted payload' do
- expect(Gitlab::ErrorTracking::Logger).to receive(:error)
- .with(a_hash_including(*expected_payload_includes))
+ expect(Gitlab::ErrorTracking::Logger).to receive(:error).with(logger_payload)
described_class.track_and_raise_for_dev_exception(
exception,
@@ -177,15 +108,19 @@ RSpec.describe Gitlab::ErrorTracking do
describe '.track_and_raise_exception' do
it 'always raises the exception' do
- expect(Raven).to receive(:capture_exception)
+ expect(Raven).to receive(:capture_exception).with(exception, sentry_payload)
- expect { described_class.track_and_raise_exception(exception) }
- .to raise_error(RuntimeError)
+ expect do
+ described_class.track_and_raise_for_dev_exception(
+ exception,
+ issue_url: issue_url,
+ some_other_info: 'info'
+ )
+ end.to raise_error(RuntimeError, /boom/)
end
it 'calls Gitlab::ErrorTracking::Logger.error with formatted payload' do
- expect(Gitlab::ErrorTracking::Logger).to receive(:error)
- .with(a_hash_including(*expected_payload_includes))
+ expect(Gitlab::ErrorTracking::Logger).to receive(:error).with(logger_payload)
expect do
described_class.track_and_raise_exception(
@@ -210,17 +145,16 @@ RSpec.describe Gitlab::ErrorTracking do
it 'calls Raven.capture_exception' do
track_exception
- expect(Raven).to have_received(:capture_exception)
- .with(exception,
- tags: a_hash_including(correlation_id: 'cid'),
- extra: a_hash_including(some_other_info: 'info', issue_url: issue_url))
+ expect(Raven).to have_received(:capture_exception).with(
+ exception,
+ sentry_payload
+ )
end
it 'calls Gitlab::ErrorTracking::Logger.error with formatted payload' do
track_exception
- expect(Gitlab::ErrorTracking::Logger).to have_received(:error)
- .with(a_hash_including(*expected_payload_includes))
+ expect(Gitlab::ErrorTracking::Logger).to have_received(:error).with(logger_payload)
end
context 'with filterable parameters' do
@@ -229,8 +163,9 @@ RSpec.describe Gitlab::ErrorTracking do
it 'filters parameters' do
track_exception
- expect(Gitlab::ErrorTracking::Logger).to have_received(:error)
- .with(hash_including({ 'extra.test' => 1, 'extra.my_token' => '[FILTERED]' }))
+ expect(Gitlab::ErrorTracking::Logger).to have_received(:error).with(
+ hash_including({ 'extra.test' => 1, 'extra.my_token' => '[FILTERED]' })
+ )
end
end
@@ -241,8 +176,9 @@ RSpec.describe Gitlab::ErrorTracking do
it 'includes the extra data from the exception in the tracking information' do
track_exception
- expect(Raven).to have_received(:capture_exception)
- .with(exception, a_hash_including(extra: a_hash_including(extra_info)))
+ expect(Raven).to have_received(:capture_exception).with(
+ exception, a_hash_including(extra: a_hash_including(extra_info))
+ )
end
end
@@ -253,8 +189,9 @@ RSpec.describe Gitlab::ErrorTracking do
it 'just includes the other extra info' do
track_exception
- expect(Raven).to have_received(:capture_exception)
- .with(exception, a_hash_including(extra: a_hash_including(extra)))
+ expect(Raven).to have_received(:capture_exception).with(
+ exception, a_hash_including(extra: a_hash_including(extra))
+ )
end
end
@@ -266,7 +203,13 @@ RSpec.describe Gitlab::ErrorTracking do
track_exception
expect(Gitlab::ErrorTracking::Logger).to have_received(:error).with(
- hash_including({ 'extra.sidekiq' => { 'class' => 'PostReceive', 'args' => ['1', '{"id"=>2, "name"=>"hello"}', 'some-value', 'another-value'] } }))
+ hash_including(
+ 'extra.sidekiq' => {
+ 'class' => 'PostReceive',
+ 'args' => ['1', '{"id"=>2, "name"=>"hello"}', 'some-value', 'another-value']
+ }
+ )
+ )
end
end
@@ -276,9 +219,17 @@ RSpec.describe Gitlab::ErrorTracking do
it 'filters sensitive arguments before sending' do
track_exception
+ sentry_event = Gitlab::Json.parse(Raven.client.transport.events.last[1])
+
expect(sentry_event.dig('extra', 'sidekiq', 'args')).to eq(['[FILTERED]', 1, 2])
expect(Gitlab::ErrorTracking::Logger).to have_received(:error).with(
- hash_including('extra.sidekiq' => { 'class' => 'UnknownWorker', 'args' => ['[FILTERED]', '1', '2'] }))
+ hash_including(
+ 'extra.sidekiq' => {
+ 'class' => 'UnknownWorker',
+ 'args' => ['[FILTERED]', '1', '2']
+ }
+ )
+ )
end
end
end
diff --git a/spec/lib/gitlab/etag_caching/router/graphql_spec.rb b/spec/lib/gitlab/etag_caching/router/graphql_spec.rb
new file mode 100644
index 00000000000..d151dcba413
--- /dev/null
+++ b/spec/lib/gitlab/etag_caching/router/graphql_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::EtagCaching::Router::Graphql do
+ it 'matches pipelines endpoint' do
+ result = match_route('/api/graphql', 'pipelines/id/1')
+
+ expect(result).to be_present
+ expect(result.name).to eq 'pipelines_graph'
+ end
+
+ it 'has a valid feature category for every route', :aggregate_failures do
+ feature_categories = YAML.load_file(Rails.root.join('config', 'feature_categories.yml')).to_set
+
+ described_class::ROUTES.each do |route|
+ expect(feature_categories).to include(route.feature_category), "#{route.name} has a category of #{route.feature_category}, which is not valid"
+ end
+ end
+
+ def match_route(path, header)
+ described_class.match(
+ double(path_info: path,
+ headers: { 'X-GITLAB-GRAPHQL-RESOURCE-ETAG' => header }))
+ end
+
+ describe '.cache_key' do
+ let(:path) { '/api/graphql' }
+ let(:header_value) { 'pipelines/id/1' }
+ let(:headers) do
+ { 'X-GITLAB-GRAPHQL-RESOURCE-ETAG' => header_value }.compact
+ end
+
+ subject do
+ described_class.cache_key(double(path: path, headers: headers))
+ end
+
+ it 'uses request path and headers as cache key' do
+ is_expected.to eq '/api/graphql:pipelines/id/1'
+ end
+
+ context 'when the header is missing' do
+ let(:header_value) {}
+
+ it 'does not raise errors' do
+ is_expected.to eq '/api/graphql'
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/etag_caching/router/restful_spec.rb b/spec/lib/gitlab/etag_caching/router/restful_spec.rb
new file mode 100644
index 00000000000..877789b320f
--- /dev/null
+++ b/spec/lib/gitlab/etag_caching/router/restful_spec.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::EtagCaching::Router::Restful do
+ it 'matches issue notes endpoint' do
+ result = match_route('/my-group/and-subgroup/here-comes-the-project/noteable/issue/1/notes')
+
+ expect(result).to be_present
+ expect(result.name).to eq 'issue_notes'
+ end
+
+ it 'matches MR notes endpoint' do
+ result = match_route('/my-group/and-subgroup/here-comes-the-project/noteable/merge_request/1/notes')
+
+ expect(result).to be_present
+ expect(result.name).to eq 'merge_request_notes'
+ end
+
+ it 'matches issue title endpoint' do
+ result = match_route('/my-group/my-project/-/issues/123/realtime_changes')
+
+ expect(result).to be_present
+ expect(result.name).to eq 'issue_title'
+ end
+
+ it 'matches with a project name that includes a suffix of create' do
+ result = match_route('/group/test-create/-/issues/123/realtime_changes')
+
+ expect(result).to be_present
+ expect(result.name).to eq 'issue_title'
+ end
+
+ it 'matches with a project name that includes a prefix of create' do
+ result = match_route('/group/create-test/-/issues/123/realtime_changes')
+
+ expect(result).to be_present
+ expect(result.name).to eq 'issue_title'
+ end
+
+ it 'matches project pipelines endpoint' do
+ result = match_route('/my-group/my-project/-/pipelines.json')
+
+ expect(result).to be_present
+ expect(result.name).to eq 'project_pipelines'
+ end
+
+ it 'matches commit pipelines endpoint' do
+ result = match_route('/my-group/my-project/-/commit/aa8260d253a53f73f6c26c734c72fdd600f6e6d4/pipelines.json')
+
+ expect(result).to be_present
+ expect(result.name).to eq 'commit_pipelines'
+ end
+
+ it 'matches new merge request pipelines endpoint' do
+ result = match_route('/my-group/my-project/-/merge_requests/new.json')
+
+ expect(result).to be_present
+ expect(result.name).to eq 'new_merge_request_pipelines'
+ end
+
+ it 'matches merge request pipelines endpoint' do
+ result = match_route('/my-group/my-project/-/merge_requests/234/pipelines.json')
+
+ expect(result).to be_present
+ expect(result.name).to eq 'merge_request_pipelines'
+ end
+
+ it 'matches build endpoint' do
+ result = match_route('/my-group/my-project/builds/234.json')
+
+ expect(result).to be_present
+ expect(result.name).to eq 'project_build'
+ end
+
+ it 'does not match blob with confusing name' do
+ result = match_route('/my-group/my-project/-/blob/master/pipelines.json')
+
+ expect(result).to be_blank
+ end
+
+ it 'matches the cluster environments path' do
+ result = match_route('/my-group/my-project/-/clusters/47/environments')
+
+ expect(result).to be_present
+ expect(result.name).to eq 'cluster_environments'
+ end
+
+ it 'matches the environments path' do
+ result = match_route('/my-group/my-project/environments.json')
+
+ expect(result).to be_present
+ expect(result.name).to eq 'environments'
+ end
+
+ it 'matches pipeline#show endpoint' do
+ result = match_route('/my-group/my-project/-/pipelines/2.json')
+
+ expect(result).to be_present
+ expect(result.name).to eq 'project_pipeline'
+ end
+
+ it 'has a valid feature category for every route', :aggregate_failures do
+ feature_categories = YAML.load_file(Rails.root.join('config', 'feature_categories.yml')).to_set
+
+ described_class::ROUTES.each do |route|
+ expect(feature_categories).to include(route.feature_category), "#{route.name} has a category of #{route.feature_category}, which is not valid"
+ end
+ end
+
+ def match_route(path)
+ described_class.match(double(path_info: path))
+ end
+
+ describe '.cache_key' do
+ subject do
+ described_class.cache_key(double(path: '/my-group/my-project/builds/234.json'))
+ end
+
+ it 'uses request path as cache key' do
+ is_expected.to eq '/my-group/my-project/builds/234.json'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/etag_caching/router_spec.rb b/spec/lib/gitlab/etag_caching/router_spec.rb
index dbd9cc230f1..c748ee00721 100644
--- a/spec/lib/gitlab/etag_caching/router_spec.rb
+++ b/spec/lib/gitlab/etag_caching/router_spec.rb
@@ -3,136 +3,33 @@
require 'spec_helper'
RSpec.describe Gitlab::EtagCaching::Router do
- it 'matches issue notes endpoint' do
- result = described_class.match(
- '/my-group/and-subgroup/here-comes-the-project/noteable/issue/1/notes'
- )
-
- expect(result).to be_present
- expect(result.name).to eq 'issue_notes'
- end
-
- it 'matches MR notes endpoint' do
- result = described_class.match(
- '/my-group/and-subgroup/here-comes-the-project/noteable/merge_request/1/notes'
- )
-
- expect(result).to be_present
- expect(result.name).to eq 'merge_request_notes'
- end
-
- it 'matches issue title endpoint' do
- result = described_class.match(
- '/my-group/my-project/-/issues/123/realtime_changes'
- )
-
- expect(result).to be_present
- expect(result.name).to eq 'issue_title'
- end
-
- it 'matches with a project name that includes a suffix of create' do
- result = described_class.match(
- '/group/test-create/-/issues/123/realtime_changes'
- )
-
- expect(result).to be_present
- expect(result.name).to eq 'issue_title'
- end
-
- it 'matches with a project name that includes a prefix of create' do
- result = described_class.match(
- '/group/create-test/-/issues/123/realtime_changes'
- )
-
- expect(result).to be_present
- expect(result.name).to eq 'issue_title'
- end
-
- it 'matches project pipelines endpoint' do
- result = described_class.match(
- '/my-group/my-project/-/pipelines.json'
- )
-
- expect(result).to be_present
- expect(result.name).to eq 'project_pipelines'
- end
-
- it 'matches commit pipelines endpoint' do
- result = described_class.match(
- '/my-group/my-project/-/commit/aa8260d253a53f73f6c26c734c72fdd600f6e6d4/pipelines.json'
- )
-
- expect(result).to be_present
- expect(result.name).to eq 'commit_pipelines'
- end
-
- it 'matches new merge request pipelines endpoint' do
- result = described_class.match(
- '/my-group/my-project/-/merge_requests/new.json'
- )
-
- expect(result).to be_present
- expect(result.name).to eq 'new_merge_request_pipelines'
- end
-
- it 'matches merge request pipelines endpoint' do
- result = described_class.match(
- '/my-group/my-project/-/merge_requests/234/pipelines.json'
- )
-
- expect(result).to be_present
- expect(result.name).to eq 'merge_request_pipelines'
- end
-
- it 'matches build endpoint' do
- result = described_class.match(
- '/my-group/my-project/builds/234.json'
- )
-
- expect(result).to be_present
- expect(result.name).to eq 'project_build'
- end
-
- it 'does not match blob with confusing name' do
- result = described_class.match(
- '/my-group/my-project/-/blob/master/pipelines.json'
- )
-
- expect(result).to be_blank
- end
+ describe '.match', :aggregate_failures do
+ context 'with RESTful routes' do
+ it 'matches project pipelines endpoint' do
+ result = match_route('/my-group/my-project/-/pipelines.json')
+
+ expect(result).to be_present
+ expect(result.name).to eq 'project_pipelines'
+ expect(result.router).to eq Gitlab::EtagCaching::Router::Restful
+ end
+ end
- it 'matches the cluster environments path' do
- result = described_class.match(
- '/my-group/my-project/-/clusters/47/environments'
- )
+ context 'with GraphQL routes' do
+ it 'matches pipelines endpoint' do
+ result = match_route('/api/graphql', 'pipelines/id/12')
- expect(result).to be_present
- expect(result.name).to eq 'cluster_environments'
+ expect(result).to be_present
+ expect(result.name).to eq 'pipelines_graph'
+ expect(result.router).to eq Gitlab::EtagCaching::Router::Graphql
+ end
+ end
end
- it 'matches the environments path' do
- result = described_class.match(
- '/my-group/my-project/environments.json'
- )
+ def match_route(path, header = nil)
+ headers = { 'X-GITLAB-GRAPHQL-RESOURCE-ETAG' => header }.compact
- expect(result).to be_present
- expect(result.name).to eq 'environments'
- end
-
- it 'matches pipeline#show endpoint' do
- result = described_class.match(
- '/my-group/my-project/-/pipelines/2.json'
+ described_class.match(
+ double(path_info: path, headers: headers)
)
-
- expect(result).to be_present
- expect(result.name).to eq 'project_pipeline'
- end
-
- it 'has a valid feature category for every route', :aggregate_failures do
- feature_categories = YAML.load_file(Rails.root.join('config', 'feature_categories.yml')).to_set
-
- described_class::ROUTES.each do |route|
- expect(feature_categories).to include(route.feature_category), "#{route.name} has a category of #{route.feature_category}, which is not valid"
- end
end
end
diff --git a/spec/lib/gitlab/etag_caching/store_spec.rb b/spec/lib/gitlab/etag_caching/store_spec.rb
new file mode 100644
index 00000000000..46195e64715
--- /dev/null
+++ b/spec/lib/gitlab/etag_caching/store_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::EtagCaching::Store, :clean_gitlab_redis_shared_state do
+ let(:store) { described_class.new }
+
+ describe '#get' do
+ subject { store.get(key) }
+
+ context 'with invalid keys' do
+ let(:key) { 'a' }
+
+ it 'raises errors' do
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).and_call_original
+
+ expect { subject }.to raise_error Gitlab::EtagCaching::Store::InvalidKeyError
+ end
+
+ it 'does not raise errors in production' do
+ expect(store).to receive(:skip_validation?).and_return true
+ expect(Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception)
+
+ subject
+ end
+ end
+
+ context 'with GraphQL keys' do
+ let(:key) { '/api/graphql:pipelines/id/5' }
+
+ it 'returns a stored value' do
+ etag = store.touch(key)
+
+ is_expected.to eq(etag)
+ end
+ end
+
+ context 'with RESTful keys' do
+ let(:key) { '/my-group/my-project/builds/234.json' }
+
+ it 'returns a stored value' do
+ etag = store.touch(key)
+
+ is_expected.to eq(etag)
+ end
+ end
+ end
+
+ describe '#touch' do
+ subject { store.touch(key) }
+
+ context 'with invalid keys' do
+ let(:key) { 'a' }
+
+ it 'raises errors' do
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).and_call_original
+
+ expect { subject }.to raise_error Gitlab::EtagCaching::Store::InvalidKeyError
+ end
+ end
+
+ context 'with GraphQL keys' do
+ let(:key) { '/api/graphql:pipelines/id/5' }
+
+ it 'stores and returns a value' do
+ etag = store.touch(key)
+
+ expect(etag).to be_present
+ expect(store.get(key)).to eq(etag)
+ end
+ end
+
+ context 'with RESTful keys' do
+ let(:key) { '/my-group/my-project/builds/234.json' }
+
+ it 'stores and returns a value' do
+ etag = store.touch(key)
+
+ expect(etag).to be_present
+ expect(store.get(key)).to eq(etag)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/experimentation/controller_concern_spec.rb b/spec/lib/gitlab/experimentation/controller_concern_spec.rb
index 1cebe37bea5..3678aeb18b0 100644
--- a/spec/lib/gitlab/experimentation/controller_concern_spec.rb
+++ b/spec/lib/gitlab/experimentation/controller_concern_spec.rb
@@ -520,6 +520,78 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
end
end
+ describe '#record_experiment_group' do
+ let(:group) { 'a group object' }
+ let(:experiment_key) { :some_experiment_key }
+ let(:dnt_enabled) { false }
+ let(:experiment_active) { true }
+ let(:rollout_strategy) { :whatever }
+ let(:variant) { 'variant' }
+
+ before do
+ allow(controller).to receive(:dnt_enabled?).and_return(dnt_enabled)
+ allow(::Gitlab::Experimentation).to receive(:active?).and_return(experiment_active)
+ allow(::Gitlab::Experimentation).to receive(:rollout_strategy).and_return(rollout_strategy)
+ allow(controller).to receive(:tracking_group).and_return(variant)
+ allow(::Experiment).to receive(:add_group)
+ end
+
+ subject(:record_experiment_group) { controller.record_experiment_group(experiment_key, group) }
+
+ shared_examples 'exits early without recording' do
+ it 'returns early without recording the group as an ExperimentSubject' do
+ expect(::Experiment).not_to receive(:add_group)
+ record_experiment_group
+ end
+ end
+
+ shared_examples 'calls tracking_group' do |using_cookie_rollout|
+ it "calls tracking_group with #{using_cookie_rollout ? 'a nil' : 'the group as the'} subject" do
+ expect(controller).to receive(:tracking_group).with(experiment_key, nil, subject: using_cookie_rollout ? nil : group).and_return(variant)
+ record_experiment_group
+ end
+ end
+
+ shared_examples 'records the group' do
+ it 'records the group' do
+ expect(::Experiment).to receive(:add_group).with(experiment_key, group: group, variant: variant)
+ record_experiment_group
+ end
+ end
+
+ context 'when DNT is enabled' do
+ let(:dnt_enabled) { true }
+
+ include_examples 'exits early without recording'
+ end
+
+ context 'when the experiment is not active' do
+ let(:experiment_active) { false }
+
+ include_examples 'exits early without recording'
+ end
+
+ context 'when a nil group is given' do
+ let(:group) { nil }
+
+ include_examples 'exits early without recording'
+ end
+
+ context 'when the experiment uses a cookie-based rollout strategy' do
+ let(:rollout_strategy) { :cookie }
+
+ include_examples 'calls tracking_group', true
+ include_examples 'records the group'
+ end
+
+ context 'when the experiment uses a non-cookie-based rollout strategy' do
+ let(:rollout_strategy) { :group }
+
+ include_examples 'calls tracking_group', false
+ include_examples 'records the group'
+ end
+ end
+
describe '#record_experiment_conversion_event' do
let(:user) { build(:user) }
@@ -534,7 +606,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
end
it 'records the conversion event for the experiment & user' do
- expect(::Experiment).to receive(:record_conversion_event).with(:test_experiment, user)
+ expect(::Experiment).to receive(:record_conversion_event).with(:test_experiment, user, {})
record_conversion_event
end
diff --git a/spec/lib/gitlab/experimentation_spec.rb b/spec/lib/gitlab/experimentation_spec.rb
index 7eeae3f3f33..83c6b556fc6 100644
--- a/spec/lib/gitlab/experimentation_spec.rb
+++ b/spec/lib/gitlab/experimentation_spec.rb
@@ -7,14 +7,10 @@ require 'spec_helper'
RSpec.describe Gitlab::Experimentation::EXPERIMENTS do
it 'temporarily ensures we know what experiments exist for backwards compatibility' do
expected_experiment_keys = [
- :ci_notification_dot,
:upgrade_link_in_user_menu_a,
- :invite_members_version_a,
:invite_members_version_b,
:invite_members_empty_group_version_a,
- :contact_sales_btn_in_app,
- :customize_homepage,
- :group_only_trials
+ :contact_sales_btn_in_app
]
backwards_compatible_experiment_keys = described_class.filter { |_, v| v[:use_backwards_compatible_subject_index] }.keys
diff --git a/spec/lib/gitlab/git/push_spec.rb b/spec/lib/gitlab/git/push_spec.rb
index 8ba43b2967c..68cef558f6f 100644
--- a/spec/lib/gitlab/git/push_spec.rb
+++ b/spec/lib/gitlab/git/push_spec.rb
@@ -87,7 +87,7 @@ RSpec.describe Gitlab::Git::Push do
it { is_expected.to be_force_push }
end
- context 'when called muiltiple times' do
+ context 'when called mulitiple times' do
it 'does not make make multiple calls to the force push check' do
expect(Gitlab::Checks::ForcePush).to receive(:force_push?).once
diff --git a/spec/lib/gitlab/github_import/importer/pull_request_merged_by_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_request_merged_by_importer_spec.rb
index 2999dc5bb41..e42b6d89c30 100644
--- a/spec/lib/gitlab/github_import/importer/pull_request_merged_by_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/pull_request_merged_by_importer_spec.rb
@@ -5,37 +5,46 @@ require 'spec_helper'
RSpec.describe Gitlab::GithubImport::Importer::PullRequestMergedByImporter, :clean_gitlab_redis_cache do
let_it_be(:merge_request) { create(:merged_merge_request) }
let(:project) { merge_request.project }
- let(:created_at) { Time.new(2017, 1, 1, 12, 00).utc }
+ let(:merged_at) { Time.new(2017, 1, 1, 12, 00).utc }
let(:client_double) { double(user: double(id: 999, login: 'merger', email: 'merger@email.com')) }
let(:pull_request) do
instance_double(
Gitlab::GithubImport::Representation::PullRequest,
iid: merge_request.iid,
- created_at: created_at,
+ merged_at: merged_at,
merged_by: double(id: 999, login: 'merger')
)
end
subject { described_class.new(pull_request, project, client_double) }
- it 'assigns the merged by user when mapped' do
- merge_user = create(:user, email: 'merger@email.com')
+ context 'when the merger user can be mapped' do
+ it 'assigns the merged by user when mapped' do
+ merge_user = create(:user, email: 'merger@email.com')
- subject.execute
+ subject.execute
- expect(merge_request.metrics.reload.merged_by).to eq(merge_user)
+ metrics = merge_request.metrics.reload
+ expect(metrics.merged_by).to eq(merge_user)
+ expect(metrics.merged_at).to eq(merged_at)
+ end
end
- it 'adds a note referencing the merger user when the user cannot be mapped' do
- expect { subject.execute }
- .to change(Note, :count).by(1)
- .and not_change(merge_request, :updated_at)
-
- last_note = merge_request.notes.last
-
- expect(last_note.note).to eq("*Merged by: merger*")
- expect(last_note.created_at).to eq(created_at)
- expect(last_note.author).to eq(project.creator)
+ context 'when the merger user cannot be mapped to a gitlab user' do
+ it 'adds a note referencing the merger user' do
+ expect { subject.execute }
+ .to change(Note, :count).by(1)
+ .and not_change(merge_request, :updated_at)
+
+ metrics = merge_request.metrics.reload
+ expect(metrics.merged_by).to be_nil
+ expect(metrics.merged_at).to eq(merged_at)
+
+ last_note = merge_request.notes.last
+ expect(last_note.note).to eq("*Merged by: merger at 2017-01-01 12:00:00 UTC*")
+ expect(last_note.created_at).to eq(merged_at)
+ expect(last_note.author).to eq(project.creator)
+ end
end
end
diff --git a/spec/lib/gitlab/github_import/importer/pull_request_review_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_request_review_importer_spec.rb
index b2f993ac47c..290f3f51202 100644
--- a/spec/lib/gitlab/github_import/importer/pull_request_review_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/pull_request_review_importer_spec.rb
@@ -19,8 +19,10 @@ RSpec.describe Gitlab::GithubImport::Importer::PullRequestReviewImporter, :clean
context 'when the review is "APPROVED"' do
let(:review) { create_review(type: 'APPROVED', note: '') }
- it 'creates a note for the review' do
- expect { subject.execute }.to change(Note, :count)
+ it 'creates a note for the review and approves the Merge Request' do
+ expect { subject.execute }
+ .to change(Note, :count).by(1)
+ .and change(Approval, :count).by(1)
last_note = merge_request.notes.last
expect(last_note.note).to eq('approved this merge request')
@@ -31,6 +33,14 @@ RSpec.describe Gitlab::GithubImport::Importer::PullRequestReviewImporter, :clean
expect(merge_request.approved_by_users.reload).to include(author)
expect(merge_request.approvals.last.created_at).to eq(submitted_at)
end
+
+ it 'does nothing if the user already approved the merge request' do
+ create(:approval, merge_request: merge_request, user: author)
+
+ expect { subject.execute }
+ .to change(Note, :count).by(0)
+ .and change(Approval, :count).by(0)
+ end
end
context 'when the review is "COMMENTED"' do
diff --git a/spec/lib/gitlab/graphql/calls_gitaly/field_extension_spec.rb b/spec/lib/gitlab/graphql/calls_gitaly/field_extension_spec.rb
new file mode 100644
index 00000000000..1d8849f7e38
--- /dev/null
+++ b/spec/lib/gitlab/graphql/calls_gitaly/field_extension_spec.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Gitlab::Graphql::CallsGitaly::FieldExtension, :request_store do
+ include GraphqlHelpers
+
+ let(:field_args) { {} }
+ let(:owner) { fresh_object_type }
+ let(:field) do
+ ::Types::BaseField.new(name: 'value', type: GraphQL::STRING_TYPE, null: true, owner: owner, **field_args)
+ end
+
+ def resolve_value
+ resolve_field(field, { value: 'foo' }, object_type: owner)
+ end
+
+ context 'when the field calls gitaly' do
+ before do
+ owner.define_method :value do
+ Gitlab::SafeRequestStore['gitaly_call_actual'] = 1
+ 'fresh-from-the-gitaly-mines!'
+ end
+ end
+
+ context 'when the field has a constant complexity' do
+ let(:field_args) { { complexity: 100 } }
+
+ it 'allows the call' do
+ expect { resolve_value }.not_to raise_error
+ end
+ end
+
+ context 'when the field declares that it calls gitaly' do
+ let(:field_args) { { calls_gitaly: true } }
+
+ it 'allows the call' do
+ expect { resolve_value }.not_to raise_error
+ end
+ end
+
+ context 'when the field does not have these arguments' do
+ let(:field_args) { {} }
+
+ it 'notices, and raises, mentioning the field' do
+ expect { resolve_value }.to raise_error(include('Object.value'))
+ end
+ end
+ end
+
+ context 'when it does not call gitaly' do
+ let(:field_args) { {} }
+
+ it 'does not raise' do
+ value = resolve_value
+
+ expect(value).to eq 'foo'
+ end
+ end
+
+ context 'when some field calls gitaly while we were waiting' do
+ let(:extension) { described_class.new(field: field, options: {}) }
+
+ it 'is acceptable if all are accounted for' do
+ object = :anything
+ arguments = :any_args
+
+ ::Gitlab::SafeRequestStore['gitaly_call_actual'] = 3
+ ::Gitlab::SafeRequestStore['graphql_gitaly_accounted_for'] = 0
+
+ expect do |b|
+ extension.resolve(object: object, arguments: arguments, &b)
+ end.to yield_with_args(object, arguments, [3, 0])
+
+ ::Gitlab::SafeRequestStore['gitaly_call_actual'] = 13
+ ::Gitlab::SafeRequestStore['graphql_gitaly_accounted_for'] = 10
+
+ expect { extension.after_resolve(value: 'foo', memo: [3, 0]) }.not_to raise_error
+ end
+
+ it 'is unacceptable if some of the calls are unaccounted for' do
+ ::Gitlab::SafeRequestStore['gitaly_call_actual'] = 10
+ ::Gitlab::SafeRequestStore['graphql_gitaly_accounted_for'] = 9
+
+ expect { extension.after_resolve(value: 'foo', memo: [0, 0]) }.to raise_error(include('Object.value'))
+ end
+ end
+end
diff --git a/spec/lib/gitlab/graphql/calls_gitaly/instrumentation_spec.rb b/spec/lib/gitlab/graphql/calls_gitaly/instrumentation_spec.rb
deleted file mode 100644
index f16767f7d14..00000000000
--- a/spec/lib/gitlab/graphql/calls_gitaly/instrumentation_spec.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe Gitlab::Graphql::CallsGitaly::Instrumentation do
- subject { described_class.new }
-
- describe '#calls_gitaly_check' do
- let(:gitaly_field) { Types::BaseField.new(name: 'test', type: GraphQL::STRING_TYPE, null: true, calls_gitaly: true) }
- let(:no_gitaly_field) { Types::BaseField.new(name: 'test', type: GraphQL::STRING_TYPE, null: true, calls_gitaly: false) }
-
- context 'if there are no Gitaly calls' do
- it 'does not raise an error if calls_gitaly is false' do
- expect { subject.send(:calls_gitaly_check, no_gitaly_field, 0) }.not_to raise_error
- end
- end
-
- context 'if there is at least 1 Gitaly call' do
- it 'raises an error if calls_gitaly: is false or not defined' do
- expect { subject.send(:calls_gitaly_check, no_gitaly_field, 1) }.to raise_error(/specify a constant complexity or add `calls_gitaly: true`/)
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/graphql/docs/renderer_spec.rb b/spec/lib/gitlab/graphql/docs/renderer_spec.rb
index 064e0c6828b..5afed8c3390 100644
--- a/spec/lib/gitlab/graphql/docs/renderer_spec.rb
+++ b/spec/lib/gitlab/graphql/docs/renderer_spec.rb
@@ -5,27 +5,50 @@ require 'spec_helper'
RSpec.describe Gitlab::Graphql::Docs::Renderer do
describe '#contents' do
# Returns a Schema that uses the given `type`
- def mock_schema(type)
+ def mock_schema(type, field_description)
query_type = Class.new(Types::BaseObject) do
- graphql_name 'QueryType'
+ graphql_name 'Query'
- field :foo, type, null: true
+ field :foo, type, null: true do
+ description field_description
+ argument :id, GraphQL::ID_TYPE, required: false, description: 'ID of the object.'
+ end
end
- GraphQL::Schema.define(query: query_type)
+ GraphQL::Schema.define(
+ query: query_type,
+ resolve_type: ->(obj, ctx) { raise 'Not a real schema' }
+ )
end
- let_it_be(:template) { Rails.root.join('lib/gitlab/graphql/docs/templates/', 'default.md.haml') }
+ let_it_be(:template) { Rails.root.join('lib/gitlab/graphql/docs/templates/default.md.haml') }
+ let(:field_description) { 'List of objects.' }
subject(:contents) do
described_class.new(
- mock_schema(type).graphql_definition,
+ mock_schema(type, field_description).graphql_definition,
output_dir: nil,
template: template
).contents
end
- context 'A type with a field with a [Array] return type' do
+ describe 'headings' do
+ let(:type) { ::GraphQL::INT_TYPE }
+
+ it 'contains the expected sections' do
+ expect(contents.lines.map(&:chomp)).to include(
+ '## `Query` type',
+ '## Object types',
+ '## Enumeration types',
+ '## Scalar types',
+ '## Abstract types',
+ '### Unions',
+ '### Interfaces'
+ )
+ end
+ end
+
+ context 'when a field has a list type' do
let(:type) do
Class.new(Types::BaseObject) do
graphql_name 'ArrayTest'
@@ -35,19 +58,51 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
end
specify do
+ type_name = '[String!]!'
+ inner_type = 'string'
expectation = <<~DOC
- ### ArrayTest
+ ### `ArrayTest`
| Field | Type | Description |
| ----- | ---- | ----------- |
- | `foo` | String! => Array | A description. |
+ | `foo` | [`#{type_name}`](##{inner_type}) | A description. |
DOC
is_expected.to include(expectation)
end
+
+ describe 'a top level query field' do
+ let(:expectation) do
+ <<~DOC
+ ### `foo`
+
+ List of objects.
+
+ Returns [`ArrayTest`](#arraytest).
+
+ #### Arguments
+
+ | Name | Type | Description |
+ | ---- | ---- | ----------- |
+ | `id` | [`ID`](#id) | ID of the object. |
+ DOC
+ end
+
+ it 'generates the query with arguments' do
+ expect(subject).to include(expectation)
+ end
+
+ context 'when description does not end with `.`' do
+ let(:field_description) { 'List of objects' }
+
+ it 'adds the `.` to the end' do
+ expect(subject).to include(expectation)
+ end
+ end
+ end
end
- context 'A type with fields defined in reverse alphabetical order' do
+ describe 'when fields are not defined in alphabetical order' do
let(:type) do
Class.new(Types::BaseObject) do
graphql_name 'OrderingTest'
@@ -57,49 +112,56 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
end
end
- specify do
+ it 'lists the fields in alphabetical order' do
expectation = <<~DOC
- ### OrderingTest
+ ### `OrderingTest`
| Field | Type | Description |
| ----- | ---- | ----------- |
- | `bar` | String! | A description of bar field. |
- | `foo` | String! | A description of foo field. |
+ | `bar` | [`String!`](#string) | A description of bar field. |
+ | `foo` | [`String!`](#string) | A description of foo field. |
DOC
is_expected.to include(expectation)
end
end
- context 'A type with a deprecated field' do
+ context 'when a field is deprecated' do
let(:type) do
Class.new(Types::BaseObject) do
graphql_name 'DeprecatedTest'
- field :foo, GraphQL::STRING_TYPE, null: false, deprecated: { reason: 'This is deprecated', milestone: '1.10' }, description: 'A description.'
+ field :foo,
+ type: GraphQL::STRING_TYPE,
+ null: false,
+ deprecated: { reason: 'This is deprecated', milestone: '1.10' },
+ description: 'A description.'
end
end
- specify do
+ it 'includes the deprecation' do
expectation = <<~DOC
- ### DeprecatedTest
+ ### `DeprecatedTest`
| Field | Type | Description |
| ----- | ---- | ----------- |
- | `foo` **{warning-solid}** | String! | **Deprecated:** This is deprecated. Deprecated in 1.10. |
+ | `foo` **{warning-solid}** | [`String!`](#string) | **Deprecated:** This is deprecated. Deprecated in 1.10. |
DOC
is_expected.to include(expectation)
end
end
- context 'A type with an emum field' do
+ context 'when a field has an Enumeration type' do
let(:type) do
enum_type = Class.new(Types::BaseEnum) do
graphql_name 'MyEnum'
- value 'BAZ', description: 'A description of BAZ.'
- value 'BAR', description: 'A description of BAR.', deprecated: { reason: 'This is deprecated', milestone: '1.10' }
+ value 'BAZ',
+ description: 'A description of BAZ.'
+ value 'BAR',
+ description: 'A description of BAR.',
+ deprecated: { reason: 'This is deprecated', milestone: '1.10' }
end
Class.new(Types::BaseObject) do
@@ -109,9 +171,9 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
end
end
- specify do
+ it 'includes the description of the Enumeration' do
expectation = <<~DOC
- ### MyEnum
+ ### `MyEnum`
| Value | Description |
| ----- | ----------- |
@@ -122,5 +184,129 @@ RSpec.describe Gitlab::Graphql::Docs::Renderer do
is_expected.to include(expectation)
end
end
+
+ context 'when a field has a global ID type' do
+ let(:type) do
+ Class.new(Types::BaseObject) do
+ graphql_name 'IDTest'
+ description 'A test for rendering IDs.'
+
+ field :foo, ::Types::GlobalIDType[::User], null: true, description: 'A user foo.'
+ end
+ end
+
+ it 'includes the field and the description of the ID, so we can link to it' do
+ type_section = <<~DOC
+ ### `IDTest`
+
+ A test for rendering IDs.
+
+ | Field | Type | Description |
+ | ----- | ---- | ----------- |
+ | `foo` | [`UserID`](#userid) | A user foo. |
+ DOC
+
+ id_section = <<~DOC
+ ### `UserID`
+
+ A `UserID` is a global ID. It is encoded as a string.
+
+ An example `UserID` is: `"gid://gitlab/User/1"`.
+ DOC
+
+ is_expected.to include(type_section, id_section)
+ end
+ end
+
+ context 'when there is an interface and a union' do
+ let(:type) do
+ user = Class.new(::Types::BaseObject)
+ user.graphql_name 'User'
+ user.field :user_field, ::GraphQL::STRING_TYPE, null: true
+ group = Class.new(::Types::BaseObject)
+ group.graphql_name 'Group'
+ group.field :group_field, ::GraphQL::STRING_TYPE, null: true
+
+ union = Class.new(::Types::BaseUnion)
+ union.graphql_name 'UserOrGroup'
+ union.description 'Either a user or a group.'
+ union.possible_types user, group
+
+ interface = Module.new
+ interface.include(::Types::BaseInterface)
+ interface.graphql_name 'Flying'
+ interface.description 'Something that can fly.'
+ interface.field :flight_speed, GraphQL::INT_TYPE, null: true, description: 'Speed in mph.'
+
+ african_swallow = Class.new(::Types::BaseObject)
+ african_swallow.graphql_name 'AfricanSwallow'
+ african_swallow.description 'A swallow from Africa.'
+ african_swallow.implements interface
+ interface.orphan_types african_swallow
+
+ Class.new(::Types::BaseObject) do
+ graphql_name 'AbstactTypeTest'
+ description 'A test for abstract types.'
+
+ field :foo, union, null: true, description: 'The foo.'
+ field :flying, interface, null: true, description: 'A flying thing.'
+ end
+ end
+
+ it 'lists the fields correctly, and includes descriptions of all the types' do
+ type_section = <<~DOC
+ ### `AbstactTypeTest`
+
+ A test for abstract types.
+
+ | Field | Type | Description |
+ | ----- | ---- | ----------- |
+ | `flying` | [`Flying`](#flying) | A flying thing. |
+ | `foo` | [`UserOrGroup`](#userorgroup) | The foo. |
+ DOC
+
+ union_section = <<~DOC
+ #### `UserOrGroup`
+
+ Either a user or a group.
+
+ One of:
+
+ - [`Group`](#group)
+ - [`User`](#user)
+ DOC
+
+ interface_section = <<~DOC
+ #### `Flying`
+
+ Something that can fly.
+
+ Implementations:
+
+ - [`AfricanSwallow`](#africanswallow)
+
+ | Field | Type | Description |
+ | ----- | ---- | ----------- |
+ | `flightSpeed` | [`Int`](#int) | Speed in mph. |
+ DOC
+
+ implementation_section = <<~DOC
+ ### `AfricanSwallow`
+
+ A swallow from Africa.
+
+ | Field | Type | Description |
+ | ----- | ---- | ----------- |
+ | `flightSpeed` | [`Int`](#int) | Speed in mph. |
+ DOC
+
+ is_expected.to include(
+ type_section,
+ union_section,
+ interface_section,
+ implementation_section
+ )
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/graphql/pagination/keyset/last_items_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/last_items_spec.rb
index b45bb8b79d9..ec2ec4bf50d 100644
--- a/spec/lib/gitlab/graphql/pagination/keyset/last_items_spec.rb
+++ b/spec/lib/gitlab/graphql/pagination/keyset/last_items_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe Gitlab::Graphql::Pagination::Keyset::LastItems do
let_it_be(:merge_request) { create(:merge_request) }
- let(:scope) { MergeRequest.order_merged_at_asc.with_order_id_desc }
+ let(:scope) { MergeRequest.order_merged_at_asc }
subject { described_class.take_items(*args) }
diff --git a/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb
index eb28e6c8c0a..40ee47ece49 100644
--- a/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb
+++ b/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb
@@ -52,18 +52,6 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::OrderInfo do
end
end
- context 'when ordering by SIMILARITY' do
- let(:relation) { Project.sorted_by_similarity_desc('test', include_in_select: true) }
-
- it 'assigns the right attribute name, named function, and direction' do
- expect(order_list.count).to eq 2
- expect(order_list.first.attribute_name).to eq 'similarity'
- expect(order_list.first.named_function).to be_kind_of(Arel::Nodes::Addition)
- expect(order_list.first.named_function.to_sql).to include 'SIMILARITY('
- expect(order_list.first.sort_direction).to eq :desc
- end
- end
-
context 'when ordering by CASE', :aggregate_failuers do
let(:relation) { Project.order(Arel::Nodes::Case.new(Project.arel_table[:pending_delete]).when(true).then(100).else(1000).asc) }
diff --git a/spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb
index fa631aa5666..31c02fd43e8 100644
--- a/spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb
+++ b/spec/lib/gitlab/graphql/pagination/keyset/query_builder_spec.rb
@@ -131,43 +131,5 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::QueryBuilder do
end
end
end
-
- context 'when sorting using SIMILARITY' do
- let(:relation) { Project.sorted_by_similarity_desc('test', include_in_select: true) }
- let(:arel_table) { Project.arel_table }
- let(:decoded_cursor) { { 'similarity' => 0.5, 'id' => 100 } }
- let(:similarity_function_call) { Gitlab::Database::SimilarityScore::SIMILARITY_FUNCTION_CALL_WITH_ANNOTATION }
- let(:similarity_sql) do
- [
- "(#{similarity_function_call}(COALESCE(\"projects\".\"path\", ''), 'test') * CAST('1' AS numeric))",
- "(#{similarity_function_call}(COALESCE(\"projects\".\"name\", ''), 'test') * CAST('0.7' AS numeric))",
- "(#{similarity_function_call}(COALESCE(\"projects\".\"description\", ''), 'test') * CAST('0.2' AS numeric))"
- ].join(' + ')
- end
-
- context 'when no values are nil' do
- context 'when :after' do
- it 'generates the correct condition' do
- conditions = builder.conditions.gsub(/\s+/, ' ')
-
- expect(conditions).to include "(#{similarity_sql} < 0.5)"
- expect(conditions).to include '"projects"."id" < 100'
- expect(conditions).to include "OR (#{similarity_sql} IS NULL)"
- end
- end
-
- context 'when :before' do
- let(:before_or_after) { :before }
-
- it 'generates the correct condition' do
- conditions = builder.conditions.gsub(/\s+/, ' ')
-
- expect(conditions).to include "(#{similarity_sql} > 0.5)"
- expect(conditions).to include '"projects"."id" > 100'
- expect(conditions).to include "OR ( #{similarity_sql} = 0.5"
- end
- end
- end
- end
end
end
diff --git a/spec/lib/gitlab/graphql/present/field_extension_spec.rb b/spec/lib/gitlab/graphql/present/field_extension_spec.rb
new file mode 100644
index 00000000000..5e66e16d655
--- /dev/null
+++ b/spec/lib/gitlab/graphql/present/field_extension_spec.rb
@@ -0,0 +1,143 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Gitlab::Graphql::Present::FieldExtension do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+
+ let(:object) { double(value: 'foo') }
+ let(:owner) { fresh_object_type }
+ let(:field_name) { 'value' }
+ let(:field) do
+ ::Types::BaseField.new(name: field_name, type: GraphQL::STRING_TYPE, null: true, owner: owner)
+ end
+
+ let(:base_presenter) do
+ Class.new(SimpleDelegator) do
+ def initialize(object, **options)
+ super(object)
+ @object = object
+ @options = options
+ end
+ end
+ end
+
+ def resolve_value
+ resolve_field(field, object, current_user: user, object_type: owner)
+ end
+
+ context 'when the object does not declare a presenter' do
+ it 'does not affect normal resolution' do
+ expect(resolve_value).to eq 'foo'
+ end
+ end
+
+ describe 'interactions with inheritance' do
+ def parent
+ type = fresh_object_type('Parent')
+ type.present_using(provide_foo)
+ type.field :foo, ::GraphQL::INT_TYPE, null: true
+ type.field :value, ::GraphQL::STRING_TYPE, null: true
+ type
+ end
+
+ def child
+ type = Class.new(parent)
+ type.graphql_name 'Child'
+ type.present_using(provide_bar)
+ type.field :bar, ::GraphQL::INT_TYPE, null: true
+ type
+ end
+
+ def provide_foo
+ Class.new(base_presenter) do
+ def foo
+ 100
+ end
+ end
+ end
+
+ def provide_bar
+ Class.new(base_presenter) do
+ def bar
+ 101
+ end
+ end
+ end
+
+ it 'can resolve value, foo and bar' do
+ type = child
+ value = resolve_field(:value, object, object_type: type)
+ foo = resolve_field(:foo, object, object_type: type)
+ bar = resolve_field(:bar, object, object_type: type)
+
+ expect([value, foo, bar]).to eq ['foo', 100, 101]
+ end
+ end
+
+ shared_examples 'calling the presenter method' do
+ it 'calls the presenter method' do
+ expect(resolve_value).to eq presenter.new(object, current_user: user).send(field_name)
+ end
+ end
+
+ context 'when the object declares a presenter' do
+ before do
+ owner.present_using(presenter)
+ end
+
+ context 'when the presenter overrides the original method' do
+ def twice
+ Class.new(base_presenter) do
+ def value
+ @object.value * 2
+ end
+ end
+ end
+
+ let(:presenter) { twice }
+
+ it_behaves_like 'calling the presenter method'
+ end
+
+ # This is exercised here using an explicit `resolve:` proc, but
+ # @resolver_proc values are used in field instrumentation as well.
+ context 'when the field uses a resolve proc' do
+ let(:presenter) { base_presenter }
+ let(:field) do
+ ::Types::BaseField.new(
+ name: field_name,
+ type: GraphQL::STRING_TYPE,
+ null: true,
+ owner: owner,
+ resolve: ->(obj, args, ctx) { 'Hello from a proc' }
+ )
+ end
+
+ specify { expect(resolve_value).to eq 'Hello from a proc' }
+ end
+
+ context 'when the presenter provides a new method' do
+ def presenter
+ Class.new(base_presenter) do
+ def current_username
+ "Hello #{@options[:current_user]&.username} from the presenter!"
+ end
+ end
+ end
+
+ context 'when we select the original field' do
+ it 'is unaffected' do
+ expect(resolve_value).to eq 'foo'
+ end
+ end
+
+ context 'when we select the new field' do
+ let(:field_name) { 'current_username' }
+
+ it_behaves_like 'calling the presenter method'
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb b/spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb
index 138765afd8a..8450396284a 100644
--- a/spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb
+++ b/spec/lib/gitlab/graphql/query_analyzers/logger_analyzer_spec.rb
@@ -5,42 +5,6 @@ require 'spec_helper'
RSpec.describe Gitlab::Graphql::QueryAnalyzers::LoggerAnalyzer do
subject { described_class.new }
- describe '#analyze?' do
- context 'feature flag disabled' do
- before do
- stub_feature_flags(graphql_logging: false)
- end
-
- it 'disables the analyzer' do
- expect(subject.analyze?(anything)).to be_falsey
- end
- end
-
- context 'feature flag enabled by default' do
- let(:monotonic_time_before) { 42 }
- let(:monotonic_time_after) { 500 }
- let(:monotonic_time_duration) { monotonic_time_after - monotonic_time_before }
-
- it 'enables the analyzer' do
- expect(subject.analyze?(anything)).to be_truthy
- end
-
- it 'returns a duration in seconds' do
- allow(GraphQL::Analysis).to receive(:analyze_query).and_return([4, 2, [[], []]])
- allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(monotonic_time_before, monotonic_time_after)
- allow(Gitlab::GraphqlLogger).to receive(:info)
-
- expected_duration = monotonic_time_duration
- memo = subject.initial_value(spy('query'))
-
- subject.final_value(memo)
-
- expect(memo).to have_key(:duration_s)
- expect(memo[:duration_s]).to eq(expected_duration)
- end
- end
- end
-
describe '#initial_value' do
it 'filters out sensitive variables' do
doc = GraphQL.parse <<-GRAPHQL
@@ -58,4 +22,24 @@ RSpec.describe Gitlab::Graphql::QueryAnalyzers::LoggerAnalyzer do
expect(subject.initial_value(query)[:variables]).to eq('{:body=>"[FILTERED]"}')
end
end
+
+ describe '#final_value' do
+ let(:monotonic_time_before) { 42 }
+ let(:monotonic_time_after) { 500 }
+ let(:monotonic_time_duration) { monotonic_time_after - monotonic_time_before }
+
+ it 'returns a duration in seconds' do
+ allow(GraphQL::Analysis).to receive(:analyze_query).and_return([4, 2, [[], []]])
+ allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(monotonic_time_before, monotonic_time_after)
+ allow(Gitlab::GraphqlLogger).to receive(:info)
+
+ expected_duration = monotonic_time_duration
+ memo = subject.initial_value(spy('query'))
+
+ subject.final_value(memo)
+
+ expect(memo).to have_key(:duration_s)
+ expect(memo[:duration_s]).to eq(expected_duration)
+ end
+ end
end
diff --git a/spec/lib/gitlab/hook_data/project_member_builder_spec.rb b/spec/lib/gitlab/hook_data/project_member_builder_spec.rb
new file mode 100644
index 00000000000..3fb84223581
--- /dev/null
+++ b/spec/lib/gitlab/hook_data/project_member_builder_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::HookData::ProjectMemberBuilder do
+ let_it_be(:project) { create(:project, :internal, name: 'gitlab') }
+ let_it_be(:user) { create(:user, name: 'John Doe', username: 'johndoe', email: 'john@example.com') }
+ let_it_be(:project_member) { create(:project_member, :developer, user: user, project: project) }
+
+ describe '#build' do
+ let(:data) { described_class.new(project_member).build(event) }
+ let(:event_name) { data[:event_name] }
+ let(:attributes) do
+ [
+ :event_name, :created_at, :updated_at, :project_name, :project_path, :project_path_with_namespace, :project_id, :user_username, :user_name, :user_email, :user_id, :access_level, :project_visibility
+ ]
+ end
+
+ context 'data' do
+ shared_examples_for 'includes the required attributes' do
+ it 'includes the required attributes' do
+ expect(data).to include(*attributes)
+ expect(data[:project_name]).to eq('gitlab')
+ expect(data[:project_path]).to eq(project.path)
+ expect(data[:project_path_with_namespace]).to eq(project.full_path)
+ expect(data[:project_id]).to eq(project.id)
+ expect(data[:user_username]).to eq('johndoe')
+ expect(data[:user_name]).to eq('John Doe')
+ expect(data[:user_id]).to eq(user.id)
+ expect(data[:user_email]).to eq('john@example.com')
+ expect(data[:access_level]).to eq('Developer')
+ expect(data[:project_visibility]).to eq('internal')
+ end
+ end
+
+ context 'on create' do
+ let(:event) { :create }
+
+ it { expect(event_name).to eq('user_add_to_team') }
+ it_behaves_like 'includes the required attributes'
+ end
+
+ context 'on update' do
+ let(:event) { :update }
+
+ it { expect(event_name).to eq('user_update_for_team') }
+ it_behaves_like 'includes the required attributes'
+ end
+
+ context 'on destroy' do
+ let(:event) { :destroy }
+
+ it { expect(event_name).to eq('user_remove_from_team') }
+ it_behaves_like 'includes the required attributes'
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/http_connection_adapter_spec.rb b/spec/lib/gitlab/http_connection_adapter_spec.rb
index 389bc1a85f4..96e6e485841 100644
--- a/spec/lib/gitlab/http_connection_adapter_spec.rb
+++ b/spec/lib/gitlab/http_connection_adapter_spec.rb
@@ -5,17 +5,32 @@ require 'spec_helper'
RSpec.describe Gitlab::HTTPConnectionAdapter do
include StubRequests
+ let(:uri) { URI('https://example.org') }
+ let(:options) { {} }
+
+ subject(:connection) { described_class.new(uri, options).connection }
+
describe '#connection' do
before do
stub_all_dns('https://example.org', ip_address: '93.184.216.34')
end
- context 'when local requests are not allowed' do
+ context 'when local requests are allowed' do
+ let(:options) { { allow_local_requests: true } }
+
it 'sets up the connection' do
- uri = URI('https://example.org')
+ expect(connection).to be_a(Net::HTTP)
+ expect(connection.address).to eq('93.184.216.34')
+ expect(connection.hostname_override).to eq('example.org')
+ expect(connection.addr_port).to eq('example.org')
+ expect(connection.port).to eq(443)
+ end
+ end
- connection = described_class.new(uri).connection
+ context 'when local requests are not allowed' do
+ let(:options) { { allow_local_requests: false } }
+ it 'sets up the connection' do
expect(connection).to be_a(Net::HTTP)
expect(connection.address).to eq('93.184.216.34')
expect(connection.hostname_override).to eq('example.org')
@@ -23,28 +38,57 @@ RSpec.describe Gitlab::HTTPConnectionAdapter do
expect(connection.port).to eq(443)
end
- it 'raises error when it is a request to local address' do
- uri = URI('http://172.16.0.0/12')
+ context 'when it is a request to local network' do
+ let(:uri) { URI('http://172.16.0.0/12') }
+
+ it 'raises error' do
+ expect { subject }.to raise_error(
+ Gitlab::HTTP::BlockedUrlError,
+ "URL 'http://172.16.0.0/12' is blocked: Requests to the local network are not allowed"
+ )
+ end
+
+ context 'when local request allowed' do
+ let(:options) { { allow_local_requests: true } }
- expect { described_class.new(uri).connection }
- .to raise_error(Gitlab::HTTP::BlockedUrlError,
- "URL 'http://172.16.0.0/12' is blocked: Requests to the local network are not allowed")
+ it 'sets up the connection' do
+ expect(connection).to be_a(Net::HTTP)
+ expect(connection.address).to eq('172.16.0.0')
+ expect(connection.hostname_override).to be(nil)
+ expect(connection.addr_port).to eq('172.16.0.0')
+ expect(connection.port).to eq(80)
+ end
+ end
end
- it 'raises error when it is a request to localhost address' do
- uri = URI('http://127.0.0.1')
+ context 'when it is a request to local address' do
+ let(:uri) { URI('http://127.0.0.1') }
+
+ it 'raises error' do
+ expect { subject }.to raise_error(
+ Gitlab::HTTP::BlockedUrlError,
+ "URL 'http://127.0.0.1' is blocked: Requests to localhost are not allowed"
+ )
+ end
- expect { described_class.new(uri).connection }
- .to raise_error(Gitlab::HTTP::BlockedUrlError,
- "URL 'http://127.0.0.1' is blocked: Requests to localhost are not allowed")
+ context 'when local request allowed' do
+ let(:options) { { allow_local_requests: true } }
+
+ it 'sets up the connection' do
+ expect(connection).to be_a(Net::HTTP)
+ expect(connection.address).to eq('127.0.0.1')
+ expect(connection.hostname_override).to be(nil)
+ expect(connection.addr_port).to eq('127.0.0.1')
+ expect(connection.port).to eq(80)
+ end
+ end
end
context 'when port different from URL scheme is used' do
- it 'sets up the addr_port accordingly' do
- uri = URI('https://example.org:8080')
-
- connection = described_class.new(uri).connection
+ let(:uri) { URI('https://example.org:8080') }
+ it 'sets up the addr_port accordingly' do
+ expect(connection).to be_a(Net::HTTP)
expect(connection.address).to eq('93.184.216.34')
expect(connection.hostname_override).to eq('example.org')
expect(connection.addr_port).to eq('example.org:8080')
@@ -54,13 +98,11 @@ RSpec.describe Gitlab::HTTPConnectionAdapter do
end
context 'when DNS rebinding protection is disabled' do
- it 'sets up the connection' do
+ before do
stub_application_setting(dns_rebinding_protection_enabled: false)
+ end
- uri = URI('https://example.org')
-
- connection = described_class.new(uri).connection
-
+ it 'sets up the connection' do
expect(connection).to be_a(Net::HTTP)
expect(connection.address).to eq('example.org')
expect(connection.hostname_override).to eq(nil)
@@ -70,13 +112,11 @@ RSpec.describe Gitlab::HTTPConnectionAdapter do
end
context 'when http(s) environment variable is set' do
- it 'sets up the connection' do
+ before do
stub_env('https_proxy' => 'https://my.proxy')
+ end
- uri = URI('https://example.org')
-
- connection = described_class.new(uri).connection
-
+ it 'sets up the connection' do
expect(connection).to be_a(Net::HTTP)
expect(connection.address).to eq('example.org')
expect(connection.hostname_override).to eq(nil)
@@ -85,41 +125,128 @@ RSpec.describe Gitlab::HTTPConnectionAdapter do
end
end
- context 'when local requests are allowed' do
- it 'sets up the connection' do
- uri = URI('https://example.org')
+ context 'when proxy settings are configured' do
+ let(:options) do
+ {
+ http_proxyaddr: 'https://proxy.org',
+ http_proxyport: 1557,
+ http_proxyuser: 'user',
+ http_proxypass: 'pass'
+ }
+ end
- connection = described_class.new(uri, allow_local_requests: true).connection
+ before do
+ stub_all_dns('https://proxy.org', ip_address: '166.84.12.54')
+ end
- expect(connection).to be_a(Net::HTTP)
- expect(connection.address).to eq('93.184.216.34')
- expect(connection.hostname_override).to eq('example.org')
- expect(connection.addr_port).to eq('example.org')
- expect(connection.port).to eq(443)
+ it 'sets up the proxy settings' do
+ expect(connection.proxy_address).to eq('https://166.84.12.54')
+ expect(connection.proxy_port).to eq(1557)
+ expect(connection.proxy_user).to eq('user')
+ expect(connection.proxy_pass).to eq('pass')
end
- it 'sets up the connection when it is a local network' do
- uri = URI('http://172.16.0.0/12')
+ context 'when the address has path' do
+ before do
+ options[:http_proxyaddr] = 'https://proxy.org/path'
+ end
- connection = described_class.new(uri, allow_local_requests: true).connection
+ it 'sets up the proxy settings' do
+ expect(connection.proxy_address).to eq('https://166.84.12.54/path')
+ expect(connection.proxy_port).to eq(1557)
+ end
+ end
- expect(connection).to be_a(Net::HTTP)
- expect(connection.address).to eq('172.16.0.0')
- expect(connection.hostname_override).to be(nil)
- expect(connection.addr_port).to eq('172.16.0.0')
- expect(connection.port).to eq(80)
+ context 'when the port is in the address and port' do
+ before do
+ options[:http_proxyaddr] = 'https://proxy.org:1422'
+ end
+
+ it 'sets up the proxy settings' do
+ expect(connection.proxy_address).to eq('https://166.84.12.54')
+ expect(connection.proxy_port).to eq(1557)
+ end
+
+ context 'when the port is only in the address' do
+ before do
+ options[:http_proxyport] = nil
+ end
+
+ it 'sets up the proxy settings' do
+ expect(connection.proxy_address).to eq('https://166.84.12.54')
+ expect(connection.proxy_port).to eq(1422)
+ end
+ end
end
- it 'sets up the connection when it is localhost' do
- uri = URI('http://127.0.0.1')
+ context 'when it is a request to local network' do
+ before do
+ options[:http_proxyaddr] = 'http://172.16.0.0/12'
+ end
+
+ it 'raises error' do
+ expect { subject }.to raise_error(
+ Gitlab::HTTP::BlockedUrlError,
+ "URL 'http://172.16.0.0:1557/12' is blocked: Requests to the local network are not allowed"
+ )
+ end
- connection = described_class.new(uri, allow_local_requests: true).connection
+ context 'when local request allowed' do
+ before do
+ options[:allow_local_requests] = true
+ end
- expect(connection).to be_a(Net::HTTP)
- expect(connection.address).to eq('127.0.0.1')
- expect(connection.hostname_override).to be(nil)
- expect(connection.addr_port).to eq('127.0.0.1')
- expect(connection.port).to eq(80)
+ it 'sets up the connection' do
+ expect(connection.proxy_address).to eq('http://172.16.0.0/12')
+ expect(connection.proxy_port).to eq(1557)
+ end
+ end
+ end
+
+ context 'when it is a request to local address' do
+ before do
+ options[:http_proxyaddr] = 'http://127.0.0.1'
+ end
+
+ it 'raises error' do
+ expect { subject }.to raise_error(
+ Gitlab::HTTP::BlockedUrlError,
+ "URL 'http://127.0.0.1:1557' is blocked: Requests to localhost are not allowed"
+ )
+ end
+
+ context 'when local request allowed' do
+ before do
+ options[:allow_local_requests] = true
+ end
+
+ it 'sets up the connection' do
+ expect(connection.proxy_address).to eq('http://127.0.0.1')
+ expect(connection.proxy_port).to eq(1557)
+ end
+ end
+ end
+
+ context 'when http(s) environment variable is set' do
+ before do
+ stub_env('https_proxy' => 'https://my.proxy')
+ end
+
+ it 'sets up the connection' do
+ expect(connection.proxy_address).to eq('https://proxy.org')
+ expect(connection.proxy_port).to eq(1557)
+ end
+ end
+
+ context 'when DNS rebinding protection is disabled' do
+ before do
+ stub_application_setting(dns_rebinding_protection_enabled: false)
+ end
+
+ it 'sets up the connection' do
+ expect(connection.proxy_address).to eq('https://proxy.org')
+ expect(connection.proxy_port).to eq(1557)
+ end
end
end
end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index d0282e14d5f..37b43066a62 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -335,6 +335,7 @@ container_repositories:
- project
- name
project:
+- external_approval_rules
- taggings
- base_tags
- tag_taggings
diff --git a/spec/lib/gitlab/import_export/import_export_spec.rb b/spec/lib/gitlab/import_export/import_export_spec.rb
index 62b4717fc96..87757b07572 100644
--- a/spec/lib/gitlab/import_export/import_export_spec.rb
+++ b/spec/lib/gitlab/import_export/import_export_spec.rb
@@ -4,8 +4,8 @@ require 'spec_helper'
RSpec.describe Gitlab::ImportExport do
describe 'export filename' do
- let(:group) { create(:group, :nested) }
- let(:project) { create(:project, :public, path: 'project-path', namespace: group) }
+ let(:group) { build(:group, path: 'child', parent: build(:group, path: 'parent')) }
+ let(:project) { build(:project, :public, path: 'project-path', namespace: group) }
it 'contains the project path' do
expect(described_class.export_filename(exportable: project)).to include(project.path)
diff --git a/spec/lib/gitlab/import_export/project/tree_saver_spec.rb b/spec/lib/gitlab/import_export/project/tree_saver_spec.rb
index ece261e0882..50494433c5d 100644
--- a/spec/lib/gitlab/import_export/project/tree_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/project/tree_saver_spec.rb
@@ -349,14 +349,22 @@ RSpec.describe Gitlab::ImportExport::Project::TreeSaver do
project_tree_saver.save
end
- it 'exports group members as admin' do
- expect(member_emails).to include('group@member.com')
- end
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'exports group members as admin' do
+ expect(member_emails).to include('group@member.com')
+ end
- it 'exports group members as project members' do
- member_types = subject.map { |pm| pm['source_type'] }
+ it 'exports group members as project members' do
+ member_types = subject.map { |pm| pm['source_type'] }
+
+ expect(member_types).to all(eq('Project'))
+ end
+ end
- expect(member_types).to all(eq('Project'))
+ context 'when admin mode is disabled' do
+ it 'does not export group members' do
+ expect(member_emails).not_to include('group@member.com')
+ end
end
end
end
diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml
index e301be47d68..b159d0cfc76 100644
--- a/spec/lib/gitlab/import_export/safe_model_attributes.yml
+++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml
@@ -84,6 +84,7 @@ Note:
- discussion_id
- original_discussion_id
- confidential
+- last_edited_at
LabelLink:
- id
- target_type
@@ -500,6 +501,7 @@ ProtectedBranch:
- name
- created_at
- updated_at
+- allow_force_push
- code_owner_approval_required
ProtectedTag:
- id
@@ -584,6 +586,7 @@ ProjectFeature:
- analytics_access_level
- operations_access_level
- security_and_compliance_access_level
+- container_registry_access_level
- created_at
- updated_at
ProtectedBranch::MergeAccessLevel:
diff --git a/spec/lib/gitlab/marker_range_spec.rb b/spec/lib/gitlab/marker_range_spec.rb
new file mode 100644
index 00000000000..5f73d2a5048
--- /dev/null
+++ b/spec/lib/gitlab/marker_range_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Gitlab::MarkerRange do
+ subject(:marker_range) { described_class.new(first, last, mode: mode) }
+
+ let(:first) { 1 }
+ let(:last) { 10 }
+ let(:mode) { nil }
+
+ it { is_expected.to eq(first..last) }
+
+ it 'behaves like a Range' do
+ is_expected.to be_kind_of(Range)
+ end
+
+ describe '#mode' do
+ subject { marker_range.mode }
+
+ it { is_expected.to be_nil }
+
+ context 'when mode is provided' do
+ let(:mode) { :deletion }
+
+ it { is_expected.to eq(mode) }
+ end
+ end
+
+ describe '#to_range' do
+ subject { marker_range.to_range }
+
+ it { is_expected.to eq(first..last) }
+
+ context 'when mode is provided' do
+ let(:mode) { :deletion }
+
+ it 'is omitted during transformation' do
+ is_expected.not_to respond_to(:mode)
+ end
+ end
+ end
+
+ describe '.from_range' do
+ subject { described_class.from_range(range) }
+
+ let(:range) { 1..3 }
+
+ it 'converts Range to MarkerRange object' do
+ is_expected.to be_a(described_class)
+ end
+
+ it 'keeps correct range' do
+ is_expected.to eq(range)
+ end
+
+ context 'when range excludes end' do
+ let(:range) { 1...3 }
+
+ it 'keeps correct range' do
+ is_expected.to eq(range)
+ end
+ end
+
+ context 'when range is already a MarkerRange' do
+ let(:range) { marker_range }
+
+ it { is_expected.to be(marker_range) }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/background_transaction_spec.rb b/spec/lib/gitlab/metrics/background_transaction_spec.rb
new file mode 100644
index 00000000000..b31a2f7549a
--- /dev/null
+++ b/spec/lib/gitlab/metrics/background_transaction_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Metrics::BackgroundTransaction do
+ let(:transaction) { described_class.new }
+ let(:prometheus_metric) { instance_double(Prometheus::Client::Metric, base_labels: {}) }
+
+ before do
+ allow(described_class).to receive(:prometheus_metric).and_return(prometheus_metric)
+ end
+
+ describe '#run' do
+ it 'yields the supplied block' do
+ expect { |b| transaction.run(&b) }.to yield_control
+ end
+
+ it 'stores the transaction in the current thread' do
+ transaction.run do
+ expect(Thread.current[described_class::BACKGROUND_THREAD_KEY]).to eq(transaction)
+ end
+ end
+
+ it 'removes the transaction from the current thread upon completion' do
+ transaction.run { }
+
+ expect(Thread.current[described_class::BACKGROUND_THREAD_KEY]).to be_nil
+ end
+ end
+
+ describe '#labels' do
+ it 'provides labels with endpoint_id and feature_category' do
+ Labkit::Context.with_context(feature_category: 'projects', caller_id: 'TestWorker') do
+ expect(transaction.labels).to eq({ endpoint_id: 'TestWorker', feature_category: 'projects' })
+ end
+ end
+ end
+
+ RSpec.shared_examples 'metric with labels' do |metric_method|
+ it 'measures with correct labels and value' do
+ value = 1
+ expect(prometheus_metric).to receive(metric_method).with({ endpoint_id: 'TestWorker', feature_category: 'projects' }, value)
+
+ Labkit::Context.with_context(feature_category: 'projects', caller_id: 'TestWorker') do
+ transaction.send(metric_method, :test_metric, value)
+ end
+ end
+ end
+
+ describe '#increment' do
+ let(:prometheus_metric) { instance_double(Prometheus::Client::Counter, :increment, base_labels: {}) }
+
+ it_behaves_like 'metric with labels', :increment
+ end
+
+ describe '#set' do
+ let(:prometheus_metric) { instance_double(Prometheus::Client::Gauge, :set, base_labels: {}) }
+
+ it_behaves_like 'metric with labels', :set
+ end
+
+ describe '#observe' do
+ let(:prometheus_metric) { instance_double(Prometheus::Client::Histogram, :observe, base_labels: {}) }
+
+ it_behaves_like 'metric with labels', :observe
+ end
+end
diff --git a/spec/lib/gitlab/metrics/subscribers/action_cable_spec.rb b/spec/lib/gitlab/metrics/subscribers/action_cable_spec.rb
new file mode 100644
index 00000000000..153cf43be0a
--- /dev/null
+++ b/spec/lib/gitlab/metrics/subscribers/action_cable_spec.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Metrics::Subscribers::ActionCable, :request_store do
+ let(:subscriber) { described_class.new }
+ let(:counter) { double(:counter) }
+ let(:data) { { data: { event: 'updated' } } }
+ let(:channel_class) { 'IssuesChannel' }
+ let(:event) do
+ double(
+ :event,
+ name: name,
+ payload: payload
+ )
+ end
+
+ describe '#transmit' do
+ let(:name) { 'transmit.action_cable' }
+ let(:via) { 'streamed from issues:Z2lkOi8vZs2l0bGFiL0lzc3VlLzQ0Ng' }
+ let(:payload) do
+ {
+ channel_class: channel_class,
+ via: via,
+ data: data
+ }
+ end
+
+ it 'tracks the transmit event' do
+ allow(::Gitlab::Metrics).to receive(:counter).with(
+ :action_cable_single_client_transmissions_total, /transmit/
+ ).and_return(counter)
+
+ expect(counter).to receive(:increment)
+
+ subscriber.transmit(event)
+ end
+ end
+
+ describe '#broadcast' do
+ let(:name) { 'broadcast.action_cable' }
+ let(:coder) { ActiveSupport::JSON }
+ let(:message) do
+ { event: :updated }
+ end
+
+ let(:broadcasting) { 'issues:Z2lkOi8vZ2l0bGFiL0lzc3VlLzQ0Ng' }
+ let(:payload) do
+ {
+ broadcasting: broadcasting,
+ message: message,
+ coder: coder
+ }
+ end
+
+ it 'tracks the broadcast event' do
+ allow(::Gitlab::Metrics).to receive(:counter).with(
+ :action_cable_broadcasts_total, /broadcast/
+ ).and_return(counter)
+
+ expect(counter).to receive(:increment)
+
+ subscriber.broadcast(event)
+ end
+ end
+
+ describe '#transmit_subscription_confirmation' do
+ let(:name) { 'transmit_subscription_confirmation.action_cable' }
+ let(:channel_class) { 'IssuesChannel' }
+ let(:payload) do
+ {
+ channel_class: channel_class
+ }
+ end
+
+ it 'tracks the subscription confirmation event' do
+ allow(::Gitlab::Metrics).to receive(:counter).with(
+ :action_cable_subscription_confirmations_total, /confirm/
+ ).and_return(counter)
+
+ expect(counter).to receive(:increment)
+
+ subscriber.transmit_subscription_confirmation(event)
+ end
+ end
+
+ describe '#transmit_subscription_rejection' do
+ let(:name) { 'transmit_subscription_rejection.action_cable' }
+ let(:channel_class) { 'IssuesChannel' }
+ let(:payload) do
+ {
+ channel_class: channel_class
+ }
+ end
+
+ it 'tracks the subscription rejection event' do
+ allow(::Gitlab::Metrics).to receive(:counter).with(
+ :action_cable_subscription_rejections_total, /reject/
+ ).and_return(counter)
+
+ expect(counter).to receive(:increment)
+
+ subscriber.transmit_subscription_rejection(event)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb b/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
index edcd5b31941..dffd37eeb9d 100644
--- a/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
+++ b/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
@@ -3,10 +3,12 @@
require 'spec_helper'
RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do
+ using RSpec::Parameterized::TableSyntax
+
let(:env) { {} }
- let(:transaction) { Gitlab::Metrics::WebTransaction.new(env) }
- let(:subscriber) { described_class.new }
- let(:payload) { { sql: 'SELECT * FROM users WHERE id = 10' } }
+ let(:subscriber) { described_class.new }
+ let(:connection) { double(:connection) }
+ let(:payload) { { sql: 'SELECT * FROM users WHERE id = 10', connection: connection } }
let(:event) do
double(
@@ -17,82 +19,32 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do
)
end
- describe '#sql' do
- shared_examples 'track query in metrics' do
- before do
- allow(subscriber).to receive(:current_transaction)
- .at_least(:once)
- .and_return(transaction)
- end
-
- it 'increments only db count value' do
- described_class::DB_COUNTERS.each do |counter|
- prometheus_counter = "gitlab_transaction_#{counter}_total".to_sym
- if expected_counters[counter] > 0
- expect(transaction).to receive(:increment).with(prometheus_counter, 1)
- else
- expect(transaction).not_to receive(:increment).with(prometheus_counter, 1)
- end
- end
-
- subscriber.sql(event)
- end
+ # Emulate Marginalia pre-pending comments
+ def sql(query, comments: true)
+ if comments && !%w[BEGIN COMMIT].include?(query)
+ "/*application:web,controller:badges,action:pipeline,correlation_id:01EYN39K9VMJC56Z7808N7RSRH*/ #{query}"
+ else
+ query
end
+ end
- shared_examples 'track query in RequestStore' do
- context 'when RequestStore is enabled' do
- it 'caches db count value', :request_store, :aggregate_failures do
- subscriber.sql(event)
-
- described_class::DB_COUNTERS.each do |counter|
- expect(Gitlab::SafeRequestStore[counter].to_i).to eq expected_counters[counter]
- end
- end
-
- it 'prevents db counters from leaking to the next transaction' do
- 2.times do
- Gitlab::WithRequestStore.with_request_store do
- subscriber.sql(event)
-
- described_class::DB_COUNTERS.each do |counter|
- expect(Gitlab::SafeRequestStore[counter].to_i).to eq expected_counters[counter]
- end
- end
- end
- end
- end
- end
-
- describe 'without a current transaction' do
- it 'does not track any metrics' do
- expect_any_instance_of(Gitlab::Metrics::Transaction)
- .not_to receive(:increment)
-
- subscriber.sql(event)
- end
-
- context 'with read query' do
- let(:expected_counters) do
- {
- db_count: 1,
- db_write_count: 0,
- db_cached_count: 0
- }
- end
-
- it_behaves_like 'track query in RequestStore'
- end
+ shared_examples 'track generic sql events' do
+ where(:name, :sql_query, :record_query, :record_write_query, :record_cached_query) do
+ 'SQL' | 'SELECT * FROM users WHERE id = 10' | true | false | false
+ 'SQL' | 'WITH active_milestones AS (SELECT COUNT(*), state FROM milestones GROUP BY state) SELECT * FROM active_milestones' | true | false | false
+ 'SQL' | 'SELECT * FROM users WHERE id = 10 FOR UPDATE' | true | true | false
+ 'SQL' | 'WITH archived_rows AS (SELECT * FROM users WHERE archived = true) INSERT INTO products_log SELECT * FROM archived_rows' | true | true | false
+ 'SQL' | 'DELETE FROM users where id = 10' | true | true | false
+ 'SQL' | 'INSERT INTO project_ci_cd_settings (project_id) SELECT id FROM projects' | true | true | false
+ 'SQL' | 'UPDATE users SET admin = true WHERE id = 10' | true | true | false
+ 'CACHE' | 'SELECT * FROM users WHERE id = 10' | true | false | true
+ 'SCHEMA' | "SELECT attr.attname FROM pg_attribute attr INNER JOIN pg_constraint cons ON attr.attrelid = cons.conrelid AND attr.attnum = any(cons.conkey) WHERE cons.contype = 'p' AND cons.conrelid = '\"projects\"'::regclass" | false | false | false
+ nil | 'BEGIN' | false | false | false
+ nil | 'COMMIT' | false | false | false
end
- describe 'with a current transaction' do
- it 'observes sql_duration metric' do
- expect(subscriber).to receive(:current_transaction)
- .at_least(:once)
- .and_return(transaction)
- expect(transaction).to receive(:observe).with(:gitlab_sql_duration_seconds, 0.002)
-
- subscriber.sql(event)
- end
+ with_them do
+ let(:payload) { { name: name, sql: sql(sql_query, comments: comments), connection: connection } }
it 'marks the current thread as using the database' do
# since it would already have been toggled by other specs
@@ -101,215 +53,20 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do
expect { subscriber.sql(event) }.to change { Thread.current[:uses_db_connection] }.from(nil).to(true)
end
- context 'with read query' do
- let(:expected_counters) do
- {
- db_count: 1,
- db_write_count: 0,
- db_cached_count: 0
- }
- end
-
- it_behaves_like 'track query in metrics'
- it_behaves_like 'track query in RequestStore'
-
- context 'with only select' do
- let(:payload) { { sql: 'WITH active_milestones AS (SELECT COUNT(*), state FROM milestones GROUP BY state) SELECT * FROM active_milestones' } }
-
- it_behaves_like 'track query in metrics'
- it_behaves_like 'track query in RequestStore'
- end
- end
-
- context 'write query' do
- let(:expected_counters) do
- {
- db_count: 1,
- db_write_count: 1,
- db_cached_count: 0
- }
- end
-
- context 'with select for update sql event' do
- let(:payload) { { sql: 'SELECT * FROM users WHERE id = 10 FOR UPDATE' } }
-
- it_behaves_like 'track query in metrics'
- it_behaves_like 'track query in RequestStore'
- end
-
- context 'with common table expression' do
- context 'with insert' do
- let(:payload) { { sql: 'WITH archived_rows AS (SELECT * FROM users WHERE archived = true) INSERT INTO products_log SELECT * FROM archived_rows' } }
-
- it_behaves_like 'track query in metrics'
- it_behaves_like 'track query in RequestStore'
- end
- end
-
- context 'with delete sql event' do
- let(:payload) { { sql: 'DELETE FROM users where id = 10' } }
-
- it_behaves_like 'track query in metrics'
- it_behaves_like 'track query in RequestStore'
- end
-
- context 'with insert sql event' do
- let(:payload) { { sql: 'INSERT INTO project_ci_cd_settings (project_id) SELECT id FROM projects' } }
-
- it_behaves_like 'track query in metrics'
- it_behaves_like 'track query in RequestStore'
- end
-
- context 'with update sql event' do
- let(:payload) { { sql: 'UPDATE users SET admin = true WHERE id = 10' } }
-
- it_behaves_like 'track query in metrics'
- it_behaves_like 'track query in RequestStore'
- end
- end
-
- context 'with cached query' do
- let(:expected_counters) do
- {
- db_count: 1,
- db_write_count: 0,
- db_cached_count: 1
- }
- end
-
- context 'with cached payload ' do
- let(:payload) do
- {
- sql: 'SELECT * FROM users WHERE id = 10',
- cached: true
- }
- end
-
- it_behaves_like 'track query in metrics'
- it_behaves_like 'track query in RequestStore'
- end
-
- context 'with cached payload name' do
- let(:payload) do
- {
- sql: 'SELECT * FROM users WHERE id = 10',
- name: 'CACHE'
- }
- end
-
- it_behaves_like 'track query in metrics'
- it_behaves_like 'track query in RequestStore'
- end
- end
-
- context 'events are internal to Rails or irrelevant' do
- let(:schema_event) do
- double(
- :event,
- name: 'sql.active_record',
- payload: {
- sql: "SELECT attr.attname FROM pg_attribute attr INNER JOIN pg_constraint cons ON attr.attrelid = cons.conrelid AND attr.attnum = any(cons.conkey) WHERE cons.contype = 'p' AND cons.conrelid = '\"projects\"'::regclass",
- name: 'SCHEMA',
- connection_id: 135,
- statement_name: nil,
- binds: []
- },
- duration: 0.7
- )
- end
-
- let(:begin_event) do
- double(
- :event,
- name: 'sql.active_record',
- payload: {
- sql: "BEGIN",
- name: nil,
- connection_id: 231,
- statement_name: nil,
- binds: []
- },
- duration: 1.1
- )
- end
-
- let(:commit_event) do
- double(
- :event,
- name: 'sql.active_record',
- payload: {
- sql: "COMMIT",
- name: nil,
- connection_id: 212,
- statement_name: nil,
- binds: []
- },
- duration: 1.6
- )
- end
-
- it 'skips schema/begin/commit sql commands' do
- allow(subscriber).to receive(:current_transaction)
- .at_least(:once)
- .and_return(transaction)
-
- expect(transaction).not_to receive(:increment)
-
- subscriber.sql(schema_event)
- subscriber.sql(begin_event)
- subscriber.sql(commit_event)
- end
- end
+ it_behaves_like 'record ActiveRecord metrics'
+ it_behaves_like 'store ActiveRecord info in RequestStore'
end
end
- describe 'self.db_counter_payload' do
- before do
- allow(subscriber).to receive(:current_transaction)
- .at_least(:once)
- .and_return(transaction)
- end
-
- context 'when RequestStore is enabled', :request_store do
- context 'when query is executed' do
- let(:expected_payload) do
- {
- db_count: 1,
- db_cached_count: 0,
- db_write_count: 0
- }
- end
-
- it 'returns correct payload' do
- subscriber.sql(event)
-
- expect(described_class.db_counter_payload).to eq(expected_payload)
- end
- end
+ context 'without Marginalia comments' do
+ let(:comments) { false }
- context 'when query is not executed' do
- let(:expected_payload) do
- {
- db_count: 0,
- db_cached_count: 0,
- db_write_count: 0
- }
- end
-
- it 'returns correct payload' do
- expect(described_class.db_counter_payload).to eq(expected_payload)
- end
- end
- end
-
- context 'when RequestStore is disabled' do
- let(:expected_payload) { {} }
+ it_behaves_like 'track generic sql events'
+ end
- it 'returns empty payload' do
- subscriber.sql(event)
+ context 'with Marginalia comments' do
+ let(:comments) { true }
- expect(described_class.db_counter_payload).to eq(expected_payload)
- end
- end
+ it_behaves_like 'track generic sql events'
end
end
diff --git a/spec/lib/gitlab/object_hierarchy_spec.rb b/spec/lib/gitlab/object_hierarchy_spec.rb
index ef2d4fa0cbf..08e1a5ee0a3 100644
--- a/spec/lib/gitlab/object_hierarchy_spec.rb
+++ b/spec/lib/gitlab/object_hierarchy_spec.rb
@@ -7,178 +7,206 @@ RSpec.describe Gitlab::ObjectHierarchy do
let!(:child1) { create(:group, parent: parent) }
let!(:child2) { create(:group, parent: child1) }
- describe '#base_and_ancestors' do
- let(:relation) do
- described_class.new(Group.where(id: child2.id)).base_and_ancestors
- end
-
- it 'includes the base rows' do
- expect(relation).to include(child2)
- end
+ shared_context 'Gitlab::ObjectHierarchy test cases' do
+ describe '#base_and_ancestors' do
+ let(:relation) do
+ described_class.new(Group.where(id: child2.id)).base_and_ancestors
+ end
- it 'includes all of the ancestors' do
- expect(relation).to include(parent, child1)
- end
+ it 'includes the base rows' do
+ expect(relation).to include(child2)
+ end
- it 'can find ancestors upto a certain level' do
- relation = described_class.new(Group.where(id: child2)).base_and_ancestors(upto: child1)
+ it 'includes all of the ancestors' do
+ expect(relation).to include(parent, child1)
+ end
- expect(relation).to contain_exactly(child2)
- end
+ it 'can find ancestors upto a certain level' do
+ relation = described_class.new(Group.where(id: child2)).base_and_ancestors(upto: child1)
- it 'uses ancestors_base #initialize argument' do
- relation = described_class.new(Group.where(id: child2.id), Group.none).base_and_ancestors
+ expect(relation).to contain_exactly(child2)
+ end
- expect(relation).to include(parent, child1, child2)
- end
+ it 'uses ancestors_base #initialize argument' do
+ relation = described_class.new(Group.where(id: child2.id), Group.none).base_and_ancestors
- it 'does not allow the use of #update_all' do
- expect { relation.update_all(share_with_group_lock: false) }
- .to raise_error(ActiveRecord::ReadOnlyRecord)
- end
+ expect(relation).to include(parent, child1, child2)
+ end
- describe 'hierarchy_order option' do
- let(:relation) do
- described_class.new(Group.where(id: child2.id)).base_and_ancestors(hierarchy_order: hierarchy_order)
+ it 'does not allow the use of #update_all' do
+ expect { relation.update_all(share_with_group_lock: false) }
+ .to raise_error(ActiveRecord::ReadOnlyRecord)
end
- context ':asc' do
- let(:hierarchy_order) { :asc }
+ describe 'hierarchy_order option' do
+ let(:relation) do
+ described_class.new(Group.where(id: child2.id)).base_and_ancestors(hierarchy_order: hierarchy_order)
+ end
+
+ context ':asc' do
+ let(:hierarchy_order) { :asc }
- it 'orders by child to parent' do
- expect(relation).to eq([child2, child1, parent])
+ it 'orders by child to parent' do
+ expect(relation).to eq([child2, child1, parent])
+ end
end
- end
- context ':desc' do
- let(:hierarchy_order) { :desc }
+ context ':desc' do
+ let(:hierarchy_order) { :desc }
- it 'orders by parent to child' do
- expect(relation).to eq([parent, child1, child2])
+ it 'orders by parent to child' do
+ expect(relation).to eq([parent, child1, child2])
+ end
end
end
end
- end
-
- describe '#base_and_descendants' do
- let(:relation) do
- described_class.new(Group.where(id: parent.id)).base_and_descendants
- end
- it 'includes the base rows' do
- expect(relation).to include(parent)
- end
+ describe '#base_and_descendants' do
+ let(:relation) do
+ described_class.new(Group.where(id: parent.id)).base_and_descendants
+ end
- it 'includes all the descendants' do
- expect(relation).to include(child1, child2)
- end
+ it 'includes the base rows' do
+ expect(relation).to include(parent)
+ end
- it 'uses descendants_base #initialize argument' do
- relation = described_class.new(Group.none, Group.where(id: parent.id)).base_and_descendants
+ it 'includes all the descendants' do
+ expect(relation).to include(child1, child2)
+ end
- expect(relation).to include(parent, child1, child2)
- end
+ it 'uses descendants_base #initialize argument' do
+ relation = described_class.new(Group.none, Group.where(id: parent.id)).base_and_descendants
- it 'does not allow the use of #update_all' do
- expect { relation.update_all(share_with_group_lock: false) }
- .to raise_error(ActiveRecord::ReadOnlyRecord)
- end
+ expect(relation).to include(parent, child1, child2)
+ end
- context 'when with_depth is true' do
- let(:relation) do
- described_class.new(Group.where(id: parent.id)).base_and_descendants(with_depth: true)
+ it 'does not allow the use of #update_all' do
+ expect { relation.update_all(share_with_group_lock: false) }
+ .to raise_error(ActiveRecord::ReadOnlyRecord)
end
- it 'includes depth in the results' do
- object_depths = {
- parent.id => 1,
- child1.id => 2,
- child2.id => 3
- }
+ context 'when with_depth is true' do
+ let(:relation) do
+ described_class.new(Group.where(id: parent.id)).base_and_descendants(with_depth: true)
+ end
+
+ it 'includes depth in the results' do
+ object_depths = {
+ parent.id => 1,
+ child1.id => 2,
+ child2.id => 3
+ }
- relation.each do |object|
- expect(object.depth).to eq(object_depths[object.id])
+ relation.each do |object|
+ expect(object.depth).to eq(object_depths[object.id])
+ end
end
end
end
- end
- describe '#descendants' do
- it 'includes only the descendants' do
- relation = described_class.new(Group.where(id: parent)).descendants
+ describe '#descendants' do
+ it 'includes only the descendants' do
+ relation = described_class.new(Group.where(id: parent)).descendants
- expect(relation).to contain_exactly(child1, child2)
+ expect(relation).to contain_exactly(child1, child2)
+ end
end
- end
- describe '#max_descendants_depth' do
- subject { described_class.new(base_relation).max_descendants_depth }
+ describe '#max_descendants_depth' do
+ subject { described_class.new(base_relation).max_descendants_depth }
- context 'when base relation is empty' do
- let(:base_relation) { Group.where(id: nil) }
+ context 'when base relation is empty' do
+ let(:base_relation) { Group.where(id: nil) }
- it { expect(subject).to be_nil }
- end
+ it { expect(subject).to be_nil }
+ end
- context 'when base has no children' do
- let(:base_relation) { Group.where(id: child2) }
+ context 'when base has no children' do
+ let(:base_relation) { Group.where(id: child2) }
- it { expect(subject).to eq(1) }
- end
+ it { expect(subject).to eq(1) }
+ end
- context 'when base has grandchildren' do
- let(:base_relation) { Group.where(id: parent) }
+ context 'when base has grandchildren' do
+ let(:base_relation) { Group.where(id: parent) }
- it { expect(subject).to eq(3) }
+ it { expect(subject).to eq(3) }
+ end
end
- end
- describe '#ancestors' do
- it 'includes only the ancestors' do
- relation = described_class.new(Group.where(id: child2)).ancestors
+ describe '#ancestors' do
+ it 'includes only the ancestors' do
+ relation = described_class.new(Group.where(id: child2)).ancestors
- expect(relation).to contain_exactly(child1, parent)
- end
+ expect(relation).to contain_exactly(child1, parent)
+ end
- it 'can find ancestors upto a certain level' do
- relation = described_class.new(Group.where(id: child2)).ancestors(upto: child1)
+ it 'can find ancestors upto a certain level' do
+ relation = described_class.new(Group.where(id: child2)).ancestors(upto: child1)
- expect(relation).to be_empty
+ expect(relation).to be_empty
+ end
end
- end
- describe '#all_objects' do
- let(:relation) do
- described_class.new(Group.where(id: child1.id)).all_objects
- end
+ describe '#all_objects' do
+ let(:relation) do
+ described_class.new(Group.where(id: child1.id)).all_objects
+ end
- it 'includes the base rows' do
- expect(relation).to include(child1)
- end
+ it 'includes the base rows' do
+ expect(relation).to include(child1)
+ end
+
+ it 'includes the ancestors' do
+ expect(relation).to include(parent)
+ end
+
+ it 'includes the descendants' do
+ expect(relation).to include(child2)
+ end
+
+ it 'uses ancestors_base #initialize argument for ancestors' do
+ relation = described_class.new(Group.where(id: child1.id), Group.where(id: non_existing_record_id)).all_objects
+
+ expect(relation).to include(parent)
+ end
- it 'includes the ancestors' do
- expect(relation).to include(parent)
+ it 'uses descendants_base #initialize argument for descendants' do
+ relation = described_class.new(Group.where(id: non_existing_record_id), Group.where(id: child1.id)).all_objects
+
+ expect(relation).to include(child2)
+ end
+
+ it 'does not allow the use of #update_all' do
+ expect { relation.update_all(share_with_group_lock: false) }
+ .to raise_error(ActiveRecord::ReadOnlyRecord)
+ end
end
+ end
- it 'includes the descendants' do
- expect(relation).to include(child2)
+ context 'when the use_distinct_in_object_hierarchy feature flag is enabled' do
+ before do
+ stub_feature_flags(use_distinct_in_object_hierarchy: true)
end
- it 'uses ancestors_base #initialize argument for ancestors' do
- relation = described_class.new(Group.where(id: child1.id), Group.where(id: non_existing_record_id)).all_objects
+ it_behaves_like 'Gitlab::ObjectHierarchy test cases'
- expect(relation).to include(parent)
+ it 'calls DISTINCT' do
+ expect(parent.self_and_descendants.to_sql).to include("DISTINCT")
+ expect(child2.self_and_ancestors.to_sql).to include("DISTINCT")
end
+ end
- it 'uses descendants_base #initialize argument for descendants' do
- relation = described_class.new(Group.where(id: non_existing_record_id), Group.where(id: child1.id)).all_objects
-
- expect(relation).to include(child2)
+ context 'when the use_distinct_in_object_hierarchy feature flag is disabled' do
+ before do
+ stub_feature_flags(use_distinct_in_object_hierarchy: false)
end
- it 'does not allow the use of #update_all' do
- expect { relation.update_all(share_with_group_lock: false) }
- .to raise_error(ActiveRecord::ReadOnlyRecord)
+ it_behaves_like 'Gitlab::ObjectHierarchy test cases'
+
+ it 'does not call DISTINCT' do
+ expect(parent.self_and_descendants.to_sql).not_to include("DISTINCT")
+ expect(child2.self_and_ancestors.to_sql).not_to include("DISTINCT")
end
end
end
diff --git a/spec/lib/gitlab/optimistic_locking_spec.rb b/spec/lib/gitlab/optimistic_locking_spec.rb
index 0862a9c880e..1d669573b74 100644
--- a/spec/lib/gitlab/optimistic_locking_spec.rb
+++ b/spec/lib/gitlab/optimistic_locking_spec.rb
@@ -5,37 +5,108 @@ require 'spec_helper'
RSpec.describe Gitlab::OptimisticLocking do
let!(:pipeline) { create(:ci_pipeline) }
let!(:pipeline2) { Ci::Pipeline.find(pipeline.id) }
+ let(:histogram) { spy('prometheus metric') }
+
+ before do
+ allow(described_class)
+ .to receive(:retry_lock_histogram)
+ .and_return(histogram)
+ end
describe '#retry_lock' do
- it 'does not reload object if state changes' do
- expect(pipeline).not_to receive(:reset)
- expect(pipeline).to receive(:succeed).and_call_original
+ let(:name) { 'optimistic_locking_spec' }
- described_class.retry_lock(pipeline) do |subject|
- subject.succeed
+ context 'when state changed successfully without retries' do
+ subject do
+ described_class.retry_lock(pipeline, name: name) do |lock_subject|
+ lock_subject.succeed
+ end
end
- end
- it 'retries action if exception is raised' do
- pipeline.succeed
+ it 'does not reload object' do
+ expect(pipeline).not_to receive(:reset)
+ expect(pipeline).to receive(:succeed).and_call_original
+
+ subject
+ end
+
+ it 'does not create log record' do
+ expect(described_class.retry_lock_logger).not_to receive(:info)
+
+ subject
+ end
- expect(pipeline2).to receive(:reset).and_call_original
- expect(pipeline2).to receive(:drop).twice.and_call_original
+ it 'adds number of retries to histogram' do
+ subject
- described_class.retry_lock(pipeline2) do |subject|
- subject.drop
+ expect(histogram).to have_received(:observe).with({}, 0)
end
end
- it 'raises exception when too many retries' do
- expect(pipeline).to receive(:drop).twice.and_call_original
+ context 'when at least one retry happened, the change succeeded' do
+ subject do
+ described_class.retry_lock(pipeline2, name: 'optimistic_locking_spec') do |lock_subject|
+ lock_subject.drop
+ end
+ end
+
+ before do
+ pipeline.succeed
+ end
+
+ it 'completes the action' do
+ expect(pipeline2).to receive(:reset).and_call_original
+ expect(pipeline2).to receive(:drop).twice.and_call_original
+
+ subject
+ end
+
+ it 'creates a single log record' do
+ expect(described_class.retry_lock_logger)
+ .to receive(:info)
+ .once
+ .with(hash_including(:time_s, name: name, retries: 1))
- expect do
- described_class.retry_lock(pipeline, 1) do |subject|
- subject.lock_version = 100
- subject.drop
+ subject
+ end
+
+ it 'adds number of retries to histogram' do
+ subject
+
+ expect(histogram).to have_received(:observe).with({}, 1)
+ end
+ end
+
+ context 'when MAX_RETRIES attempts exceeded' do
+ subject do
+ described_class.retry_lock(pipeline, max_retries, name: name) do |lock_subject|
+ lock_subject.lock_version = 100
+ lock_subject.drop
end
- end.to raise_error(ActiveRecord::StaleObjectError)
+ end
+
+ let(:max_retries) { 2 }
+
+ it 'raises an exception' do
+ expect(pipeline).to receive(:drop).exactly(max_retries + 1).times.and_call_original
+
+ expect { subject }.to raise_error(ActiveRecord::StaleObjectError)
+ end
+
+ it 'creates a single log record' do
+ expect(described_class.retry_lock_logger)
+ .to receive(:info)
+ .once
+ .with(hash_including(:time_s, name: name, retries: max_retries))
+
+ expect { subject }.to raise_error(ActiveRecord::StaleObjectError)
+ end
+
+ it 'adds number of retries to histogram' do
+ expect { subject }.to raise_error(ActiveRecord::StaleObjectError)
+
+ expect(histogram).to have_received(:observe).with({}, max_retries)
+ end
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
new file mode 100644
index 00000000000..6e9e987f90c
--- /dev/null
+++ b/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb
@@ -0,0 +1,188 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Pagination::Keyset::ColumnOrderDefinition do
+ let_it_be(:project_name_column) do
+ described_class.new(
+ attribute_name: :name,
+ order_expression: Project.arel_table[:name].asc,
+ nullable: :not_nullable,
+ distinct: true
+ )
+ end
+
+ let_it_be(:project_name_lower_column) do
+ described_class.new(
+ attribute_name: :name,
+ order_expression: Project.arel_table[:name].lower.desc,
+ nullable: :not_nullable,
+ distinct: true
+ )
+ end
+
+ let_it_be(:project_calculated_column_expression) do
+ # COALESCE("projects"."description", 'No Description')
+ Arel::Nodes::NamedFunction.new('COALESCE', [
+ Project.arel_table[:description],
+ Arel.sql("'No Description'")
+ ])
+ end
+
+ let_it_be(:project_calculated_column) do
+ described_class.new(
+ attribute_name: :name,
+ column_expression: project_calculated_column_expression,
+ order_expression: project_calculated_column_expression.asc,
+ nullable: :not_nullable,
+ distinct: true
+ )
+ end
+
+ describe '#order_direction' do
+ context 'inferring order_direction from order_expression' do
+ it { expect(project_name_column).to be_ascending_order }
+ it { expect(project_name_column).not_to be_descending_order }
+
+ it { expect(project_name_lower_column).to be_descending_order }
+ it { expect(project_name_lower_column).not_to be_ascending_order }
+
+ it { expect(project_calculated_column).to be_ascending_order }
+ it { expect(project_calculated_column).not_to be_descending_order }
+
+ it 'raises error when order direction cannot be infered' do
+ expect do
+ described_class.new(
+ attribute_name: :name,
+ column_expression: Project.arel_table[:name],
+ order_expression: 'name asc',
+ reversed_order_expression: 'name desc',
+ nullable: :not_nullable,
+ distinct: true
+ )
+ end.to raise_error(RuntimeError, /Invalid or missing `order_direction`/)
+ end
+
+ it 'does not raise error when order direction is explicitly given' do
+ column_order_definition = described_class.new(
+ attribute_name: :name,
+ column_expression: Project.arel_table[:name],
+ order_expression: 'name asc',
+ reversed_order_expression: 'name desc',
+ order_direction: :asc,
+ nullable: :not_nullable,
+ distinct: true
+ )
+
+ expect(column_order_definition).to be_ascending_order
+ end
+ end
+ end
+
+ describe '#column_expression' do
+ context 'inferring column_expression from order_expression' do
+ it 'infers the correct column expression' do
+ column_order_definition = described_class.new(attribute_name: :name, order_expression: Project.arel_table[:name].asc)
+
+ expect(column_order_definition.column_expression).to eq(Project.arel_table[:name])
+ end
+
+ it 'raises error when raw string is given as order expression' do
+ expect do
+ described_class.new(attribute_name: :name, order_expression: 'name DESC')
+ end.to raise_error(RuntimeError, /Couldn't calculate the column expression. Please pass an ARel node/)
+ end
+ end
+ end
+
+ describe '#reversed_order_expression' do
+ it 'raises error when order cannot be reversed automatically' do
+ expect do
+ described_class.new(
+ attribute_name: :name,
+ column_expression: Project.arel_table[:name],
+ order_expression: 'name asc',
+ order_direction: :asc,
+ nullable: :not_nullable,
+ distinct: true
+ )
+ end.to raise_error(RuntimeError, /Couldn't determine reversed order/)
+ end
+ end
+
+ describe '#reverse' do
+ it { expect(project_name_column.reverse.order_expression).to eq(Project.arel_table[:name].desc) }
+ it { expect(project_name_column.reverse).to be_descending_order }
+
+ it { expect(project_calculated_column.reverse.order_expression).to eq(project_calculated_column_expression.desc) }
+ it { expect(project_calculated_column.reverse).to be_descending_order }
+
+ context 'when reversed_order_expression is given' do
+ it 'uses the given expression' do
+ column_order_definition = described_class.new(
+ attribute_name: :name,
+ column_expression: Project.arel_table[:name],
+ order_expression: 'name asc',
+ reversed_order_expression: 'name desc',
+ order_direction: :asc,
+ nullable: :not_nullable,
+ distinct: true
+ )
+
+ expect(column_order_definition.reverse.order_expression).to eq('name desc')
+ end
+ end
+ end
+
+ describe '#nullable' do
+ context 'when the column is nullable' do
+ let(:nulls_last_order) 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_direction: :desc,
+ nullable: :nulls_last, # null values are always last
+ distinct: false
+ )
+ end
+
+ it 'requires the position of the null values in the result' do
+ expect(nulls_last_order).to be_nulls_last
+ end
+
+ it 'reverses nullable correctly' do
+ expect(nulls_last_order.reverse).to be_nulls_first
+ end
+
+ it 'raises error when invalid nullable value is given' do
+ expect 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_direction: :desc,
+ nullable: true,
+ distinct: false
+ )
+ end.to raise_error(RuntimeError, /Invalid `nullable` is given/)
+ end
+
+ it 'raises error when the column is nullable and distinct' do
+ expect 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_direction: :desc,
+ nullable: :nulls_last,
+ distinct: true
+ )
+ end.to raise_error(RuntimeError, /Invalid column definition/)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/pagination/keyset/order_spec.rb b/spec/lib/gitlab/pagination/keyset/order_spec.rb
new file mode 100644
index 00000000000..665f790ee47
--- /dev/null
+++ b/spec/lib/gitlab/pagination/keyset/order_spec.rb
@@ -0,0 +1,420 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Pagination::Keyset::Order do
+ let(:table) { Arel::Table.new(:my_table) }
+ let(:order) { nil }
+
+ def run_query(query)
+ ActiveRecord::Base.connection.execute(query).to_a
+ end
+
+ def build_query(order:, where_conditions: nil, limit: nil)
+ <<-SQL
+ SELECT id, year, month
+ FROM (#{table_data}) my_table (id, year, month)
+ WHERE #{where_conditions || '1=1'}
+ ORDER BY #{order}
+ LIMIT #{limit || 999};
+ SQL
+ end
+
+ def iterate_and_collect(order:, page_size:, where_conditions: nil)
+ all_items = []
+
+ loop do
+ paginated_items = run_query(build_query(order: order, where_conditions: where_conditions, limit: page_size))
+ break if paginated_items.empty?
+
+ all_items.concat(paginated_items)
+ last_item = paginated_items.last
+ cursor_attributes = order.cursor_attributes_for_node(last_item)
+ where_conditions = order.build_where_values(cursor_attributes).to_sql
+ end
+
+ all_items
+ end
+
+ subject do
+ run_query(build_query(order: order))
+ end
+
+ shared_examples 'order examples' do
+ it { expect(subject).to eq(expected) }
+
+ context 'when paginating forwards' do
+ subject { iterate_and_collect(order: order, page_size: 2) }
+
+ it { expect(subject).to eq(expected) }
+
+ context 'with different page size' do
+ subject { iterate_and_collect(order: order, page_size: 5) }
+
+ it { expect(subject).to eq(expected) }
+ end
+ end
+
+ context 'when paginating backwards' do
+ subject do
+ last_item = expected.last
+ cursor_attributes = order.cursor_attributes_for_node(last_item)
+ where_conditions = order.reversed_order.build_where_values(cursor_attributes)
+
+ iterate_and_collect(order: order.reversed_order, page_size: 2, where_conditions: where_conditions.to_sql)
+ end
+
+ it do
+ expect(subject).to eq(expected.reverse[1..-1]) # removing one item because we used it to calculate cursor data for the "last" page in subject
+ end
+ end
+ end
+
+ context 'when ordering by a distinct column' do
+ let(:table_data) do
+ <<-SQL
+ VALUES (1, 0, 0),
+ (2, 0, 0),
+ (3, 0, 0),
+ (4, 0, 0),
+ (5, 0, 0),
+ (6, 0, 0),
+ (7, 0, 0),
+ (8, 0, 0),
+ (9, 0, 0)
+ SQL
+ end
+
+ let(:order) do
+ Gitlab::Pagination::Keyset::Order.build([
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id',
+ column_expression: table['id'],
+ order_expression: table['id'].desc,
+ nullable: :not_nullable,
+ distinct: true
+ )
+ ])
+ end
+
+ let(:expected) do
+ [
+ { "id" => 9, "year" => 0, "month" => 0 },
+ { "id" => 8, "year" => 0, "month" => 0 },
+ { "id" => 7, "year" => 0, "month" => 0 },
+ { "id" => 6, "year" => 0, "month" => 0 },
+ { "id" => 5, "year" => 0, "month" => 0 },
+ { "id" => 4, "year" => 0, "month" => 0 },
+ { "id" => 3, "year" => 0, "month" => 0 },
+ { "id" => 2, "year" => 0, "month" => 0 },
+ { "id" => 1, "year" => 0, "month" => 0 }
+ ]
+ end
+
+ it_behaves_like 'order examples'
+ end
+
+ context 'when ordering by two non-nullable columns and a distinct column' do
+ let(:table_data) do
+ <<-SQL
+ VALUES (1, 2010, 2),
+ (2, 2011, 1),
+ (3, 2009, 2),
+ (4, 2011, 1),
+ (5, 2011, 1),
+ (6, 2009, 2),
+ (7, 2010, 3),
+ (8, 2012, 4),
+ (9, 2013, 5)
+ SQL
+ end
+
+ let(:order) do
+ Gitlab::Pagination::Keyset::Order.build([
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'year',
+ column_expression: table['year'],
+ order_expression: table['year'].asc,
+ nullable: :not_nullable,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'month',
+ column_expression: table['month'],
+ order_expression: table['month'].asc,
+ nullable: :not_nullable,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id',
+ column_expression: table['id'],
+ order_expression: table['id'].asc,
+ nullable: :not_nullable,
+ distinct: true
+ )
+ ])
+ end
+
+ let(:expected) do
+ [
+ { 'year' => 2009, 'month' => 2, 'id' => 3 },
+ { 'year' => 2009, 'month' => 2, 'id' => 6 },
+ { 'year' => 2010, 'month' => 2, 'id' => 1 },
+ { 'year' => 2010, 'month' => 3, 'id' => 7 },
+ { 'year' => 2011, 'month' => 1, 'id' => 2 },
+ { 'year' => 2011, 'month' => 1, 'id' => 4 },
+ { 'year' => 2011, 'month' => 1, 'id' => 5 },
+ { 'year' => 2012, 'month' => 4, 'id' => 8 },
+ { 'year' => 2013, 'month' => 5, 'id' => 9 }
+ ]
+ end
+
+ it_behaves_like 'order examples'
+ end
+
+ context 'when ordering by nullable columns and a distinct column' do
+ let(:table_data) do
+ <<-SQL
+ VALUES (1, 2010, null),
+ (2, 2011, 2),
+ (3, null, null),
+ (4, null, 5),
+ (5, 2010, null),
+ (6, 2011, 2),
+ (7, 2010, 2),
+ (8, 2012, 2),
+ (9, null, 2),
+ (10, null, null),
+ (11, 2010, 2)
+ SQL
+ end
+
+ let(:order) do
+ Gitlab::Pagination::Keyset::Order.build([
+ 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_direction: :asc,
+ nullable: :nulls_last,
+ distinct: false
+ ),
+ 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_direction: :asc,
+ nullable: :nulls_last,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id',
+ column_expression: table['id'],
+ order_expression: table['id'].asc,
+ nullable: :not_nullable,
+ distinct: true
+ )
+ ])
+ end
+
+ let(:expected) do
+ [
+ { "id" => 7, "year" => 2010, "month" => 2 },
+ { "id" => 11, "year" => 2010, "month" => 2 },
+ { "id" => 1, "year" => 2010, "month" => nil },
+ { "id" => 5, "year" => 2010, "month" => nil },
+ { "id" => 2, "year" => 2011, "month" => 2 },
+ { "id" => 6, "year" => 2011, "month" => 2 },
+ { "id" => 8, "year" => 2012, "month" => 2 },
+ { "id" => 9, "year" => nil, "month" => 2 },
+ { "id" => 4, "year" => nil, "month" => 5 },
+ { "id" => 3, "year" => nil, "month" => nil },
+ { "id" => 10, "year" => nil, "month" => nil }
+ ]
+ end
+
+ it_behaves_like 'order examples'
+ end
+
+ context 'when ordering by nullable columns with nulls first ordering and a distinct column' do
+ let(:table_data) do
+ <<-SQL
+ VALUES (1, 2010, null),
+ (2, 2011, 2),
+ (3, null, null),
+ (4, null, 5),
+ (5, 2010, null),
+ (6, 2011, 2),
+ (7, 2010, 2),
+ (8, 2012, 2),
+ (9, null, 2),
+ (10, null, null),
+ (11, 2010, 2)
+ SQL
+ end
+
+ let(:order) do
+ Gitlab::Pagination::Keyset::Order.build([
+ 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_direction: :asc,
+ nullable: :nulls_first,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'month',
+ column_expression: table['month'],
+ order_expression: Gitlab::Database.nulls_first_order('month', :asc),
+ order_direction: :asc,
+ reversed_order_expression: Gitlab::Database.nulls_last_order('month', :desc),
+ nullable: :nulls_first,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id',
+ column_expression: table['id'],
+ order_expression: table['id'].asc,
+ nullable: :not_nullable,
+ distinct: true
+ )
+ ])
+ end
+
+ let(:expected) do
+ [
+ { "id" => 3, "year" => nil, "month" => nil },
+ { "id" => 10, "year" => nil, "month" => nil },
+ { "id" => 9, "year" => nil, "month" => 2 },
+ { "id" => 4, "year" => nil, "month" => 5 },
+ { "id" => 1, "year" => 2010, "month" => nil },
+ { "id" => 5, "year" => 2010, "month" => nil },
+ { "id" => 7, "year" => 2010, "month" => 2 },
+ { "id" => 11, "year" => 2010, "month" => 2 },
+ { "id" => 2, "year" => 2011, "month" => 2 },
+ { "id" => 6, "year" => 2011, "month" => 2 },
+ { "id" => 8, "year" => 2012, "month" => 2 }
+ ]
+ end
+
+ it_behaves_like 'order examples'
+ end
+
+ context 'when ordering by non-nullable columns with mixed directions and a distinct column' do
+ let(:table_data) do
+ <<-SQL
+ VALUES (1, 2010, 0),
+ (2, 2011, 0),
+ (3, 2010, 0),
+ (4, 2010, 0),
+ (5, 2012, 0),
+ (6, 2012, 0),
+ (7, 2010, 0),
+ (8, 2011, 0),
+ (9, 2013, 0),
+ (10, 2014, 0),
+ (11, 2013, 0)
+ SQL
+ end
+
+ let(:order) do
+ Gitlab::Pagination::Keyset::Order.build([
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'year',
+ column_expression: table['year'],
+ order_expression: table['year'].asc,
+ 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(:expected) do
+ [
+ { "id" => 7, "year" => 2010, "month" => 0 },
+ { "id" => 4, "year" => 2010, "month" => 0 },
+ { "id" => 3, "year" => 2010, "month" => 0 },
+ { "id" => 1, "year" => 2010, "month" => 0 },
+ { "id" => 8, "year" => 2011, "month" => 0 },
+ { "id" => 2, "year" => 2011, "month" => 0 },
+ { "id" => 6, "year" => 2012, "month" => 0 },
+ { "id" => 5, "year" => 2012, "month" => 0 },
+ { "id" => 11, "year" => 2013, "month" => 0 },
+ { "id" => 9, "year" => 2013, "month" => 0 },
+ { "id" => 10, "year" => 2014, "month" => 0 }
+ ]
+ end
+
+ it 'takes out a slice between two cursors' do
+ after_cursor = { "id" => 8, "year" => 2011 }
+ before_cursor = { "id" => 5, "year" => 2012 }
+
+ after_conditions = order.build_where_values(after_cursor)
+ reversed = order.reversed_order
+ before_conditions = reversed.build_where_values(before_cursor)
+
+ query = build_query(order: order, where_conditions: "(#{after_conditions.to_sql}) AND (#{before_conditions.to_sql})", limit: 100)
+
+ expect(run_query(query)).to eq([
+ { "id" => 2, "year" => 2011, "month" => 0 },
+ { "id" => 6, "year" => 2012, "month" => 0 }
+ ])
+ end
+ end
+
+ context 'when the passed cursor values do not match with the order definition' do
+ let(:order) do
+ Gitlab::Pagination::Keyset::Order.build([
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'year',
+ column_expression: table['year'],
+ order_expression: table['year'].asc,
+ 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
+
+ context 'when values are missing' do
+ it 'raises error' do
+ expect { order.build_where_values(id: 1) }.to raise_error(/Missing items: year/)
+ end
+ end
+
+ context 'when extra values are present' do
+ it 'raises error' do
+ expect { order.build_where_values(id: 1, year: 2, foo: 3) }.to raise_error(/Extra items: foo/)
+ end
+ end
+
+ context 'when values are missing and extra values are present' do
+ it 'raises error' do
+ expect { order.build_where_values(year: 2, foo: 3) }.to raise_error(/Extra items: foo\. Missing items: id/)
+ end
+ end
+
+ context 'when no values are passed' do
+ it 'returns nil' do
+ expect(order.build_where_values({})).to eq(nil)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/query_limiting/active_support_subscriber_spec.rb b/spec/lib/gitlab/query_limiting/active_support_subscriber_spec.rb
index a8dd482c7b8..1ab8e22d6d1 100644
--- a/spec/lib/gitlab/query_limiting/active_support_subscriber_spec.rb
+++ b/spec/lib/gitlab/query_limiting/active_support_subscriber_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::QueryLimiting::ActiveSupportSubscriber do
- let(:transaction) { instance_double(Gitlab::QueryLimiting::Transaction, increment: true) }
+ let(:transaction) { instance_double(Gitlab::QueryLimiting::Transaction, executed_sql: true, increment: true) }
before do
allow(Gitlab::QueryLimiting::Transaction)
@@ -18,6 +18,11 @@ RSpec.describe Gitlab::QueryLimiting::ActiveSupportSubscriber do
expect(transaction)
.to have_received(:increment)
.once
+
+ expect(transaction)
+ .to have_received(:executed_sql)
+ .once
+ .with(String)
end
context 'when the query is actually a rails cache hit' do
@@ -30,6 +35,11 @@ RSpec.describe Gitlab::QueryLimiting::ActiveSupportSubscriber do
expect(transaction)
.to have_received(:increment)
.once
+
+ expect(transaction)
+ .to have_received(:executed_sql)
+ .once
+ .with(String)
end
end
end
diff --git a/spec/lib/gitlab/query_limiting/transaction_spec.rb b/spec/lib/gitlab/query_limiting/transaction_spec.rb
index 331c3c1d8b0..40804736b86 100644
--- a/spec/lib/gitlab/query_limiting/transaction_spec.rb
+++ b/spec/lib/gitlab/query_limiting/transaction_spec.rb
@@ -118,6 +118,30 @@ RSpec.describe Gitlab::QueryLimiting::Transaction do
)
end
+ it 'includes a list of executed queries' do
+ transaction = described_class.new
+ transaction.count = max = described_class::THRESHOLD
+ %w[foo bar baz].each { |sql| transaction.executed_sql(sql) }
+
+ message = transaction.error_message
+
+ expect(message).to start_with(
+ "Too many SQL queries were executed: a maximum of #{max} " \
+ "is allowed but #{max} SQL queries were executed"
+ )
+
+ expect(message).to include("0: foo", "1: bar", "2: baz")
+ end
+
+ it 'indicates if the log is truncated' do
+ transaction = described_class.new
+ transaction.count = described_class::THRESHOLD * 2
+
+ message = transaction.error_message
+
+ expect(message).to end_with('...')
+ end
+
it 'includes the action name in the error message when present' do
transaction = described_class.new
transaction.count = max = described_class::THRESHOLD
diff --git a/spec/lib/gitlab/query_limiting_spec.rb b/spec/lib/gitlab/query_limiting_spec.rb
index 0fcd865567d..4f70c65adca 100644
--- a/spec/lib/gitlab/query_limiting_spec.rb
+++ b/spec/lib/gitlab/query_limiting_spec.rb
@@ -63,6 +63,20 @@ RSpec.describe Gitlab::QueryLimiting do
expect(transaction.count).to eq(before)
end
+
+ it 'whitelists when enabled' do
+ described_class.whitelist('https://example.com')
+
+ expect(transaction.whitelisted).to eq(true)
+ end
+
+ it 'does not whitelist when disabled' do
+ allow(described_class).to receive(:enable?).and_return(false)
+
+ described_class.whitelist('https://example.com')
+
+ expect(transaction.whitelisted).to eq(false)
+ end
end
end
end
diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb
index 776ca81a338..1aca3dae41b 100644
--- a/spec/lib/gitlab/regex_spec.rb
+++ b/spec/lib/gitlab/regex_spec.rb
@@ -367,6 +367,35 @@ RSpec.describe Gitlab::Regex do
it { is_expected.not_to match('%2e%2e%2f1.2.3') }
end
+ describe '.npm_package_name_regex' do
+ subject { described_class.npm_package_name_regex }
+
+ it { is_expected.to match('@scope/package') }
+ it { is_expected.to match('unscoped-package') }
+ it { is_expected.not_to match('@first-scope@second-scope/package') }
+ it { is_expected.not_to match('scope-without-at-symbol/package') }
+ it { is_expected.not_to match('@not-a-scoped-package') }
+ it { is_expected.not_to match('@scope/sub/package') }
+ it { is_expected.not_to match('@scope/../../package') }
+ it { is_expected.not_to match('@scope%2e%2e%2fpackage') }
+ it { is_expected.not_to match('@%2e%2e%2f/package') }
+
+ context 'capturing group' do
+ [
+ ['@scope/package', 'scope'],
+ ['unscoped-package', nil],
+ ['@not-a-scoped-package', nil],
+ ['@scope/sub/package', nil],
+ ['@inv@lid-scope/package', nil]
+ ].each do |package_name, extracted_scope_name|
+ it "extracts the scope name for #{package_name}" do
+ match = package_name.match(described_class.npm_package_name_regex)
+ expect(match&.captures&.first).to eq(extracted_scope_name)
+ end
+ end
+ end
+ end
+
describe '.nuget_version_regex' do
subject { described_class.nuget_version_regex }
diff --git a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb
index e58e41d3e4f..71f4f2a3b64 100644
--- a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb
@@ -2,6 +2,7 @@
require 'spec_helper'
+# rubocop: disable RSpec/MultipleMemoizedHelpers
RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do
context "with worker attribution" do
subject { described_class.new }
@@ -112,6 +113,14 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do
expect { |b| subject.call(worker, job, :test, &b) }.to yield_control.once
end
+ it 'calls BackgroundTransaction' do
+ expect_next_instance_of(Gitlab::Metrics::BackgroundTransaction) do |instance|
+ expect(instance).to receive(:run)
+ end
+
+ subject.call(worker, job, :test) {}
+ end
+
it 'sets queue specific metrics' do
expect(running_jobs_metric).to receive(:increment).with(labels, -1)
expect(running_jobs_metric).to receive(:increment).with(labels, 1)
@@ -287,3 +296,4 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do
end
end
end
+# rubocop: enable RSpec/MultipleMemoizedHelpers
diff --git a/spec/lib/gitlab/sidekiq_middleware/size_limiter/client_spec.rb b/spec/lib/gitlab/sidekiq_middleware/size_limiter/client_spec.rb
new file mode 100644
index 00000000000..df8e47d60f0
--- /dev/null
+++ b/spec/lib/gitlab/sidekiq_middleware/size_limiter/client_spec.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Client, :clean_gitlab_redis_queues do
+ let(:worker_class) do
+ Class.new do
+ def self.name
+ "TestSizeLimiterWorker"
+ end
+
+ include ApplicationWorker
+
+ def perform(*args); end
+ end
+ end
+
+ before do
+ stub_const("TestSizeLimiterWorker", worker_class)
+ end
+
+ describe '#call' do
+ context 'when the validator rejects the job' do
+ before do
+ allow(Gitlab::SidekiqMiddleware::SizeLimiter::Validator).to receive(:validate!).and_raise(
+ Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError.new(
+ TestSizeLimiterWorker, 500, 300
+ )
+ )
+ end
+
+ it 'raises an exception when scheduling job with #perform_at' do
+ expect do
+ TestSizeLimiterWorker.perform_at(30.seconds.from_now, 1, 2, 3)
+ end.to raise_error Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError
+ end
+
+ it 'raises an exception when scheduling job with #perform_async' do
+ expect do
+ TestSizeLimiterWorker.perform_async(1, 2, 3)
+ end.to raise_error Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError
+ end
+
+ it 'raises an exception when scheduling job with #perform_in' do
+ expect do
+ TestSizeLimiterWorker.perform_in(3.seconds, 1, 2, 3)
+ end.to raise_error Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError
+ end
+ end
+
+ context 'when the validator validates the job suscessfully' do
+ before do
+ # Do nothing
+ allow(Gitlab::SidekiqMiddleware::SizeLimiter::Client).to receive(:validate!)
+ end
+
+ it 'raises an exception when scheduling job with #perform_at' do
+ expect do
+ TestSizeLimiterWorker.perform_at(30.seconds.from_now, 1, 2, 3)
+ end.not_to raise_error
+
+ expect(TestSizeLimiterWorker.jobs).to contain_exactly(
+ a_hash_including(
+ "class" => "TestSizeLimiterWorker",
+ "args" => [1, 2, 3],
+ "at" => be_a(Float)
+ )
+ )
+ end
+
+ it 'raises an exception when scheduling job with #perform_async' do
+ expect do
+ TestSizeLimiterWorker.perform_async(1, 2, 3)
+ end.not_to raise_error
+
+ expect(TestSizeLimiterWorker.jobs).to contain_exactly(
+ a_hash_including(
+ "class" => "TestSizeLimiterWorker",
+ "args" => [1, 2, 3]
+ )
+ )
+ end
+
+ it 'raises an exception when scheduling job with #perform_in' do
+ expect do
+ TestSizeLimiterWorker.perform_in(3.seconds, 1, 2, 3)
+ end.not_to raise_error
+
+ expect(TestSizeLimiterWorker.jobs).to contain_exactly(
+ a_hash_including(
+ "class" => "TestSizeLimiterWorker",
+ "args" => [1, 2, 3],
+ "at" => be_a(Float)
+ )
+ )
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/sidekiq_middleware/size_limiter/exceed_limit_error_spec.rb b/spec/lib/gitlab/sidekiq_middleware/size_limiter/exceed_limit_error_spec.rb
new file mode 100644
index 00000000000..75b1d9fd87e
--- /dev/null
+++ b/spec/lib/gitlab/sidekiq_middleware/size_limiter/exceed_limit_error_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError do
+ let(:worker_class) do
+ Class.new do
+ def self.name
+ "TestSizeLimiterWorker"
+ end
+
+ include ApplicationWorker
+
+ def perform(*args); end
+ end
+ end
+
+ before do
+ stub_const("TestSizeLimiterWorker", worker_class)
+ end
+
+ it 'encapsulates worker info' do
+ exception = described_class.new(TestSizeLimiterWorker, 500, 300)
+
+ expect(exception.message).to eql("TestSizeLimiterWorker job exceeds payload size limit (500/300)")
+ expect(exception.worker_class).to eql(TestSizeLimiterWorker)
+ expect(exception.size).to be(500)
+ expect(exception.size_limit).to be(300)
+ expect(exception.sentry_extra_data).to eql(
+ worker_class: 'TestSizeLimiterWorker',
+ size: 500,
+ size_limit: 300
+ )
+ end
+end
diff --git a/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb b/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb
new file mode 100644
index 00000000000..3140686c908
--- /dev/null
+++ b/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb
@@ -0,0 +1,253 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do
+ let(:worker_class) do
+ Class.new do
+ def self.name
+ "TestSizeLimiterWorker"
+ end
+
+ include ApplicationWorker
+
+ def perform(*args); end
+ end
+ end
+
+ before do
+ stub_const("TestSizeLimiterWorker", worker_class)
+ end
+
+ describe '#initialize' do
+ context 'when the input mode is valid' do
+ it 'does not log a warning message' do
+ expect(::Sidekiq.logger).not_to receive(:warn)
+
+ described_class.new(TestSizeLimiterWorker, {}, mode: 'track')
+ described_class.new(TestSizeLimiterWorker, {}, mode: 'raise')
+ end
+ end
+
+ context 'when the input mode is invalid' do
+ it 'defaults to track mode and logs a warning message' do
+ expect(::Sidekiq.logger).to receive(:warn).with('Invalid Sidekiq size limiter mode: invalid. Fallback to track mode.')
+
+ validator = described_class.new(TestSizeLimiterWorker, {}, mode: 'invalid')
+
+ expect(validator.mode).to eql('track')
+ end
+ end
+
+ context 'when the input mode is empty' do
+ it 'defaults to track mode' do
+ expect(::Sidekiq.logger).not_to receive(:warn)
+
+ validator = described_class.new(TestSizeLimiterWorker, {})
+
+ expect(validator.mode).to eql('track')
+ end
+ end
+
+ context 'when the size input is valid' do
+ it 'does not log a warning message' do
+ expect(::Sidekiq.logger).not_to receive(:warn)
+
+ described_class.new(TestSizeLimiterWorker, {}, size_limit: 300)
+ described_class.new(TestSizeLimiterWorker, {}, size_limit: 0)
+ end
+ end
+
+ context 'when the size input is invalid' do
+ it 'defaults to 0 and logs a warning message' do
+ expect(::Sidekiq.logger).to receive(:warn).with('Invalid Sidekiq size limiter limit: -1')
+
+ described_class.new(TestSizeLimiterWorker, {}, size_limit: -1)
+ end
+ end
+
+ context 'when the size input is empty' do
+ it 'defaults to 0' do
+ expect(::Sidekiq.logger).not_to receive(:warn)
+
+ validator = described_class.new(TestSizeLimiterWorker, {})
+
+ expect(validator.size_limit).to be(0)
+ end
+ end
+ end
+
+ shared_examples 'validate limit job payload size' do
+ context 'in track mode' do
+ let(:mode) { 'track' }
+
+ context 'when size limit negative' do
+ let(:size_limit) { -1 }
+
+ it 'does not track jobs' do
+ expect(Gitlab::ErrorTracking).not_to receive(:track_exception)
+
+ validate.call(TestSizeLimiterWorker, { a: 'a' * 300 })
+ end
+
+ it 'does not raise exception' do
+ expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error
+ end
+ end
+
+ context 'when size limit is 0' do
+ let(:size_limit) { 0 }
+
+ it 'does not track jobs' do
+ expect(Gitlab::ErrorTracking).not_to receive(:track_exception)
+
+ validate.call(TestSizeLimiterWorker, { a: 'a' * 300 })
+ end
+
+ it 'does not raise exception' do
+ expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error
+ end
+ end
+
+ context 'when job size is bigger than size limit' do
+ let(:size_limit) { 50 }
+
+ it 'tracks job' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
+ be_a(Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError)
+ )
+
+ validate.call(TestSizeLimiterWorker, { a: 'a' * 100 })
+ end
+
+ it 'does not raise an exception' do
+ expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error
+ end
+
+ context 'when the worker has big_payload attribute' do
+ before do
+ worker_class.big_payload!
+ end
+
+ it 'does not track jobs' do
+ expect(Gitlab::ErrorTracking).not_to receive(:track_exception)
+
+ validate.call(TestSizeLimiterWorker, { a: 'a' * 300 })
+ validate.call('TestSizeLimiterWorker', { a: 'a' * 300 })
+ end
+
+ it 'does not raise an exception' do
+ expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error
+ expect { validate.call('TestSizeLimiterWorker', { a: 'a' * 300 }) }.not_to raise_error
+ end
+ end
+ end
+
+ context 'when job size is less than size limit' do
+ let(:size_limit) { 50 }
+
+ it 'does not track job' do
+ expect(Gitlab::ErrorTracking).not_to receive(:track_exception)
+
+ validate.call(TestSizeLimiterWorker, { a: 'a' })
+ end
+
+ it 'does not raise an exception' do
+ expect { validate.call(TestSizeLimiterWorker, { a: 'a' }) }.not_to raise_error
+ end
+ end
+ end
+
+ context 'in raise mode' do
+ let(:mode) { 'raise' }
+
+ context 'when size limit is negative' do
+ let(:size_limit) { -1 }
+
+ it 'does not raise exception' do
+ expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error
+ end
+ end
+
+ context 'when size limit is 0' do
+ let(:size_limit) { 0 }
+
+ it 'does not raise exception' do
+ expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error
+ end
+ end
+
+ context 'when job size is bigger than size limit' do
+ let(:size_limit) { 50 }
+
+ it 'raises an exception' do
+ expect do
+ validate.call(TestSizeLimiterWorker, { a: 'a' * 300 })
+ end.to raise_error(
+ Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError,
+ /TestSizeLimiterWorker job exceeds payload size limit/i
+ )
+ end
+
+ context 'when the worker has big_payload attribute' do
+ before do
+ worker_class.big_payload!
+ end
+
+ it 'does not raise an exception' do
+ expect { validate.call(TestSizeLimiterWorker, { a: 'a' * 300 }) }.not_to raise_error
+ expect { validate.call('TestSizeLimiterWorker', { a: 'a' * 300 }) }.not_to raise_error
+ end
+ end
+ end
+
+ context 'when job size is less than size limit' do
+ let(:size_limit) { 50 }
+
+ it 'does not raise an exception' do
+ expect { validate.call(TestSizeLimiterWorker, { a: 'a' }) }.not_to raise_error
+ end
+ end
+ end
+ end
+
+ describe '#validate!' do
+ context 'when calling SizeLimiter.validate!' do
+ let(:validate) { ->(worker_clas, job) { described_class.validate!(worker_class, job) } }
+
+ before do
+ stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_MODE', mode)
+ stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_LIMIT_BYTES', size_limit)
+ end
+
+ it_behaves_like 'validate limit job payload size'
+ end
+
+ context 'when creating an instance with the related ENV variables' do
+ let(:validate) do
+ ->(worker_clas, job) do
+ validator = described_class.new(worker_class, job, mode: mode, size_limit: size_limit)
+ validator.validate!
+ end
+ end
+
+ before do
+ stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_MODE', mode)
+ stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_LIMIT_BYTES', size_limit)
+ end
+
+ it_behaves_like 'validate limit job payload size'
+ end
+
+ context 'when creating an instance with mode and size limit' do
+ let(:validate) do
+ ->(worker_clas, job) do
+ validator = described_class.new(worker_class, job, mode: mode, size_limit: size_limit)
+ validator.validate!
+ end
+ end
+
+ it_behaves_like 'validate limit job payload size'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/sidekiq_middleware_spec.rb b/spec/lib/gitlab/sidekiq_middleware_spec.rb
index b632fc8bad2..755f6004e52 100644
--- a/spec/lib/gitlab/sidekiq_middleware_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware_spec.rb
@@ -177,6 +177,7 @@ RSpec.describe Gitlab::SidekiqMiddleware do
::Gitlab::SidekiqMiddleware::DuplicateJobs::Client,
::Gitlab::SidekiqStatus::ClientMiddleware,
::Gitlab::SidekiqMiddleware::AdminMode::Client,
+ ::Gitlab::SidekiqMiddleware::SizeLimiter::Client,
::Gitlab::SidekiqMiddleware::ClientMetrics
]
end
diff --git a/spec/lib/gitlab/string_range_marker_spec.rb b/spec/lib/gitlab/string_range_marker_spec.rb
index 52fab6e3109..6f63c8e2df4 100644
--- a/spec/lib/gitlab/string_range_marker_spec.rb
+++ b/spec/lib/gitlab/string_range_marker_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe Gitlab::StringRangeMarker do
raw = 'abc <def>'
inline_diffs = [2..5]
- described_class.new(raw, rich).mark(inline_diffs) do |text, left:, right:|
+ described_class.new(raw, rich).mark(inline_diffs) do |text, left:, right:, mode:|
"LEFT#{text}RIGHT".html_safe
end
end
diff --git a/spec/lib/gitlab/string_regex_marker_spec.rb b/spec/lib/gitlab/string_regex_marker_spec.rb
index 2dadd222820..a02be83558c 100644
--- a/spec/lib/gitlab/string_regex_marker_spec.rb
+++ b/spec/lib/gitlab/string_regex_marker_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Gitlab::StringRegexMarker do
let(:rich) { %{<span class="key">"name"</span><span class="punctuation">: </span><span class="value">"AFNetworking"</span>}.html_safe }
subject do
- described_class.new(raw, rich).mark(/"[^"]+":\s*"(?<name>[^"]+)"/, group: :name) do |text, left:, right:|
+ described_class.new(raw, rich).mark(/"[^"]+":\s*"(?<name>[^"]+)"/, group: :name) do |text, left:, right:, mode:|
%{<a href="#">#{text}</a>}.html_safe
end
end
@@ -25,7 +25,7 @@ RSpec.describe Gitlab::StringRegexMarker do
let(:rich) { %{a &lt;b&gt; &lt;c&gt; d}.html_safe }
subject do
- described_class.new(raw, rich).mark(/<[a-z]>/) do |text, left:, right:|
+ described_class.new(raw, rich).mark(/<[a-z]>/) do |text, left:, right:, mode:|
%{<strong>#{text}</strong>}.html_safe
end
end
diff --git a/spec/lib/gitlab/tracking/standard_context_spec.rb b/spec/lib/gitlab/tracking/standard_context_spec.rb
index 7a0a4f0cc46..561edbd38f8 100644
--- a/spec/lib/gitlab/tracking/standard_context_spec.rb
+++ b/spec/lib/gitlab/tracking/standard_context_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe Gitlab::Tracking::StandardContext do
context 'staging' do
before do
- allow(Gitlab).to receive(:staging?).and_return(true)
+ stub_config_setting(url: 'https://staging.gitlab.com')
end
include_examples 'contains environment', 'staging'
@@ -30,11 +30,27 @@ RSpec.describe Gitlab::Tracking::StandardContext do
context 'production' do
before do
- allow(Gitlab).to receive(:com_and_canary?).and_return(true)
+ stub_config_setting(url: 'https://gitlab.com')
end
include_examples 'contains environment', 'production'
end
+
+ context 'org' do
+ before do
+ stub_config_setting(url: 'https://dev.gitlab.org')
+ end
+
+ include_examples 'contains environment', 'org'
+ end
+
+ context 'other self-managed instance' do
+ before do
+ stub_rails_env('production')
+ end
+
+ include_examples 'contains environment', 'self-managed'
+ end
end
it 'contains source' do
diff --git a/spec/lib/gitlab/tracking_spec.rb b/spec/lib/gitlab/tracking_spec.rb
index 80740c8112e..ac052bd7a80 100644
--- a/spec/lib/gitlab/tracking_spec.rb
+++ b/spec/lib/gitlab/tracking_spec.rb
@@ -61,8 +61,8 @@ RSpec.describe Gitlab::Tracking do
expect(args[:property]).to eq('property')
expect(args[:value]).to eq(1.5)
expect(args[:context].length).to eq(2)
- expect(args[:context].first).to eq(other_context)
- expect(args[:context].last.to_json[:schema]).to eq(Gitlab::Tracking::StandardContext::GITLAB_STANDARD_SCHEMA_URL)
+ expect(args[:context].first.to_json[:schema]).to eq(Gitlab::Tracking::StandardContext::GITLAB_STANDARD_SCHEMA_URL)
+ expect(args[:context].last).to eq(other_context)
end
described_class.event('category', 'action', label: 'label', property: 'property', value: 1.5,
diff --git a/spec/lib/gitlab/tree_summary_spec.rb b/spec/lib/gitlab/tree_summary_spec.rb
index d2c5844b0fa..661ef507a82 100644
--- a/spec/lib/gitlab/tree_summary_spec.rb
+++ b/spec/lib/gitlab/tree_summary_spec.rb
@@ -57,14 +57,12 @@ RSpec.describe Gitlab::TreeSummary do
context 'with caching', :use_clean_rails_memory_store_caching do
subject { Rails.cache.fetch(key) }
- before do
- summarized
- end
-
context 'Repository tree cache' do
let(:key) { ['projects', project.id, 'content', commit.id, path] }
it 'creates a cache for repository content' do
+ summarized
+
is_expected.to eq([{ file_name: 'a.txt', type: :blob }])
end
end
@@ -72,11 +70,34 @@ RSpec.describe Gitlab::TreeSummary do
context 'Commits list cache' do
let(:offset) { 0 }
let(:limit) { 25 }
- let(:key) { ['projects', project.id, 'last_commits_list', commit.id, path, offset, limit] }
+ let(:key) { ['projects', project.id, 'last_commits', commit.id, path, offset, limit] }
it 'creates a cache for commits list' do
+ summarized
+
is_expected.to eq('a.txt' => commit.to_hash)
end
+
+ context 'when commit has a very long message' do
+ before do
+ repo.create_file(
+ project.creator,
+ 'long.txt',
+ '',
+ message: message,
+ branch_name: project.default_branch_or_master
+ )
+ end
+
+ let(:message) { 'a' * 1025 }
+ let(:expected_message) { message[0...1021] + '...' }
+
+ it 'truncates commit message to 1 kilobyte' do
+ summarized
+
+ is_expected.to include('long.txt' => a_hash_including(message: expected_message))
+ end
+ end
end
end
end
diff --git a/spec/lib/gitlab/url_blocker_spec.rb b/spec/lib/gitlab/url_blocker_spec.rb
index fa01d4e48df..e076815c4f6 100644
--- a/spec/lib/gitlab/url_blocker_spec.rb
+++ b/spec/lib/gitlab/url_blocker_spec.rb
@@ -166,7 +166,7 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
let(:ports) { Project::VALID_IMPORT_PORTS }
it 'allows imports from configured web host and port' do
- import_url = "http://#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}/t.git"
+ import_url = "http://#{Gitlab.host_with_port}/t.git"
expect(described_class.blocked_url?(import_url)).to be false
end
@@ -190,7 +190,7 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
end
it 'returns true for bad protocol on configured web/SSH host and ports' do
- web_url = "javascript://#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}/t.git%0aalert(1)"
+ web_url = "javascript://#{Gitlab.host_with_port}/t.git%0aalert(1)"
expect(described_class.blocked_url?(web_url)).to be true
ssh_url = "javascript://#{Gitlab.config.gitlab_shell.ssh_host}:#{Gitlab.config.gitlab_shell.ssh_port}/t.git%0aalert(1)"
diff --git a/spec/lib/gitlab/usage/docs/renderer_spec.rb b/spec/lib/gitlab/usage/docs/renderer_spec.rb
index 0677aa2d9d7..f3b83a4a4b3 100644
--- a/spec/lib/gitlab/usage/docs/renderer_spec.rb
+++ b/spec/lib/gitlab/usage/docs/renderer_spec.rb
@@ -2,19 +2,21 @@
require 'spec_helper'
+CODE_REGEX = %r{<code>(.*)</code>}.freeze
+
RSpec.describe Gitlab::Usage::Docs::Renderer do
describe 'contents' do
let(:dictionary_path) { Gitlab::Usage::Docs::Renderer::DICTIONARY_PATH }
- let(:items) { Gitlab::Usage::MetricDefinition.definitions }
+ let(:items) { Gitlab::Usage::MetricDefinition.definitions.first(10).to_h }
it 'generates dictionary for given items' do
generated_dictionary = described_class.new(items).contents
+
generated_dictionary_keys = RDoc::Markdown
.parse(generated_dictionary)
.table_of_contents
- .select { |metric_doc| metric_doc.level == 2 && !metric_doc.text.start_with?('info:') }
- .map(&:text)
- .map { |text| text.sub('<code>', '').sub('</code>', '') }
+ .select { |metric_doc| metric_doc.level == 3 }
+ .map { |item| item.text.match(CODE_REGEX)&.captures&.first }
expect(generated_dictionary_keys).to match_array(items.keys)
end
diff --git a/spec/lib/gitlab/usage/docs/value_formatter_spec.rb b/spec/lib/gitlab/usage/docs/value_formatter_spec.rb
index 7002c76a7cf..f21656df894 100644
--- a/spec/lib/gitlab/usage/docs/value_formatter_spec.rb
+++ b/spec/lib/gitlab/usage/docs/value_formatter_spec.rb
@@ -10,11 +10,11 @@ RSpec.describe Gitlab::Usage::Docs::ValueFormatter do
:data_source | 'redis' | 'Redis'
:data_source | 'ruby' | 'Ruby'
:introduced_by_url | 'http://test.com' | '[Introduced by](http://test.com)'
- :tier | %w(gold premium) | 'gold, premium'
- :distribution | %w(ce ee) | 'ce, ee'
+ :tier | %w(gold premium) | ' `gold`, `premium`'
+ :distribution | %w(ce ee) | ' `ce`, `ee`'
:key_path | 'key.path' | '**`key.path`**'
:milestone | '13.4' | '13.4'
- :status | 'data_available' | 'data_available'
+ :status | 'data_available' | '`data_available`'
end
with_them do
diff --git a/spec/lib/gitlab/usage/metrics/aggregates/aggregate_spec.rb b/spec/lib/gitlab/usage/metrics/aggregates/aggregate_spec.rb
index 5469ded18f9..7d8e3056384 100644
--- a/spec/lib/gitlab/usage/metrics/aggregates/aggregate_spec.rb
+++ b/spec/lib/gitlab/usage/metrics/aggregates/aggregate_spec.rb
@@ -9,10 +9,50 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi
let(:entity4) { '8b9a2671-2abf-4bec-a682-22f6a8f7bf31' }
let(:end_date) { Date.current }
let(:sources) { Gitlab::Usage::Metrics::Aggregates::Sources }
+ let(:namespace) { described_class.to_s.deconstantize.constantize }
let_it_be(:recorded_at) { Time.current.to_i }
+ def aggregated_metric(name:, time_frame:, source: "redis", events: %w[event1 event2 event3], operator: "OR", feature_flag: nil)
+ {
+ name: name,
+ source: source,
+ events: events,
+ operator: operator,
+ time_frame: time_frame,
+ feature_flag: feature_flag
+ }.compact.with_indifferent_access
+ end
+
context 'aggregated_metrics_data' do
+ shared_examples 'db sourced aggregated metrics without database_sourced_aggregated_metrics feature' do
+ before do
+ allow_next_instance_of(described_class) do |instance|
+ allow(instance).to receive(:aggregated_metrics).and_return(aggregated_metrics)
+ end
+ end
+
+ context 'with disabled database_sourced_aggregated_metrics feature flag' do
+ before do
+ stub_feature_flags(database_sourced_aggregated_metrics: false)
+ end
+
+ let(:aggregated_metrics) do
+ [
+ aggregated_metric(name: "gmau_2", source: "database", time_frame: time_frame)
+ ]
+ end
+
+ it 'skips database sourced metrics', :aggregate_failures do
+ results = {}
+ params = { start_date: start_date, end_date: end_date, recorded_at: recorded_at }
+
+ expect(sources::PostgresHll).not_to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2 event3]))
+ expect(aggregated_metrics_data).to eq(results)
+ end
+ end
+ end
+
shared_examples 'aggregated_metrics_data' do
context 'no aggregated metric is defined' do
it 'returns empty hash' do
@@ -31,37 +71,13 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi
end
end
- context 'with disabled database_sourced_aggregated_metrics feature flag' do
- before do
- stub_feature_flags(database_sourced_aggregated_metrics: false)
- end
-
- let(:aggregated_metrics) do
- [
- { name: 'gmau_1', source: 'redis', events: %w[event3 event5], operator: "OR" },
- { name: 'gmau_2', source: 'database', events: %w[event1 event2 event3], operator: "OR" }
- ].map(&:with_indifferent_access)
- end
-
- it 'skips database sourced metrics', :aggregate_failures do
- results = {
- 'gmau_1' => 5
- }
-
- params = { start_date: start_date, end_date: end_date, recorded_at: recorded_at }
-
- expect(sources::RedisHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event3 event5])).and_return(5)
- expect(sources::PostgresHll).not_to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2 event3]))
- expect(aggregated_metrics_data).to eq(results)
- end
- end
-
context 'with AND operator' do
let(:aggregated_metrics) do
+ params = { source: datasource, operator: "AND", time_frame: time_frame }
[
- { name: 'gmau_1', source: 'redis', events: %w[event3 event5], operator: "AND" },
- { name: 'gmau_2', source: 'database', events: %w[event1 event2 event3], operator: "AND" }
- ].map(&:with_indifferent_access)
+ aggregated_metric(**params.merge(name: "gmau_1", events: %w[event3 event5])),
+ aggregated_metric(**params.merge(name: "gmau_2"))
+ ]
end
it 'returns the number of unique events recorded for every metric in aggregate', :aggregate_failures do
@@ -73,30 +89,30 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi
# gmau_1 data is as follow
# |A| => 4
- expect(sources::RedisHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event3')).and_return(4)
+ expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event3')).and_return(4)
# |B| => 6
- expect(sources::RedisHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event5')).and_return(6)
+ expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event5')).and_return(6)
# |A + B| => 8
- expect(sources::RedisHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event3 event5])).and_return(8)
+ expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event3 event5])).and_return(8)
# Exclusion inclusion principle formula to calculate intersection of 2 sets
# |A & B| = (|A| + |B|) - |A + B| => (4 + 6) - 8 => 2
# gmau_2 data is as follow:
# |A| => 2
- expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event1')).and_return(2)
+ expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event1')).and_return(2)
# |B| => 3
- expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event2')).and_return(3)
+ expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event2')).and_return(3)
# |C| => 5
- expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event3')).and_return(5)
+ expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: 'event3')).and_return(5)
# |A + B| => 4 therefore |A & B| = (|A| + |B|) - |A + B| => 2 + 3 - 4 => 1
- expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2])).and_return(4)
+ expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2])).and_return(4)
# |A + C| => 6 therefore |A & C| = (|A| + |C|) - |A + C| => 2 + 5 - 6 => 1
- expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event3])).and_return(6)
+ expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event3])).and_return(6)
# |B + C| => 7 therefore |B & C| = (|B| + |C|) - |B + C| => 3 + 5 - 7 => 1
- expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event2 event3])).and_return(7)
+ expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event2 event3])).and_return(7)
# |A + B + C| => 8
- expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2 event3])).and_return(8)
+ expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2 event3])).and_return(8)
# Exclusion inclusion principle formula to calculate intersection of 3 sets
# |A & B & C| = (|A & B| + |A & C| + |B & C|) - (|A| + |B| + |C|) + |A + B + C|
# (1 + 1 + 1) - (2 + 3 + 5) + 8 => 1
@@ -108,20 +124,17 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi
context 'with OR operator' do
let(:aggregated_metrics) do
[
- { name: 'gmau_1', source: 'redis', events: %w[event3 event5], operator: "OR" },
- { name: 'gmau_2', source: 'database', events: %w[event1 event2 event3], operator: "OR" }
- ].map(&:with_indifferent_access)
+ aggregated_metric(name: "gmau_1", source: datasource, time_frame: time_frame, operator: "OR")
+ ]
end
it 'returns the number of unique events occurred for any metric in aggregate', :aggregate_failures do
results = {
- 'gmau_1' => 5,
- 'gmau_2' => 3
+ 'gmau_1' => 5
}
params = { start_date: start_date, end_date: end_date, recorded_at: recorded_at }
- expect(sources::RedisHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event3 event5])).and_return(5)
- expect(sources::PostgresHll).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2 event3])).and_return(3)
+ expect(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).with(params.merge(metric_names: %w[event1 event2 event3])).and_return(5)
expect(aggregated_metrics_data).to eq(results)
end
end
@@ -130,21 +143,22 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi
let(:enabled_feature_flag) { 'test_ff_enabled' }
let(:disabled_feature_flag) { 'test_ff_disabled' }
let(:aggregated_metrics) do
+ params = { source: datasource, time_frame: time_frame }
[
# represents stable aggregated metrics that has been fully released
- { name: 'gmau_without_ff', source: 'redis', events: %w[event3_slot event5_slot], operator: "OR" },
+ aggregated_metric(**params.merge(name: "gmau_without_ff")),
# represents new aggregated metric that is under performance testing on gitlab.com
- { name: 'gmau_enabled', source: 'redis', events: %w[event4], operator: "OR", feature_flag: enabled_feature_flag },
+ aggregated_metric(**params.merge(name: "gmau_enabled", feature_flag: enabled_feature_flag)),
# represents aggregated metric that is under development and shouldn't be yet collected even on gitlab.com
- { name: 'gmau_disabled', source: 'redis', events: %w[event4], operator: "OR", feature_flag: disabled_feature_flag }
- ].map(&:with_indifferent_access)
+ aggregated_metric(**params.merge(name: "gmau_disabled", feature_flag: disabled_feature_flag))
+ ]
end
it 'does not calculate data for aggregates with ff turned off' do
skip_feature_flags_yaml_validation
skip_default_enabled_yaml_check
stub_feature_flags(enabled_feature_flag => true, disabled_feature_flag => false)
- allow(sources::RedisHll).to receive(:calculate_metrics_union).and_return(6)
+ allow(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).and_return(6)
expect(aggregated_metrics_data).to eq('gmau_without_ff' => 6, 'gmau_enabled' => 6)
end
@@ -156,31 +170,29 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi
it 'raises error when unknown aggregation operator is used' do
allow_next_instance_of(described_class) do |instance|
allow(instance).to receive(:aggregated_metrics)
- .and_return([{ name: 'gmau_1', source: 'redis', events: %w[event1_slot], operator: "SUM" }])
+ .and_return([aggregated_metric(name: 'gmau_1', source: datasource, operator: "SUM", time_frame: time_frame)])
end
- expect { aggregated_metrics_data }.to raise_error Gitlab::Usage::Metrics::Aggregates::UnknownAggregationOperator
+ expect { aggregated_metrics_data }.to raise_error namespace::UnknownAggregationOperator
end
it 'raises error when unknown aggregation source is used' do
allow_next_instance_of(described_class) do |instance|
allow(instance).to receive(:aggregated_metrics)
- .and_return([{ name: 'gmau_1', source: 'whoami', events: %w[event1_slot], operator: "AND" }])
+ .and_return([aggregated_metric(name: 'gmau_1', source: 'whoami', time_frame: time_frame)])
end
- expect { aggregated_metrics_data }.to raise_error Gitlab::Usage::Metrics::Aggregates::UnknownAggregationSource
+ expect { aggregated_metrics_data }.to raise_error namespace::UnknownAggregationSource
end
- it 're raises Gitlab::UsageDataCounters::HLLRedisCounter::EventError' do
- error = Gitlab::UsageDataCounters::HLLRedisCounter::EventError
- allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:calculate_events_union).and_raise(error)
-
+ it 'raises error when union is missing' do
allow_next_instance_of(described_class) do |instance|
allow(instance).to receive(:aggregated_metrics)
- .and_return([{ name: 'gmau_1', source: 'redis', events: %w[event1_slot], operator: "OR" }])
+ .and_return([aggregated_metric(name: 'gmau_1', source: datasource, time_frame: time_frame)])
end
+ allow(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).and_raise(sources::UnionNotAvailable)
- expect { aggregated_metrics_data }.to raise_error error
+ expect { aggregated_metrics_data }.to raise_error sources::UnionNotAvailable
end
end
@@ -192,7 +204,7 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi
it 'rescues unknown aggregation operator error' do
allow_next_instance_of(described_class) do |instance|
allow(instance).to receive(:aggregated_metrics)
- .and_return([{ name: 'gmau_1', source: 'redis', events: %w[event1_slot], operator: "SUM" }])
+ .and_return([aggregated_metric(name: 'gmau_1', source: datasource, operator: "SUM", time_frame: time_frame)])
end
expect(aggregated_metrics_data).to eq('gmau_1' => -1)
@@ -201,20 +213,91 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi
it 'rescues unknown aggregation source error' do
allow_next_instance_of(described_class) do |instance|
allow(instance).to receive(:aggregated_metrics)
- .and_return([{ name: 'gmau_1', source: 'whoami', events: %w[event1_slot], operator: "AND" }])
+ .and_return([aggregated_metric(name: 'gmau_1', source: 'whoami', time_frame: time_frame)])
end
expect(aggregated_metrics_data).to eq('gmau_1' => -1)
end
- it 'rescues Gitlab::UsageDataCounters::HLLRedisCounter::EventError' do
- error = Gitlab::UsageDataCounters::HLLRedisCounter::EventError
- allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:calculate_events_union).and_raise(error)
-
+ it 'rescues error when union is missing' do
allow_next_instance_of(described_class) do |instance|
allow(instance).to receive(:aggregated_metrics)
- .and_return([{ name: 'gmau_1', source: 'redis', events: %w[event1_slot], operator: "OR" }])
+ .and_return([aggregated_metric(name: 'gmau_1', source: datasource, time_frame: time_frame)])
end
+ allow(namespace::SOURCES[datasource]).to receive(:calculate_metrics_union).and_raise(sources::UnionNotAvailable)
+
+ expect(aggregated_metrics_data).to eq('gmau_1' => -1)
+ end
+ end
+ end
+ end
+
+ shared_examples 'database_sourced_aggregated_metrics' do
+ let(:datasource) { namespace::DATABASE_SOURCE }
+
+ it_behaves_like 'aggregated_metrics_data'
+ end
+
+ shared_examples 'redis_sourced_aggregated_metrics' do
+ let(:datasource) { namespace::REDIS_SOURCE }
+
+ it_behaves_like 'aggregated_metrics_data' do
+ context 'error handling' do
+ let(:aggregated_metrics) { [aggregated_metric(name: 'gmau_1', source: datasource, time_frame: time_frame)] }
+ let(:error) { Gitlab::UsageDataCounters::HLLRedisCounter::EventError }
+
+ before do
+ allow_next_instance_of(described_class) do |instance|
+ allow(instance).to receive(:aggregated_metrics).and_return(aggregated_metrics)
+ end
+ allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:calculate_events_union).and_raise(error)
+ end
+
+ context 'development and test environment' do
+ it 're raises Gitlab::UsageDataCounters::HLLRedisCounter::EventError' do
+ expect { aggregated_metrics_data }.to raise_error error
+ end
+ end
+
+ context 'production' do
+ it 'rescues Gitlab::UsageDataCounters::HLLRedisCounter::EventError' do
+ stub_rails_env('production')
+
+ expect(aggregated_metrics_data).to eq('gmau_1' => -1)
+ end
+ end
+ end
+ end
+ end
+
+ describe '.aggregated_metrics_all_time_data' do
+ subject(:aggregated_metrics_data) { described_class.new(recorded_at).all_time_data }
+
+ let(:start_date) { nil }
+ let(:end_date) { nil }
+ let(:time_frame) { ['all'] }
+
+ it_behaves_like 'database_sourced_aggregated_metrics'
+ it_behaves_like 'db sourced aggregated metrics without database_sourced_aggregated_metrics feature'
+
+ context 'redis sourced aggregated metrics' do
+ let(:aggregated_metrics) { [aggregated_metric(name: 'gmau_1', time_frame: time_frame)] }
+
+ before do
+ allow_next_instance_of(described_class) do |instance|
+ allow(instance).to receive(:aggregated_metrics).and_return(aggregated_metrics)
+ end
+ end
+
+ context 'development and test environment' do
+ it 'raises Gitlab::Usage::Metrics::Aggregates::DisallowedAggregationTimeFrame' do
+ expect { aggregated_metrics_data }.to raise_error namespace::DisallowedAggregationTimeFrame
+ end
+ end
+
+ context 'production env' do
+ it 'returns fallback value for unsupported time frame' do
+ stub_rails_env('production')
expect(aggregated_metrics_data).to eq('gmau_1' => -1)
end
@@ -223,7 +306,7 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi
end
it 'allows for YAML aliases in aggregated metrics configs' do
- expect(YAML).to receive(:safe_load).with(kind_of(String), aliases: true)
+ expect(YAML).to receive(:safe_load).with(kind_of(String), aliases: true).at_least(:once)
described_class.new(recorded_at)
end
@@ -232,32 +315,34 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi
subject(:aggregated_metrics_data) { described_class.new(recorded_at).weekly_data }
let(:start_date) { 7.days.ago.to_date }
+ let(:time_frame) { ['7d'] }
- it_behaves_like 'aggregated_metrics_data'
+ it_behaves_like 'database_sourced_aggregated_metrics'
+ it_behaves_like 'redis_sourced_aggregated_metrics'
+ it_behaves_like 'db sourced aggregated metrics without database_sourced_aggregated_metrics feature'
end
describe '.aggregated_metrics_monthly_data' do
subject(:aggregated_metrics_data) { described_class.new(recorded_at).monthly_data }
let(:start_date) { 4.weeks.ago.to_date }
+ let(:time_frame) { ['28d'] }
- it_behaves_like 'aggregated_metrics_data'
+ it_behaves_like 'database_sourced_aggregated_metrics'
+ it_behaves_like 'redis_sourced_aggregated_metrics'
+ it_behaves_like 'db sourced aggregated metrics without database_sourced_aggregated_metrics feature'
context 'metrics union calls' do
- let(:aggregated_metrics) do
- [
- { name: 'gmau_3', source: 'redis', events: %w[event1_slot event2_slot event3_slot event5_slot], operator: "AND" }
- ].map(&:with_indifferent_access)
- end
-
it 'caches intermediate operations', :aggregate_failures do
+ events = %w[event1 event2 event3 event5]
allow_next_instance_of(described_class) do |instance|
- allow(instance).to receive(:aggregated_metrics).and_return(aggregated_metrics)
+ allow(instance).to receive(:aggregated_metrics)
+ .and_return([aggregated_metric(name: 'gmau_1', events: events, operator: "AND", time_frame: time_frame)])
end
params = { start_date: start_date, end_date: end_date, recorded_at: recorded_at }
- aggregated_metrics[0][:events].each do |event|
+ events.each do |event|
expect(sources::RedisHll).to receive(:calculate_metrics_union)
.with(params.merge(metric_names: event))
.once
@@ -265,7 +350,7 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi
end
2.upto(4) do |subset_size|
- aggregated_metrics[0][:events].combination(subset_size).each do |events|
+ events.combination(subset_size).each do |events|
expect(sources::RedisHll).to receive(:calculate_metrics_union)
.with(params.merge(metric_names: events))
.once
diff --git a/spec/lib/gitlab/usage/metrics/aggregates/sources/postgres_hll_spec.rb b/spec/lib/gitlab/usage/metrics/aggregates/sources/postgres_hll_spec.rb
index 7b8be8e8bc6..a2a40f17269 100644
--- a/spec/lib/gitlab/usage/metrics/aggregates/sources/postgres_hll_spec.rb
+++ b/spec/lib/gitlab/usage/metrics/aggregates/sources/postgres_hll_spec.rb
@@ -69,7 +69,7 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Sources::PostgresHll, :clean_
it 'persists serialized data in Redis' do
Gitlab::Redis::SharedState.with do |redis|
- expect(redis).to receive(:set).with("#{metric_1}_weekly-#{recorded_at.to_i}", '{"141":1,"56":1}', ex: 120.hours)
+ expect(redis).to receive(:set).with("#{metric_1}_7d-#{recorded_at.to_i}", '{"141":1,"56":1}', ex: 120.hours)
end
save_aggregated_metrics
@@ -81,7 +81,7 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Sources::PostgresHll, :clean_
it 'persists serialized data in Redis' do
Gitlab::Redis::SharedState.with do |redis|
- expect(redis).to receive(:set).with("#{metric_1}_monthly-#{recorded_at.to_i}", '{"141":1,"56":1}', ex: 120.hours)
+ expect(redis).to receive(:set).with("#{metric_1}_28d-#{recorded_at.to_i}", '{"141":1,"56":1}', ex: 120.hours)
end
save_aggregated_metrics
@@ -93,7 +93,7 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Sources::PostgresHll, :clean_
it 'persists serialized data in Redis' do
Gitlab::Redis::SharedState.with do |redis|
- expect(redis).to receive(:set).with("#{metric_1}_all_time-#{recorded_at.to_i}", '{"141":1,"56":1}', ex: 120.hours)
+ expect(redis).to receive(:set).with("#{metric_1}_all-#{recorded_at.to_i}", '{"141":1,"56":1}', ex: 120.hours)
end
save_aggregated_metrics
diff --git a/spec/lib/gitlab/usage/metrics/names_suggestions/generator_spec.rb b/spec/lib/gitlab/usage/metrics/names_suggestions/generator_spec.rb
new file mode 100644
index 00000000000..cd0413feab4
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/names_suggestions/generator_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::NamesSuggestions::Generator do
+ include UsageDataHelpers
+
+ before do
+ stub_usage_data_connections
+ end
+
+ describe '#generate' do
+ shared_examples 'name suggestion' do
+ it 'return correct name' do
+ expect(described_class.generate(key_path)).to eq name_suggestion
+ end
+ end
+
+ context 'for count with default column metrics' do
+ it_behaves_like 'name suggestion' do
+ # corresponding metric is collected with count(Board)
+ let(:key_path) { 'counts.boards' }
+ let(:name_suggestion) { 'count_boards' }
+ end
+ end
+
+ context 'for count distinct with column defined metrics' do
+ it_behaves_like 'name suggestion' do
+ # corresponding metric is collected with distinct_count(ZoomMeeting, :issue_id)
+ let(:key_path) { 'counts.issues_using_zoom_quick_actions' }
+ let(:name_suggestion) { 'count_distinct_issue_id_from_zoom_meetings' }
+ end
+ end
+
+ context 'for sum metrics' do
+ it_behaves_like 'name suggestion' do
+ # corresponding metric is collected with sum(JiraImportState.finished, :imported_issues_count)
+ let(:key_path) { 'counts.jira_imports_total_imported_issues_count' }
+ let(:name_suggestion) { "sum_imported_issues_count_from_<adjective describing: '(jira_imports.status = 4)'>_jira_imports" }
+ end
+ end
+
+ context 'for add metrics' do
+ it_behaves_like 'name suggestion' do
+ # corresponding metric is collected with add(data[:personal_snippets], data[:project_snippets])
+ let(:key_path) { 'counts.snippets' }
+ let(:name_suggestion) { "add_count_<adjective describing: '(snippets.type = 'PersonalSnippet')'>_snippets_and_count_<adjective describing: '(snippets.type = 'ProjectSnippet')'>_snippets" }
+ end
+ end
+
+ context 'for redis metrics' do
+ it_behaves_like 'name suggestion' do
+ # corresponding metric is collected with redis_usage_data { unique_visit_service.unique_visits_for(targets: :analytics) }
+ let(:key_path) { 'analytics_unique_visits.analytics_unique_visits_for_any_target' }
+ let(:name_suggestion) { '<please fill metric name>' }
+ end
+ end
+
+ context 'for alt_usage_data metrics' do
+ it_behaves_like 'name suggestion' do
+ # corresponding metric is collected with alt_usage_data(fallback: nil) { operating_system }
+ let(:key_path) { 'settings.operating_system' }
+ let(:name_suggestion) { '<please fill metric name>' }
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/usage/metrics/names_suggestions/relation_parsers/constraints_spec.rb b/spec/lib/gitlab/usage/metrics/names_suggestions/relation_parsers/constraints_spec.rb
new file mode 100644
index 00000000000..68016e760e4
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/names_suggestions/relation_parsers/constraints_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::NamesSuggestions::RelationParsers::Constraints do
+ describe '#accept' do
+ let(:collector) { Arel::Collectors::SubstituteBinds.new(ActiveRecord::Base.connection, Arel::Collectors::SQLString.new) }
+
+ it 'builds correct constraints description' do
+ table = Arel::Table.new('records')
+ arel = table.from.project(table['id'].count).where(table[:attribute].eq(true).and(table[:some_value].gt(5)))
+ described_class.new(ApplicationRecord.connection).accept(arel, collector)
+
+ expect(collector.value).to eql '(records.attribute = true AND records.some_value > 5)'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/usage_data_counters/aggregated_metrics_spec.rb b/spec/lib/gitlab/usage_data_counters/aggregated_metrics_spec.rb
index 58f974fbe12..9aba86cdaf2 100644
--- a/spec/lib/gitlab/usage_data_counters/aggregated_metrics_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/aggregated_metrics_spec.rb
@@ -23,6 +23,22 @@ RSpec.describe 'aggregated metrics' do
end
end
+ RSpec::Matchers.define :have_known_time_frame do
+ allowed_time_frames = [
+ Gitlab::Utils::UsageData::ALL_TIME_TIME_FRAME_NAME,
+ Gitlab::Utils::UsageData::TWENTY_EIGHT_DAYS_TIME_FRAME_NAME,
+ Gitlab::Utils::UsageData::SEVEN_DAYS_TIME_FRAME_NAME
+ ]
+
+ match do |aggregate|
+ (aggregate[:time_frame] - allowed_time_frames).empty?
+ end
+
+ failure_message do |aggregate|
+ "Aggregate with name: `#{aggregate[:name]}` uses not allowed time_frame`#{aggregate[:time_frame] - allowed_time_frames}`"
+ end
+ end
+
let_it_be(:known_events) do
Gitlab::UsageDataCounters::HLLRedisCounter.known_events
end
@@ -38,10 +54,18 @@ RSpec.describe 'aggregated metrics' do
expect(aggregated_metrics).to all has_known_source
end
+ it 'all aggregated metrics has known source' do
+ expect(aggregated_metrics).to all have_known_time_frame
+ end
+
aggregated_metrics&.select { |agg| agg[:source] == Gitlab::Usage::Metrics::Aggregates::REDIS_SOURCE }&.each do |aggregate|
context "for #{aggregate[:name]} aggregate of #{aggregate[:events].join(' ')}" do
let_it_be(:events_records) { known_events.select { |event| aggregate[:events].include?(event[:name]) } }
+ it "does not include 'all' time frame for Redis sourced aggregate" do
+ expect(aggregate[:time_frame]).not_to include(Gitlab::Utils::UsageData::ALL_TIME_TIME_FRAME_NAME)
+ end
+
it "only refers to known events" do
expect(aggregate[:events]).to all be_known_event
end
diff --git a/spec/lib/gitlab/usage_data_counters/code_review_events_spec.rb b/spec/lib/gitlab/usage_data_counters/code_review_events_spec.rb
new file mode 100644
index 00000000000..664e7938a7e
--- /dev/null
+++ b/spec/lib/gitlab/usage_data_counters/code_review_events_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+# If this spec fails, we need to add the new code review event to the correct aggregated metric
+RSpec.describe 'Code review events' do
+ it 'the aggregated metrics contain all the code review metrics' do
+ path = Rails.root.join('lib/gitlab/usage_data_counters/aggregated_metrics/code_review.yml')
+ aggregated_events = YAML.safe_load(File.read(path), aliases: true)&.map(&:with_indifferent_access)
+
+ code_review_aggregated_events = aggregated_events
+ .map { |event| event['events'] }
+ .flatten
+ .uniq
+
+ code_review_events = Gitlab::UsageDataCounters::HLLRedisCounter.events_for_category("code_review")
+
+ exceptions = %w[i_code_review_mr_diffs i_code_review_mr_single_file_diffs]
+ code_review_aggregated_events += exceptions
+
+ expect(code_review_events - code_review_aggregated_events).to be_empty
+ end
+end
diff --git a/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb
index b4894ec049f..d12dcdae955 100644
--- a/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb
@@ -42,7 +42,8 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
'terraform',
'ci_templates',
'quickactions',
- 'pipeline_authoring'
+ 'pipeline_authoring',
+ 'epics_usage'
)
end
end
@@ -150,10 +151,17 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
expect { described_class.track_event(different_aggregation, values: entity1, time: Date.current) }.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownAggregation)
end
- it 'raise error if metrics of unknown aggregation' do
+ it 'raise error if metrics of unknown event' do
expect { described_class.track_event('unknown', values: entity1, time: Date.current) }.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownEvent)
end
+ it 'reports an error if Feature.enabled raise an error' do
+ expect(Feature).to receive(:enabled?).and_raise(StandardError.new)
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
+
+ described_class.track_event(:g_analytics_contribution, values: entity1, time: Date.current)
+ end
+
context 'for weekly events' do
it 'sets the keys in Redis to expire automatically after the given expiry time' do
described_class.track_event("g_analytics_contribution", values: entity1)
diff --git a/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb
index bf43f7552e6..f8f6494b92e 100644
--- a/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
let(:time) { Time.zone.now }
context 'for Issue title edit actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_TITLE_CHANGED }
def track_action(params)
@@ -19,7 +19,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue description edit actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_DESCRIPTION_CHANGED }
def track_action(params)
@@ -29,7 +29,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue assignee edit actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_ASSIGNEE_CHANGED }
def track_action(params)
@@ -39,7 +39,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue make confidential actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_MADE_CONFIDENTIAL }
def track_action(params)
@@ -49,7 +49,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue make visible actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_MADE_VISIBLE }
def track_action(params)
@@ -59,7 +59,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue created actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_CREATED }
def track_action(params)
@@ -69,7 +69,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue closed actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_CLOSED }
def track_action(params)
@@ -79,7 +79,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue reopened actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_REOPENED }
def track_action(params)
@@ -89,7 +89,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue label changed actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_LABEL_CHANGED }
def track_action(params)
@@ -99,7 +99,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue cross-referenced actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_CROSS_REFERENCED }
def track_action(params)
@@ -109,7 +109,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue moved actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_MOVED }
def track_action(params)
@@ -119,7 +119,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue cloned actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_CLONED }
def track_action(params)
@@ -129,7 +129,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue relate actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_RELATED }
def track_action(params)
@@ -139,7 +139,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue unrelate actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_UNRELATED }
def track_action(params)
@@ -149,7 +149,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue marked as duplicate actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_MARKED_AS_DUPLICATE }
def track_action(params)
@@ -159,7 +159,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue locked actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_LOCKED }
def track_action(params)
@@ -169,7 +169,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue unlocked actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_UNLOCKED }
def track_action(params)
@@ -179,7 +179,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue designs added actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_DESIGNS_ADDED }
def track_action(params)
@@ -189,7 +189,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue designs modified actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_DESIGNS_MODIFIED }
def track_action(params)
@@ -199,7 +199,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue designs removed actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_DESIGNS_REMOVED }
def track_action(params)
@@ -209,7 +209,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue due date changed actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_DUE_DATE_CHANGED }
def track_action(params)
@@ -219,7 +219,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue time estimate changed actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_TIME_ESTIMATE_CHANGED }
def track_action(params)
@@ -229,7 +229,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue time spent changed actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_TIME_SPENT_CHANGED }
def track_action(params)
@@ -239,7 +239,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue comment added actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_COMMENT_ADDED }
def track_action(params)
@@ -249,7 +249,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue comment edited actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_COMMENT_EDITED }
def track_action(params)
@@ -259,7 +259,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue comment removed actions' do
- it_behaves_like 'a tracked issue edit event' do
+ it_behaves_like 'a daily tracked issuable event' do
let(:action) { described_class::ISSUE_COMMENT_REMOVED }
def track_action(params)
diff --git a/spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb
index a604de4a61f..6486a5a22ba 100644
--- a/spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb
@@ -21,6 +21,14 @@ RSpec.describe Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter, :cl
end
end
+ shared_examples_for 'not tracked merge request unique event' do
+ specify do
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
+
+ subject
+ end
+ end
+
describe '.track_mr_diffs_action' do
subject { described_class.track_mr_diffs_action(merge_request: merge_request) }
@@ -284,4 +292,98 @@ RSpec.describe Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter, :cl
let(:action) { described_class::MR_CREATE_FROM_ISSUE_ACTION }
end
end
+
+ describe '.track_discussion_locked_action' do
+ subject { described_class.track_discussion_locked_action(user: user) }
+
+ it_behaves_like 'a tracked merge request unique event' do
+ let(:action) { described_class::MR_DISCUSSION_LOCKED_ACTION }
+ end
+ end
+
+ describe '.track_discussion_unlocked_action' do
+ subject { described_class.track_discussion_unlocked_action(user: user) }
+
+ it_behaves_like 'a tracked merge request unique event' do
+ let(:action) { described_class::MR_DISCUSSION_UNLOCKED_ACTION }
+ end
+ end
+
+ describe '.track_time_estimate_changed_action' do
+ subject { described_class.track_time_estimate_changed_action(user: user) }
+
+ it_behaves_like 'a tracked merge request unique event' do
+ let(:action) { described_class::MR_TIME_ESTIMATE_CHANGED_ACTION }
+ end
+ end
+
+ describe '.track_time_spent_changed_action' do
+ subject { described_class.track_time_spent_changed_action(user: user) }
+
+ it_behaves_like 'a tracked merge request unique event' do
+ let(:action) { described_class::MR_TIME_SPENT_CHANGED_ACTION }
+ end
+ end
+
+ describe '.track_assignees_changed_action' do
+ subject { described_class.track_assignees_changed_action(user: user) }
+
+ it_behaves_like 'a tracked merge request unique event' do
+ let(:action) { described_class::MR_ASSIGNEES_CHANGED_ACTION }
+ end
+ end
+
+ describe '.track_reviewers_changed_action' do
+ subject { described_class.track_reviewers_changed_action(user: user) }
+
+ it_behaves_like 'a tracked merge request unique event' do
+ let(:action) { described_class::MR_REVIEWERS_CHANGED_ACTION }
+ end
+ end
+
+ describe '.track_mr_including_ci_config' do
+ subject { described_class.track_mr_including_ci_config(user: user, merge_request: merge_request) }
+
+ context 'when merge request includes a ci config change' do
+ before do
+ allow(merge_request).to receive(:diff_stats).and_return([double(path: 'abc.txt'), double(path: '.gitlab-ci.yml')])
+ end
+
+ it_behaves_like 'a tracked merge request unique event' do
+ let(:action) { described_class::MR_INCLUDING_CI_CONFIG_ACTION }
+ end
+
+ context 'when FF usage_data_o_pipeline_authoring_unique_users_pushing_mr_ciconfigfile is disabled' do
+ before do
+ stub_feature_flags(usage_data_o_pipeline_authoring_unique_users_pushing_mr_ciconfigfile: false)
+ end
+
+ it_behaves_like 'not tracked merge request unique event'
+ end
+ end
+
+ context 'when merge request does not include any ci config change' do
+ before do
+ allow(merge_request).to receive(:diff_stats).and_return([double(path: 'abc.txt'), double(path: 'abc.xyz')])
+ end
+
+ it_behaves_like 'not tracked merge request unique event'
+ end
+ end
+
+ describe '.track_milestone_changed_action' do
+ subject { described_class.track_milestone_changed_action(user: user) }
+
+ it_behaves_like 'a tracked merge request unique event' do
+ let(:action) { described_class::MR_MILESTONE_CHANGED_ACTION }
+ end
+ end
+
+ describe '.track_labels_changed_action' do
+ subject { described_class.track_labels_changed_action(user: user) }
+
+ it_behaves_like 'a tracked merge request unique event' do
+ let(:action) { described_class::MR_LABELS_CHANGED_ACTION }
+ end
+ end
end
diff --git a/spec/lib/gitlab/usage_data_counters/package_event_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/package_event_counter_spec.rb
index 7b5efb11034..1be2a83f98f 100644
--- a/spec/lib/gitlab/usage_data_counters/package_event_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/package_event_counter_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe Gitlab::UsageDataCounters::PackageEventCounter, :clean_gitlab_red
end
it 'includes the right events' do
- expect(described_class::KNOWN_EVENTS.size).to eq 45
+ expect(described_class::KNOWN_EVENTS.size).to eq 48
end
described_class::KNOWN_EVENTS.each do |event|
diff --git a/spec/lib/gitlab/usage_data_counters/quick_action_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/quick_action_activity_unique_counter_spec.rb
index d4c423f57fe..2df0f331f73 100644
--- a/spec/lib/gitlab/usage_data_counters/quick_action_activity_unique_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/quick_action_activity_unique_counter_spec.rb
@@ -160,4 +160,24 @@ RSpec.describe Gitlab::UsageDataCounters::QuickActionActivityUniqueCounter, :cle
end
end
end
+
+ context 'tracking invite_email' do
+ let(:quickaction_name) { 'invite_email' }
+
+ context 'single email' do
+ let(:args) { 'someone@gitlab.com' }
+
+ it_behaves_like 'a tracked quick action unique event' do
+ let(:action) { 'i_quickactions_invite_email_single' }
+ end
+ end
+
+ context 'multiple emails' do
+ let(:args) { 'someone@gitlab.com another@gitlab.com' }
+
+ it_behaves_like 'a tracked quick action unique event' do
+ let(:action) { 'i_quickactions_invite_email_multiple' }
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/usage_data_queries_spec.rb b/spec/lib/gitlab/usage_data_queries_spec.rb
index 7fc77593265..12eac643383 100644
--- a/spec/lib/gitlab/usage_data_queries_spec.rb
+++ b/spec/lib/gitlab/usage_data_queries_spec.rb
@@ -38,4 +38,12 @@ RSpec.describe Gitlab::UsageDataQueries do
expect(described_class.sum(Issue, :weight)).to eq('SELECT SUM("issues"."weight") FROM "issues"')
end
end
+
+ describe '.add' do
+ it 'returns the combined raw SQL with an inner query' do
+ expect(described_class.add('SELECT COUNT("users"."id") FROM "users"',
+ 'SELECT COUNT("issues"."id") FROM "issues"'))
+ .to eq('SELECT (SELECT COUNT("users"."id") FROM "users") + (SELECT COUNT("issues"."id") FROM "issues")')
+ end
+ end
end
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index 602f6640d72..b1581bf02a6 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -382,14 +382,15 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
describe 'usage_activity_by_stage_monitor' do
it 'includes accurate usage_activity_by_stage data' do
for_defined_days_back do
- user = create(:user, dashboard: 'operations')
+ user = create(:user, dashboard: 'operations')
cluster = create(:cluster, user: user)
- create(:project, creator: user)
+ project = create(:project, creator: user)
create(:clusters_applications_prometheus, :installed, cluster: cluster)
create(:project_tracing_setting)
create(:project_error_tracking_setting)
create(:incident)
create(:incident, alert_management_alert: create(:alert_management_alert))
+ create(:alert_management_http_integration, :active, project: project)
end
expect(described_class.usage_activity_by_stage_monitor({})).to include(
@@ -399,10 +400,12 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
projects_with_tracing_enabled: 2,
projects_with_error_tracking_enabled: 2,
projects_with_incidents: 4,
- projects_with_alert_incidents: 2
+ projects_with_alert_incidents: 2,
+ projects_with_enabled_alert_integrations_histogram: { '1' => 2 }
)
- expect(described_class.usage_activity_by_stage_monitor(described_class.last_28_days_time_period)).to include(
+ data_28_days = described_class.usage_activity_by_stage_monitor(described_class.last_28_days_time_period)
+ expect(data_28_days).to include(
clusters: 1,
clusters_applications_prometheus: 1,
operations_dashboard_default_dashboard: 1,
@@ -411,6 +414,8 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
projects_with_incidents: 2,
projects_with_alert_incidents: 1
)
+
+ expect(data_28_days).not_to include(:projects_with_enabled_alert_integrations_histogram)
end
end
@@ -528,14 +533,14 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(subject.keys).to include(*UsageDataHelpers::USAGE_DATA_KEYS)
end
- it 'gathers usage counts' do
+ it 'gathers usage counts', :aggregate_failures do
count_data = subject[:counts]
expect(count_data[:boards]).to eq(1)
expect(count_data[:projects]).to eq(4)
- expect(count_data.values_at(*UsageDataHelpers::SMAU_KEYS)).to all(be_an(Integer))
expect(count_data.keys).to include(*UsageDataHelpers::COUNTS_KEYS)
expect(UsageDataHelpers::COUNTS_KEYS - count_data.keys).to be_empty
+ expect(count_data.values).to all(be_a_kind_of(Integer))
end
it 'gathers usage counts correctly' do
@@ -1129,12 +1134,40 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
end
end
+ describe ".operating_system" do
+ let(:ohai_data) { { "platform" => "ubuntu", "platform_version" => "20.04" } }
+
+ before do
+ allow_next_instance_of(Ohai::System) do |ohai|
+ allow(ohai).to receive(:data).and_return(ohai_data)
+ end
+ end
+
+ subject { described_class.operating_system }
+
+ it { is_expected.to eq("ubuntu-20.04") }
+
+ context 'when on Debian with armv architecture' do
+ let(:ohai_data) { { "platform" => "debian", "platform_version" => "10", 'kernel' => { 'machine' => 'armv' } } }
+
+ it { is_expected.to eq("raspbian-10") }
+ end
+ end
+
describe ".system_usage_data_settings" do
+ before do
+ allow(described_class).to receive(:operating_system).and_return('ubuntu-20.04')
+ end
+
subject { described_class.system_usage_data_settings }
it 'gathers settings usage data', :aggregate_failures do
expect(subject[:settings][:ldap_encrypted_secrets_enabled]).to eq(Gitlab::Auth::Ldap::Config.encrypted_secrets.active?)
end
+
+ it 'populates operating system information' do
+ expect(subject[:settings][:operating_system]).to eq('ubuntu-20.04')
+ end
end
end
@@ -1325,7 +1358,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
let(:categories) { ::Gitlab::UsageDataCounters::HLLRedisCounter.categories }
let(:ineligible_total_categories) do
- %w[source_code ci_secrets_management incident_management_alerts snippets terraform pipeline_authoring]
+ %w[source_code ci_secrets_management incident_management_alerts snippets terraform epics_usage]
end
it 'has all known_events' do
@@ -1347,25 +1380,20 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
end
end
- describe '.aggregated_metrics_weekly' do
- subject(:aggregated_metrics_payload) { described_class.aggregated_metrics_weekly }
+ describe '.aggregated_metrics_data' do
+ it 'uses ::Gitlab::Usage::Metrics::Aggregates::Aggregate methods', :aggregate_failures do
+ expected_payload = {
+ counts_weekly: { aggregated_metrics: { global_search_gmau: 123 } },
+ counts_monthly: { aggregated_metrics: { global_search_gmau: 456 } },
+ counts: { aggregate_global_search_gmau: 789 }
+ }
- it 'uses ::Gitlab::Usage::Metrics::Aggregates::Aggregate#weekly_data', :aggregate_failures do
expect_next_instance_of(::Gitlab::Usage::Metrics::Aggregates::Aggregate) do |instance|
expect(instance).to receive(:weekly_data).and_return(global_search_gmau: 123)
+ expect(instance).to receive(:monthly_data).and_return(global_search_gmau: 456)
+ expect(instance).to receive(:all_time_data).and_return(global_search_gmau: 789)
end
- expect(aggregated_metrics_payload).to eq(aggregated_metrics: { global_search_gmau: 123 })
- end
- end
-
- describe '.aggregated_metrics_monthly' do
- subject(:aggregated_metrics_payload) { described_class.aggregated_metrics_monthly }
-
- it 'uses ::Gitlab::Usage::Metrics::Aggregates::Aggregate#monthly_data', :aggregate_failures do
- expect_next_instance_of(::Gitlab::Usage::Metrics::Aggregates::Aggregate) do |instance|
- expect(instance).to receive(:monthly_data).and_return(global_search_gmau: 123)
- end
- expect(aggregated_metrics_payload).to eq(aggregated_metrics: { global_search_gmau: 123 })
+ expect(described_class.aggregated_metrics_data).to eq(expected_payload)
end
end
diff --git a/spec/lib/gitlab/utils/usage_data_spec.rb b/spec/lib/gitlab/utils/usage_data_spec.rb
index e964e695828..6e1904c43e1 100644
--- a/spec/lib/gitlab/utils/usage_data_spec.rb
+++ b/spec/lib/gitlab/utils/usage_data_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Gitlab::Utils::UsageData do
+ include Database::DatabaseHelpers
+
describe '#count' do
let(:relation) { double(:relation) }
@@ -183,6 +185,120 @@ RSpec.describe Gitlab::Utils::UsageData do
end
end
+ describe '#histogram' do
+ let_it_be(:projects) { create_list(:project, 3) }
+ let(:project1) { projects.first }
+ let(:project2) { projects.second }
+ let(:project3) { projects.third }
+
+ let(:fallback) { described_class::HISTOGRAM_FALLBACK }
+ let(:relation) { AlertManagement::HttpIntegration.active }
+ let(:column) { :project_id }
+
+ def expect_error(exception, message, &block)
+ expect(Gitlab::ErrorTracking)
+ .to receive(:track_and_raise_for_dev_exception)
+ .with(instance_of(exception))
+ .and_call_original
+
+ expect(&block).to raise_error(
+ an_instance_of(exception).and(
+ having_attributes(message: message, backtrace: be_kind_of(Array)))
+ )
+ end
+
+ it 'checks bucket bounds to be not equal' do
+ expect_error(ArgumentError, 'Lower bucket bound cannot equal to upper bucket bound') do
+ described_class.histogram(relation, column, buckets: 1..1)
+ end
+ end
+
+ it 'checks bucket_size being non-zero' do
+ expect_error(ArgumentError, 'Bucket size cannot be zero') do
+ described_class.histogram(relation, column, buckets: 1..2, bucket_size: 0)
+ end
+ end
+
+ it 'limits the amount of buckets without providing bucket_size argument' do
+ expect_error(ArgumentError, 'Bucket size 101 exceeds the limit of 100') do
+ described_class.histogram(relation, column, buckets: 1..101)
+ end
+ end
+
+ it 'limits the amount of buckets when providing bucket_size argument' do
+ expect_error(ArgumentError, 'Bucket size 101 exceeds the limit of 100') do
+ described_class.histogram(relation, column, buckets: 1..2, bucket_size: 101)
+ end
+ end
+
+ it 'without data' do
+ histogram = described_class.histogram(relation, column, buckets: 1..100)
+
+ expect(histogram).to eq({})
+ end
+
+ it 'aggregates properly within bounds' do
+ create(:alert_management_http_integration, :active, project: project1)
+ create(:alert_management_http_integration, :inactive, project: project1)
+
+ create(:alert_management_http_integration, :active, project: project2)
+ create(:alert_management_http_integration, :active, project: project2)
+ create(:alert_management_http_integration, :inactive, project: project2)
+
+ create(:alert_management_http_integration, :active, project: project3)
+ create(:alert_management_http_integration, :inactive, project: project3)
+
+ histogram = described_class.histogram(relation, column, buckets: 1..100)
+
+ expect(histogram).to eq('1' => 2, '2' => 1)
+ end
+
+ it 'aggregates properly out of bounds' do
+ create_list(:alert_management_http_integration, 3, :active, project: project1)
+ histogram = described_class.histogram(relation, column, buckets: 1..2)
+
+ expect(histogram).to eq('2' => 1)
+ end
+
+ it 'returns fallback and logs canceled queries' do
+ create(:alert_management_http_integration, :active, project: project1)
+
+ expect(Gitlab::AppJsonLogger).to receive(:error).with(
+ event: 'histogram',
+ relation: relation.table_name,
+ operation: 'histogram',
+ operation_args: [column, 1, 100, 99],
+ query: kind_of(String),
+ message: /PG::QueryCanceled/
+ )
+
+ with_statement_timeout(0.001) do
+ relation = AlertManagement::HttpIntegration.select('pg_sleep(0.002)')
+ histogram = described_class.histogram(relation, column, buckets: 1..100)
+
+ expect(histogram).to eq(fallback)
+ end
+ end
+ end
+
+ describe '#add' do
+ it 'adds given values' do
+ expect(described_class.add(1, 3)).to eq(4)
+ end
+
+ it 'adds given values' do
+ expect(described_class.add).to eq(0)
+ end
+
+ it 'returns the fallback value when adding fails' do
+ expect(described_class.add(nil, 3)).to eq(-1)
+ end
+
+ it 'returns the fallback value one of the arguments is negative' do
+ expect(described_class.add(-1, 1)).to eq(-1)
+ end
+ end
+
describe '#alt_usage_data' do
it 'returns the fallback when it gets an error' do
expect(described_class.alt_usage_data { raise StandardError } ).to eq(-1)
@@ -203,6 +319,12 @@ RSpec.describe Gitlab::Utils::UsageData do
expect(described_class.redis_usage_data { raise ::Redis::CommandError } ).to eq(-1)
end
+ it 'returns the fallback when Redis HLL raises any error' do
+ stub_const("Gitlab::Utils::UsageData::FALLBACK", 15)
+
+ expect(described_class.redis_usage_data { raise Gitlab::UsageDataCounters::HLLRedisCounter::CategoryMismatch } ).to eq(15)
+ end
+
it 'returns the evaluated block when given' do
expect(described_class.redis_usage_data { 1 }).to eq(1)
end
@@ -222,6 +344,13 @@ RSpec.describe Gitlab::Utils::UsageData do
end
describe '#with_prometheus_client' do
+ it 'returns fallback with for an exception in yield block' do
+ allow(described_class).to receive(:prometheus_client).and_return(Gitlab::PrometheusClient.new('http://localhost:9090'))
+ result = described_class.with_prometheus_client(fallback: -42) { |client| raise StandardError }
+
+ expect(result).to be(-42)
+ end
+
shared_examples 'query data from Prometheus' do
it 'yields a client instance and returns the block result' do
result = described_class.with_prometheus_client { |client| client }
@@ -231,10 +360,10 @@ RSpec.describe Gitlab::Utils::UsageData do
end
shared_examples 'does not query data from Prometheus' do
- it 'returns nil by default' do
+ it 'returns {} by default' do
result = described_class.with_prometheus_client { |client| client }
- expect(result).to be_nil
+ expect(result).to eq({})
end
it 'returns fallback if provided' do
@@ -338,38 +467,15 @@ RSpec.describe Gitlab::Utils::UsageData do
let(:value) { '9f302fea-f828-4ca9-aef4-e10bd723c0b3' }
let(:event_name) { 'incident_management_alert_status_changed' }
let(:unknown_event) { 'unknown' }
- let(:feature) { "usage_data_#{event_name}" }
-
- before do
- skip_feature_flags_yaml_validation
- end
- context 'with feature enabled' do
- before do
- stub_feature_flags(feature => true)
- end
+ it 'tracks redis hll event' do
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(event_name, values: value)
- it 'tracks redis hll event' do
- expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(event_name, values: value)
-
- described_class.track_usage_event(event_name, value)
- end
-
- it 'raise an error for unknown event' do
- expect { described_class.track_usage_event(unknown_event, value) }.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownEvent)
- end
+ described_class.track_usage_event(event_name, value)
end
- context 'with feature disabled' do
- before do
- stub_feature_flags(feature => false)
- end
-
- it 'does not track event' do
- expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
-
- described_class.track_usage_event(event_name, value)
- end
+ it 'raise an error for unknown event' do
+ expect { described_class.track_usage_event(unknown_event, value) }.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownEvent)
end
end
end
diff --git a/spec/lib/gitlab/visibility_level_spec.rb b/spec/lib/gitlab/visibility_level_spec.rb
index 63c31c82d59..0d34d22cbbe 100644
--- a/spec/lib/gitlab/visibility_level_spec.rb
+++ b/spec/lib/gitlab/visibility_level_spec.rb
@@ -131,4 +131,29 @@ RSpec.describe Gitlab::VisibilityLevel do
end
end
end
+
+ describe '.options' do
+ context 'keys' do
+ it 'returns the allowed visibility levels' do
+ expect(described_class.options.keys).to contain_exactly('Private', 'Internal', 'Public')
+ end
+ end
+ end
+
+ describe '.level_name' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:level_value, :level_name) do
+ described_class::PRIVATE | 'Private'
+ described_class::INTERNAL | 'Internal'
+ described_class::PUBLIC | 'Public'
+ non_existing_record_access_level | 'Unknown'
+ end
+
+ with_them do
+ it 'returns the name of the visibility level' do
+ expect(described_class.level_name(level_value)).to eq(level_name)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/word_diff/chunk_collection_spec.rb b/spec/lib/gitlab/word_diff/chunk_collection_spec.rb
new file mode 100644
index 00000000000..aa837f760c1
--- /dev/null
+++ b/spec/lib/gitlab/word_diff/chunk_collection_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::WordDiff::ChunkCollection do
+ subject(:collection) { described_class.new }
+
+ describe '#add' do
+ it 'adds elements to the chunk collection' do
+ collection.add('Hello')
+ collection.add(' World')
+
+ expect(collection.content).to eq('Hello World')
+ end
+ end
+
+ describe '#content' do
+ subject { collection.content }
+
+ context 'when no elements in the collection' do
+ it { is_expected.to eq('') }
+ end
+
+ context 'when elements exist' do
+ before do
+ collection.add('Hi')
+ collection.add(' GitLab!')
+ end
+
+ it { is_expected.to eq('Hi GitLab!') }
+ end
+ end
+
+ describe '#reset' do
+ it 'clears the collection' do
+ collection.add('1')
+ collection.add('2')
+
+ collection.reset
+
+ expect(collection.content).to eq('')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/word_diff/line_processor_spec.rb b/spec/lib/gitlab/word_diff/line_processor_spec.rb
new file mode 100644
index 00000000000..f448f5b5eb6
--- /dev/null
+++ b/spec/lib/gitlab/word_diff/line_processor_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::WordDiff::LineProcessor do
+ subject(:line_processor) { described_class.new(line) }
+
+ describe '#extract' do
+ subject(:segment) { line_processor.extract }
+
+ context 'when line is a diff hunk' do
+ let(:line) { "@@ -1,14 +1,13 @@\n" }
+
+ it 'returns DiffHunk segment' do
+ expect(segment).to be_a(Gitlab::WordDiff::Segments::DiffHunk)
+ expect(segment.to_s).to eq('@@ -1,14 +1,13 @@')
+ end
+ end
+
+ context 'when line has a newline delimiter' do
+ let(:line) { "~\n" }
+
+ it 'returns Newline segment' do
+ expect(segment).to be_a(Gitlab::WordDiff::Segments::Newline)
+ expect(segment.to_s).to eq('')
+ end
+ end
+
+ context 'when line has only space' do
+ let(:line) { " \n" }
+
+ it 'returns nil' do
+ is_expected.to be_nil
+ end
+ end
+
+ context 'when line has content' do
+ let(:line) { "+New addition\n" }
+
+ it 'returns Chunk segment' do
+ expect(segment).to be_a(Gitlab::WordDiff::Segments::Chunk)
+ expect(segment.to_s).to eq('New addition')
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/word_diff/parser_spec.rb b/spec/lib/gitlab/word_diff/parser_spec.rb
new file mode 100644
index 00000000000..3aeefb57a02
--- /dev/null
+++ b/spec/lib/gitlab/word_diff/parser_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::WordDiff::Parser do
+ subject(:parser) { described_class.new }
+
+ describe '#parse' do
+ subject { parser.parse(diff.lines).to_a }
+
+ let(:diff) do
+ <<~EOF
+ @@ -1,14 +1,13 @@
+ ~
+ Unchanged line
+ ~
+ ~
+ -Old change
+ +New addition
+ unchanged content
+ ~
+ @@ -50,14 +50,13 @@
+ +First change
+ same same same_
+ -removed_
+ +added_
+ end of the line
+ ~
+ ~
+ EOF
+ end
+
+ it 'returns a collection of lines' do
+ diff_lines = subject
+
+ aggregate_failures do
+ expect(diff_lines.count).to eq(7)
+
+ expect(diff_lines.map(&:to_hash)).to match_array(
+ [
+ a_hash_including(index: 0, old_pos: 1, new_pos: 1, text: '', type: nil),
+ a_hash_including(index: 1, old_pos: 2, new_pos: 2, text: 'Unchanged line', type: nil),
+ a_hash_including(index: 2, old_pos: 3, new_pos: 3, text: '', type: nil),
+ a_hash_including(index: 3, old_pos: 4, new_pos: 4, text: 'Old changeNew addition unchanged content', type: nil),
+ a_hash_including(index: 4, old_pos: 50, new_pos: 50, text: '@@ -50,14 +50,13 @@', type: 'match'),
+ a_hash_including(index: 5, old_pos: 50, new_pos: 50, text: 'First change same same same_removed_added_end of the line', type: nil),
+ a_hash_including(index: 6, old_pos: 51, new_pos: 51, text: '', type: nil)
+ ]
+ )
+ end
+ end
+
+ it 'restarts object index after several calls to Enumerator' do
+ enumerator = parser.parse(diff.lines)
+
+ 2.times do
+ expect(enumerator.first.index).to eq(0)
+ end
+ end
+
+ context 'when diff is empty' do
+ let(:diff) { '' }
+
+ it { is_expected.to eq([]) }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/word_diff/positions_counter_spec.rb b/spec/lib/gitlab/word_diff/positions_counter_spec.rb
new file mode 100644
index 00000000000..e2c246f6801
--- /dev/null
+++ b/spec/lib/gitlab/word_diff/positions_counter_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::WordDiff::PositionsCounter do
+ subject(:counter) { described_class.new }
+
+ describe 'Initial state' do
+ it 'starts with predefined values' do
+ expect(counter.pos_old).to eq(1)
+ expect(counter.pos_new).to eq(1)
+ expect(counter.line_obj_index).to eq(0)
+ end
+ end
+
+ describe '#increase_pos_num' do
+ it 'increases old and new positions' do
+ expect { counter.increase_pos_num }.to change { counter.pos_old }.from(1).to(2)
+ .and change { counter.pos_new }.from(1).to(2)
+ end
+ end
+
+ describe '#increase_obj_index' do
+ it 'increases object index' do
+ expect { counter.increase_obj_index }.to change { counter.line_obj_index }.from(0).to(1)
+ end
+ end
+
+ describe '#set_pos_num' do
+ it 'sets old and new positions' do
+ expect { counter.set_pos_num(old: 10, new: 12) }.to change { counter.pos_old }.from(1).to(10)
+ .and change { counter.pos_new }.from(1).to(12)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/word_diff/segments/chunk_spec.rb b/spec/lib/gitlab/word_diff/segments/chunk_spec.rb
new file mode 100644
index 00000000000..797cc42a03c
--- /dev/null
+++ b/spec/lib/gitlab/word_diff/segments/chunk_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::WordDiff::Segments::Chunk do
+ subject(:chunk) { described_class.new(line) }
+
+ let(:line) { ' Hello' }
+
+ describe '#removed?' do
+ subject { chunk.removed? }
+
+ it { is_expected.to be_falsey }
+
+ context 'when line starts with "-"' do
+ let(:line) { '-Removed' }
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ describe '#added?' do
+ subject { chunk.added? }
+
+ it { is_expected.to be_falsey }
+
+ context 'when line starts with "+"' do
+ let(:line) { '+Added' }
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ describe '#to_s' do
+ subject { chunk.to_s }
+
+ it 'removes lead string modifier' do
+ is_expected.to eq('Hello')
+ end
+
+ context 'when chunk is empty' do
+ let(:line) { '' }
+
+ it { is_expected.to eq('') }
+ end
+ end
+
+ describe '#length' do
+ subject { chunk.length }
+
+ it { is_expected.to eq('Hello'.length) }
+ end
+end
diff --git a/spec/lib/gitlab/word_diff/segments/diff_hunk_spec.rb b/spec/lib/gitlab/word_diff/segments/diff_hunk_spec.rb
new file mode 100644
index 00000000000..5250e6d73c2
--- /dev/null
+++ b/spec/lib/gitlab/word_diff/segments/diff_hunk_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::WordDiff::Segments::DiffHunk do
+ subject(:diff_hunk) { described_class.new(line) }
+
+ let(:line) { '@@ -3,14 +4,13 @@' }
+
+ describe '#pos_old' do
+ subject { diff_hunk.pos_old }
+
+ it { is_expected.to eq 3 }
+
+ context 'when diff hunk is broken' do
+ let(:line) { '@@ ??? @@' }
+
+ it { is_expected.to eq 0 }
+ end
+ end
+
+ describe '#pos_new' do
+ subject { diff_hunk.pos_new }
+
+ it { is_expected.to eq 4 }
+
+ context 'when diff hunk is broken' do
+ let(:line) { '@@ ??? @@' }
+
+ it { is_expected.to eq 0 }
+ end
+ end
+
+ describe '#first_line?' do
+ subject { diff_hunk.first_line? }
+
+ it { is_expected.to be_falsey }
+
+ context 'when diff hunk located on the first line' do
+ let(:line) { '@@ -1,14 +1,13 @@' }
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ describe '#to_s' do
+ subject { diff_hunk.to_s }
+
+ it { is_expected.to eq(line) }
+ end
+end
diff --git a/spec/lib/gitlab/word_diff/segments/newline_spec.rb b/spec/lib/gitlab/word_diff/segments/newline_spec.rb
new file mode 100644
index 00000000000..ed5054844f1
--- /dev/null
+++ b/spec/lib/gitlab/word_diff/segments/newline_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::WordDiff::Segments::Newline do
+ subject(:newline) { described_class.new }
+
+ describe '#to_s' do
+ subject { newline.to_s }
+
+ it { is_expected.to eq '' }
+ end
+end
diff --git a/spec/lib/gitlab/x509/signature_spec.rb b/spec/lib/gitlab/x509/signature_spec.rb
index ac6f7e49fe0..2ac9c1f3a3b 100644
--- a/spec/lib/gitlab/x509/signature_spec.rb
+++ b/spec/lib/gitlab/x509/signature_spec.rb
@@ -11,6 +11,65 @@ RSpec.describe Gitlab::X509::Signature do
}
end
+ shared_examples "a verified signature" do
+ it 'returns a verified signature if email does match' do
+ signature = described_class.new(
+ X509Helpers::User1.signed_commit_signature,
+ X509Helpers::User1.signed_commit_base_data,
+ X509Helpers::User1.certificate_email,
+ X509Helpers::User1.signed_commit_time
+ )
+
+ expect(signature.x509_certificate).to have_attributes(certificate_attributes)
+ expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes)
+ expect(signature.verified_signature).to be_truthy
+ expect(signature.verification_status).to eq(:verified)
+ end
+
+ it 'returns an unverified signature if email does not match' do
+ signature = described_class.new(
+ X509Helpers::User1.signed_commit_signature,
+ X509Helpers::User1.signed_commit_base_data,
+ "gitlab@example.com",
+ X509Helpers::User1.signed_commit_time
+ )
+
+ expect(signature.x509_certificate).to have_attributes(certificate_attributes)
+ expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes)
+ expect(signature.verified_signature).to be_truthy
+ expect(signature.verification_status).to eq(:unverified)
+ end
+
+ it 'returns an unverified signature if email does match and time is wrong' do
+ signature = described_class.new(
+ X509Helpers::User1.signed_commit_signature,
+ X509Helpers::User1.signed_commit_base_data,
+ X509Helpers::User1.certificate_email,
+ Time.new(2020, 2, 22)
+ )
+
+ expect(signature.x509_certificate).to have_attributes(certificate_attributes)
+ expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes)
+ expect(signature.verified_signature).to be_falsey
+ expect(signature.verification_status).to eq(:unverified)
+ end
+
+ it 'returns an unverified signature if certificate is revoked' do
+ signature = described_class.new(
+ X509Helpers::User1.signed_commit_signature,
+ X509Helpers::User1.signed_commit_base_data,
+ X509Helpers::User1.certificate_email,
+ X509Helpers::User1.signed_commit_time
+ )
+
+ expect(signature.verification_status).to eq(:verified)
+
+ signature.x509_certificate.revoked!
+
+ expect(signature.verification_status).to eq(:unverified)
+ end
+ end
+
context 'commit signature' do
let(:certificate_attributes) do
{
@@ -30,62 +89,25 @@ RSpec.describe Gitlab::X509::Signature do
allow(OpenSSL::X509::Store).to receive(:new).and_return(store)
end
- it 'returns a verified signature if email does match' do
- signature = described_class.new(
- X509Helpers::User1.signed_commit_signature,
- X509Helpers::User1.signed_commit_base_data,
- X509Helpers::User1.certificate_email,
- X509Helpers::User1.signed_commit_time
- )
-
- expect(signature.x509_certificate).to have_attributes(certificate_attributes)
- expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes)
- expect(signature.verified_signature).to be_truthy
- expect(signature.verification_status).to eq(:verified)
- end
+ it_behaves_like "a verified signature"
+ end
- it 'returns an unverified signature if email does not match' do
- signature = described_class.new(
- X509Helpers::User1.signed_commit_signature,
- X509Helpers::User1.signed_commit_base_data,
- "gitlab@example.com",
- X509Helpers::User1.signed_commit_time
- )
+ context 'with the certificate defined by OpenSSL::X509::DEFAULT_CERT_FILE' do
+ before do
+ store = OpenSSL::X509::Store.new
+ certificate = OpenSSL::X509::Certificate.new(X509Helpers::User1.trust_cert)
+ file_path = Rails.root.join("tmp/cert.pem").to_s
- expect(signature.x509_certificate).to have_attributes(certificate_attributes)
- expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes)
- expect(signature.verified_signature).to be_truthy
- expect(signature.verification_status).to eq(:unverified)
- end
+ File.open(file_path, "wb") do |f|
+ f.print certificate.to_pem
+ end
- it 'returns an unverified signature if email does match and time is wrong' do
- signature = described_class.new(
- X509Helpers::User1.signed_commit_signature,
- X509Helpers::User1.signed_commit_base_data,
- X509Helpers::User1.certificate_email,
- Time.new(2020, 2, 22)
- )
+ stub_const("OpenSSL::X509::DEFAULT_CERT_FILE", file_path)
- expect(signature.x509_certificate).to have_attributes(certificate_attributes)
- expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes)
- expect(signature.verified_signature).to be_falsey
- expect(signature.verification_status).to eq(:unverified)
+ allow(OpenSSL::X509::Store).to receive(:new).and_return(store)
end
- it 'returns an unverified signature if certificate is revoked' do
- signature = described_class.new(
- X509Helpers::User1.signed_commit_signature,
- X509Helpers::User1.signed_commit_base_data,
- X509Helpers::User1.certificate_email,
- X509Helpers::User1.signed_commit_time
- )
-
- expect(signature.verification_status).to eq(:verified)
-
- signature.x509_certificate.revoked!
-
- expect(signature.verification_status).to eq(:unverified)
- end
+ it_behaves_like "a verified signature"
end
context 'without trusted certificate within store' do
diff --git a/spec/lib/marginalia_spec.rb b/spec/lib/marginalia_spec.rb
index fa0cd214c7e..2ee27fbe20c 100644
--- a/spec/lib/marginalia_spec.rb
+++ b/spec/lib/marginalia_spec.rb
@@ -37,26 +37,9 @@ RSpec.describe 'Marginalia spec' do
}
end
- context 'when the feature is enabled' do
- before do
- stub_feature(true)
- end
-
- it 'generates a query that includes the component and value' do
- component_map.each do |component, value|
- expect(recorded.log.last).to include("#{component}:#{value}")
- end
- end
- end
-
- context 'when the feature is disabled' do
- before do
- stub_feature(false)
- end
-
- it 'excludes annotations in generated queries' do
- expect(recorded.log.last).not_to include("/*")
- expect(recorded.log.last).not_to include("*/")
+ it 'generates a query that includes the component and value' do
+ component_map.each do |component, value|
+ expect(recorded.log.last).to include("#{component}:#{value}")
end
end
end
@@ -90,59 +73,37 @@ RSpec.describe 'Marginalia spec' do
}
end
- context 'when the feature is enabled' do
- before do
- stub_feature(true)
+ it 'generates a query that includes the component and value' do
+ component_map.each do |component, value|
+ expect(recorded.log.last).to include("#{component}:#{value}")
end
+ end
- it 'generates a query that includes the component and value' do
- component_map.each do |component, value|
- expect(recorded.log.last).to include("#{component}:#{value}")
- end
- end
-
- describe 'for ActionMailer delivery jobs' do
- let(:delivery_job) { MarginaliaTestMailer.first_user.deliver_later }
-
- let(:recorded) do
- ActiveRecord::QueryRecorder.new do
- delivery_job.perform_now
- end
- end
-
- let(:component_map) do
- {
- "application" => "sidekiq",
- "jid" => delivery_job.job_id,
- "job_class" => delivery_job.arguments.first
- }
- end
+ describe 'for ActionMailer delivery jobs' do
+ let(:delivery_job) { MarginaliaTestMailer.first_user.deliver_later }
- it 'generates a query that includes the component and value' do
- component_map.each do |component, value|
- expect(recorded.log.last).to include("#{component}:#{value}")
- end
+ let(:recorded) do
+ ActiveRecord::QueryRecorder.new do
+ delivery_job.perform_now
end
end
- end
- context 'when the feature is disabled' do
- before do
- stub_feature(false)
+ let(:component_map) do
+ {
+ "application" => "sidekiq",
+ "jid" => delivery_job.job_id,
+ "job_class" => delivery_job.arguments.first
+ }
end
- it 'excludes annotations in generated queries' do
- expect(recorded.log.last).not_to include("/*")
- expect(recorded.log.last).not_to include("*/")
+ it 'generates a query that includes the component and value' do
+ component_map.each do |component, value|
+ expect(recorded.log.last).to include("#{component}:#{value}")
+ end
end
end
end
- def stub_feature(value)
- stub_feature_flags(marginalia: value)
- Gitlab::Marginalia.set_enabled_from_feature_flag
- end
-
def make_request(correlation_id)
request_env = Rack::MockRequest.env_for('/')
diff --git a/spec/lib/object_storage/direct_upload_spec.rb b/spec/lib/object_storage/direct_upload_spec.rb
index 547bba5117a..12c6cbe03b3 100644
--- a/spec/lib/object_storage/direct_upload_spec.rb
+++ b/spec/lib/object_storage/direct_upload_spec.rb
@@ -224,6 +224,17 @@ RSpec.describe ObjectStorage::DirectUpload do
expect(subject[:CustomPutHeaders]).to be_truthy
expect(subject[:PutHeaders]).to eq({})
end
+
+ context 'with an object with UTF-8 characters' do
+ let(:object_name) { 'tmp/uploads/テスト' }
+
+ it 'returns an escaped path' do
+ expect(subject[:GetURL]).to start_with(storage_url)
+
+ uri = Addressable::URI.parse(subject[:GetURL])
+ expect(uri.path).to include("tmp/uploads/#{CGI.escape("テスト")}")
+ end
+ end
end
shared_examples 'a valid upload with multipart data' do
diff --git a/spec/lib/pager_duty/webhook_payload_parser_spec.rb b/spec/lib/pager_duty/webhook_payload_parser_spec.rb
index 54c61b9121c..647f19e3d3a 100644
--- a/spec/lib/pager_duty/webhook_payload_parser_spec.rb
+++ b/spec/lib/pager_duty/webhook_payload_parser_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
-require 'json_schemer'
+require 'spec_helper'
RSpec.describe PagerDuty::WebhookPayloadParser do
describe '.call' do
diff --git a/spec/lib/peek/views/active_record_spec.rb b/spec/lib/peek/views/active_record_spec.rb
new file mode 100644
index 00000000000..dad5a2bf461
--- /dev/null
+++ b/spec/lib/peek/views/active_record_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Peek::Views::ActiveRecord, :request_store do
+ subject { Peek.views.find { |v| v.instance_of?(Peek::Views::ActiveRecord) } }
+
+ let(:connection) { double(:connection) }
+
+ let(:event_1) do
+ {
+ name: 'SQL',
+ sql: 'SELECT * FROM users WHERE id = 10',
+ cached: false,
+ connection: connection
+ }
+ end
+
+ let(:event_2) do
+ {
+ name: 'SQL',
+ sql: 'SELECT * FROM users WHERE id = 10',
+ cached: true,
+ connection: connection
+ }
+ end
+
+ let(:event_3) do
+ {
+ name: 'SQL',
+ sql: 'UPDATE users SET admin = true WHERE id = 10',
+ cached: false,
+ connection: connection
+ }
+ end
+
+ before do
+ allow(Gitlab::PerformanceBar).to receive(:enabled_for_request?).and_return(true)
+ end
+
+ it 'subscribes and store data into peek views' do
+ Timecop.freeze(2021, 2, 23, 10, 0) do
+ ActiveSupport::Notifications.publish('sql.active_record', Time.current, Time.current + 1.second, '1', event_1)
+ ActiveSupport::Notifications.publish('sql.active_record', Time.current, Time.current + 2.seconds, '2', event_2)
+ ActiveSupport::Notifications.publish('sql.active_record', Time.current, Time.current + 3.seconds, '3', event_3)
+ end
+
+ expect(subject.results).to match(
+ calls: '3 (1 cached)',
+ duration: '6000.00ms',
+ warnings: ["active-record duration: 6000.0 over 3000"],
+ details: contain_exactly(
+ a_hash_including(
+ cached: '',
+ duration: 1000.0,
+ sql: 'SELECT * FROM users WHERE id = 10'
+ ),
+ a_hash_including(
+ cached: 'cached',
+ duration: 2000.0,
+ sql: 'SELECT * FROM users WHERE id = 10'
+ ),
+ a_hash_including(
+ cached: '',
+ duration: 3000.0,
+ sql: 'UPDATE users SET admin = true WHERE id = 10'
+ )
+ )
+ )
+ end
+end
diff --git a/spec/lib/quality/test_level_spec.rb b/spec/lib/quality/test_level_spec.rb
index 2232d47234f..32960cd571b 100644
--- a/spec/lib/quality/test_level_spec.rb
+++ b/spec/lib/quality/test_level_spec.rb
@@ -28,7 +28,7 @@ RSpec.describe Quality::TestLevel do
context 'when level is unit' do
it 'returns a pattern' do
expect(subject.pattern(:unit))
- .to eq("spec/{bin,channels,config,db,dependencies,elastic,elastic_integration,experiments,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,models,policies,presenters,rack_servers,replicators,routing,rubocop,serializers,services,sidekiq,support_specs,tasks,uploaders,validators,views,workers,tooling}{,/**/}*_spec.rb")
+ .to eq("spec/{bin,channels,config,db,dependencies,elastic,elastic_integration,experiments,factories,finders,frontend,graphql,haml_lint,helpers,initializers,javascripts,lib,models,policies,presenters,rack_servers,replicators,routing,rubocop,serializers,services,sidekiq,spam,support_specs,tasks,uploaders,validators,views,workers,tooling}{,/**/}*_spec.rb")
end
end
@@ -103,7 +103,7 @@ RSpec.describe Quality::TestLevel do
context 'when level is unit' do
it 'returns a regexp' do
expect(subject.regexp(:unit))
- .to eq(%r{spec/(bin|channels|config|db|dependencies|elastic|elastic_integration|experiments|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|models|policies|presenters|rack_servers|replicators|routing|rubocop|serializers|services|sidekiq|support_specs|tasks|uploaders|validators|views|workers|tooling)})
+ .to eq(%r{spec/(bin|channels|config|db|dependencies|elastic|elastic_integration|experiments|factories|finders|frontend|graphql|haml_lint|helpers|initializers|javascripts|lib|models|policies|presenters|rack_servers|replicators|routing|rubocop|serializers|services|sidekiq|spam|support_specs|tasks|uploaders|validators|views|workers|tooling)})
end
end
diff --git a/spec/lib/release_highlights/validator/entry_spec.rb b/spec/lib/release_highlights/validator/entry_spec.rb
index da44938f165..5f7ccbf4310 100644
--- a/spec/lib/release_highlights/validator/entry_spec.rb
+++ b/spec/lib/release_highlights/validator/entry_spec.rb
@@ -40,8 +40,8 @@ RSpec.describe ReleaseHighlights::Validator::Entry do
end
it 'validates boolean value of "self-managed" and "gitlab-com"' do
- allow(entry).to receive(:value_for).with('self-managed').and_return('nope')
- allow(entry).to receive(:value_for).with('gitlab-com').and_return('yerp')
+ allow(entry).to receive(:value_for).with(:'self-managed').and_return('nope')
+ allow(entry).to receive(:value_for).with(:'gitlab-com').and_return('yerp')
subject.valid?
@@ -50,17 +50,18 @@ RSpec.describe ReleaseHighlights::Validator::Entry do
end
it 'validates URI of "url" and "image_url"' do
- allow(entry).to receive(:value_for).with('image_url').and_return('imgur/gitlab_feature.gif')
- allow(entry).to receive(:value_for).with('url').and_return('gitlab/newest_release.html')
+ stub_env('RSPEC_ALLOW_INVALID_URLS', 'false')
+ allow(entry).to receive(:value_for).with(:image_url).and_return('https://foobar.x/images/ci/gitlab-ci-cd-logo_2x.png')
+ allow(entry).to receive(:value_for).with(:url).and_return('')
subject.valid?
- expect(subject.errors[:url]).to include(/must be a URL/)
- expect(subject.errors[:image_url]).to include(/must be a URL/)
+ expect(subject.errors[:url]).to include(/must be a valid URL/)
+ expect(subject.errors[:image_url]).to include(/is blocked: Host cannot be resolved or invalid/)
end
it 'validates release is numerical' do
- allow(entry).to receive(:value_for).with('release').and_return('one')
+ allow(entry).to receive(:value_for).with(:release).and_return('one')
subject.valid?
@@ -68,7 +69,7 @@ RSpec.describe ReleaseHighlights::Validator::Entry do
end
it 'validates published_at is a date' do
- allow(entry).to receive(:value_for).with('published_at').and_return('christmas day')
+ allow(entry).to receive(:value_for).with(:published_at).and_return('christmas day')
subject.valid?
@@ -76,7 +77,7 @@ RSpec.describe ReleaseHighlights::Validator::Entry do
end
it 'validates packages are included in list' do
- allow(entry).to receive(:value_for).with('packages').and_return(['ALL'])
+ allow(entry).to receive(:value_for).with(:packages).and_return(['ALL'])
subject.valid?
diff --git a/spec/lib/release_highlights/validator_spec.rb b/spec/lib/release_highlights/validator_spec.rb
index a423e8cc5f6..f30754b4167 100644
--- a/spec/lib/release_highlights/validator_spec.rb
+++ b/spec/lib/release_highlights/validator_spec.rb
@@ -78,7 +78,10 @@ RSpec.describe ReleaseHighlights::Validator do
end
describe 'when validating all files' do
- it 'they should have no errors' do
+ # Permit DNS requests to validate all URLs in the YAML files
+ it 'they should have no errors', :permit_dns do
+ stub_env('RSPEC_ALLOW_INVALID_URLS', 'false')
+
expect(described_class.validate_all!).to be_truthy, described_class.error_message
end
end
diff --git a/spec/lib/system_check/sidekiq_check_spec.rb b/spec/lib/system_check/sidekiq_check_spec.rb
new file mode 100644
index 00000000000..c2f61e0e4b7
--- /dev/null
+++ b/spec/lib/system_check/sidekiq_check_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe SystemCheck::SidekiqCheck do
+ describe '#multi_check' do
+ def stub_ps_output(output)
+ allow(Gitlab::Popen).to receive(:popen).with(%w(ps uxww)).and_return([output, nil])
+ end
+
+ def expect_check_output(matcher)
+ expect { subject.multi_check }.to output(matcher).to_stdout
+ end
+
+ it 'fails when no worker processes are running' do
+ stub_ps_output <<~PS
+ root 2193947 0.9 0.1 146564 18104 ? Ssl 17:34 0:00 ruby bin/sidekiq-cluster * -P ...
+ PS
+
+ expect_check_output include(
+ 'Running? ... no',
+ 'Please fix the error above and rerun the checks.'
+ )
+ end
+
+ it 'fails when more than one cluster process is running' do
+ stub_ps_output <<~PS
+ root 2193947 0.9 0.1 146564 18104 ? Ssl 17:34 0:00 ruby bin/sidekiq-cluster * -P ...
+ root 2193948 0.9 0.1 146564 18104 ? Ssl 17:34 0:00 ruby bin/sidekiq-cluster * -P ...
+ root 2193955 92.2 3.1 4675972 515516 ? Sl 17:34 0:13 sidekiq 5.2.9 ...
+ PS
+
+ expect_check_output include(
+ 'Running? ... yes',
+ 'Number of Sidekiq processes (cluster/worker) ... 2/1',
+ 'Please fix the error above and rerun the checks.'
+ )
+ end
+
+ it 'succeeds when one cluster process and one or more worker processes are running' do
+ stub_ps_output <<~PS
+ root 2193947 0.9 0.1 146564 18104 ? Ssl 17:34 0:00 ruby bin/sidekiq-cluster * -P ...
+ root 2193955 92.2 3.1 4675972 515516 ? Sl 17:34 0:13 sidekiq 5.2.9 ...
+ root 2193956 92.2 3.1 4675972 515516 ? Sl 17:34 0:13 sidekiq 5.2.9 ...
+ PS
+
+ expect_check_output <<~OUTPUT
+ Running? ... yes
+ Number of Sidekiq processes (cluster/worker) ... 1/2
+ OUTPUT
+ end
+
+ # TODO: Running without a cluster is deprecated and will be removed in GitLab 14.0
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/323225
+ context 'when running without a cluster' do
+ it 'fails when more than one worker process is running' do
+ stub_ps_output <<~PS
+ root 2193955 92.2 3.1 4675972 515516 ? Sl 17:34 0:13 sidekiq 5.2.9 ...
+ root 2193956 92.2 3.1 4675972 515516 ? Sl 17:34 0:13 sidekiq 5.2.9 ...
+ PS
+
+ expect_check_output include(
+ 'Running? ... yes',
+ 'Number of Sidekiq processes (cluster/worker) ... 0/2',
+ 'Please fix the error above and rerun the checks.'
+ )
+ end
+
+ it 'succeeds when one worker process is running' do
+ stub_ps_output <<~PS
+ root 2193955 92.2 3.1 4675972 515516 ? Sl 17:34 0:13 sidekiq 5.2.9 ...
+ PS
+
+ expect_check_output <<~OUTPUT
+ Running? ... yes
+ Number of Sidekiq processes (cluster/worker) ... 0/1
+ OUTPUT
+ end
+ end
+ end
+end
diff --git a/spec/mailers/emails/in_product_marketing_spec.rb b/spec/mailers/emails/in_product_marketing_spec.rb
new file mode 100644
index 00000000000..e4157eaf5dc
--- /dev/null
+++ b/spec/mailers/emails/in_product_marketing_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'email_spec'
+
+RSpec.describe Emails::InProductMarketing do
+ include EmailSpec::Matchers
+ include InProductMarketingHelper
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+
+ describe '#in_product_marketing_email' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:track, :series) do
+ :create | 0
+ :create | 1
+ :create | 2
+ :verify | 0
+ :verify | 1
+ :verify | 2
+ :trial | 0
+ :trial | 1
+ :trial | 2
+ :team | 0
+ :team | 1
+ :team | 2
+ end
+
+ with_them do
+ subject { Notify.in_product_marketing_email(user.id, group.id, track, series) }
+
+ it 'has the correct subject and content' do
+ aggregate_failures do
+ is_expected.to have_subject(subject_line(track, series))
+ is_expected.to have_body_text(in_product_marketing_title(track, series))
+ is_expected.to have_body_text(in_product_marketing_subtitle(track, series))
+ is_expected.to have_body_text(in_product_marketing_cta_text(track, series))
+ end
+ end
+ end
+ end
+end
diff --git a/spec/mailers/emails/merge_requests_spec.rb b/spec/mailers/emails/merge_requests_spec.rb
index 34665d943ab..0c0dae6d7e6 100644
--- a/spec/mailers/emails/merge_requests_spec.rb
+++ b/spec/mailers/emails/merge_requests_spec.rb
@@ -6,37 +6,199 @@ require 'email_spec'
RSpec.describe Emails::MergeRequests do
include EmailSpec::Matchers
- describe "#resolved_all_discussions_email" do
- let(:user) { create(:user) }
- let(:merge_request) { create(:merge_request) }
- let(:current_user) { create(:user) }
+ include_context 'gitlab email notification'
+
+ let_it_be(:current_user, reload: true) { create(:user, email: "current@email.com", name: 'www.example.com') }
+ let_it_be(:assignee, reload: true) { create(:user, email: 'assignee@example.com', name: 'John Doe') }
+ let_it_be(:reviewer, reload: true) { create(:user, email: 'reviewer@example.com', name: 'Jane Doe') }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:merge_request) do
+ create(:merge_request, source_project: project,
+ target_project: project,
+ author: current_user,
+ assignees: [assignee],
+ reviewers: [reviewer],
+ description: 'Awesome description')
+ end
- subject { Notify.resolved_all_discussions_email(user.id, merge_request.id, current_user.id) }
+ let(:recipient) { assignee }
+ let(:current_user_sanitized) { 'www_example_com' }
- it "includes the name of the resolver" do
- expect(subject).to have_body_text current_user.name
+ describe '#new_mention_in_merge_request_email' do
+ subject { Notify.new_mention_in_merge_request_email(recipient.id, merge_request.id, current_user.id) }
+
+ it 'has the correct subject and body' do
+ aggregate_failures do
+ is_expected.to have_referable_subject(merge_request, reply: true)
+ is_expected.to have_body_text(project_merge_request_path(project, merge_request))
+ is_expected.to have_body_text('You have been mentioned in Merge Request')
+ is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request))
+ is_expected.to have_text_part_content(assignee.name)
+ is_expected.to have_text_part_content(reviewer.name)
+ is_expected.to have_html_part_content(assignee.name)
+ is_expected.to have_html_part_content(reviewer.name)
+ end
+ end
+ end
+
+ describe '#merge_request_unmergeable_email' do
+ subject { Notify.merge_request_unmergeable_email(recipient.id, merge_request.id) }
+
+ it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
+ let(:model) { merge_request }
+ end
+
+ it_behaves_like 'a multiple recipients email'
+ it_behaves_like 'it should show Gmail Actions View Merge request link'
+ it_behaves_like 'an unsubscribeable thread'
+ it_behaves_like 'appearance header and footer enabled'
+ it_behaves_like 'appearance header and footer not enabled'
+
+ it 'is sent as the merge request author' do
+ sender = subject.header[:from].addrs[0]
+ expect(sender.display_name).to eq(merge_request.author.name)
+ expect(sender.address).to eq(gitlab_sender)
+ end
+
+ it 'has the correct subject and body' do
+ aggregate_failures do
+ is_expected.to have_referable_subject(merge_request, reply: true)
+ is_expected.to have_body_text(project_merge_request_path(project, merge_request))
+ is_expected.to have_body_text('due to conflict.')
+ is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request))
+ is_expected.to have_text_part_content(assignee.name)
+ is_expected.to have_text_part_content(reviewer.name)
+ end
+ end
+ end
+
+ describe '#closed_merge_request_email' do
+ subject { Notify.closed_merge_request_email(recipient.id, merge_request.id, current_user.id) }
+
+ it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
+ let(:model) { merge_request }
+ end
+
+ it_behaves_like 'it should show Gmail Actions View Merge request link'
+ it_behaves_like 'an unsubscribeable thread'
+ it_behaves_like 'appearance header and footer enabled'
+ it_behaves_like 'appearance header and footer not enabled'
+
+ it 'is sent as the author' do
+ sender = subject.header[:from].addrs[0]
+ expect(sender.display_name).to eq(current_user.name)
+ expect(sender.address).to eq(gitlab_sender)
+ end
+
+ it 'has the correct subject and body' do
+ aggregate_failures do
+ is_expected.to have_referable_subject(merge_request, reply: true)
+ is_expected.to have_body_text('closed')
+ is_expected.to have_body_text(current_user_sanitized)
+ is_expected.to have_body_text(project_merge_request_path(project, merge_request))
+ is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request))
+
+ expect(subject.text_part).to have_content(assignee.name)
+ expect(subject.text_part).to have_content(reviewer.name)
+ end
+ end
+ end
+
+ describe '#merged_merge_request_email' do
+ let(:merge_author) { assignee }
+
+ subject { Notify.merged_merge_request_email(recipient.id, merge_request.id, merge_author.id) }
+
+ 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 }
+ end
+
+ it_behaves_like 'it should show Gmail Actions View Merge request link'
+ it_behaves_like 'an unsubscribeable thread'
+ it_behaves_like 'appearance header and footer enabled'
+ it_behaves_like 'appearance header and footer not enabled'
+
+ it 'is sent as the merge author' do
+ sender = subject.header[:from].addrs[0]
+ expect(sender.display_name).to eq(merge_author.name)
+ expect(sender.address).to eq(gitlab_sender)
+ end
+
+ it 'has the correct subject and body' do
+ aggregate_failures do
+ is_expected.to have_referable_subject(merge_request, reply: true)
+ is_expected.to have_body_text('merged')
+ is_expected.to have_body_text(project_merge_request_path(project, merge_request))
+ is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request))
+
+ expect(subject.text_part).to have_content(assignee.name)
+ expect(subject.text_part).to have_content(reviewer.name)
+ end
+ end
+ end
+
+ describe '#merge_request_status_email' do
+ let(:status) { 'reopened' }
+
+ subject { Notify.merge_request_status_email(recipient.id, merge_request.id, status, current_user.id) }
+
+ it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
+ let(:model) { merge_request }
+ end
+
+ it_behaves_like 'it should show Gmail Actions View Merge request link'
+ it_behaves_like 'an unsubscribeable thread'
+ it_behaves_like 'appearance header and footer enabled'
+ it_behaves_like 'appearance header and footer not enabled'
+
+ it 'is sent as the author' do
+ sender = subject.header[:from].addrs[0]
+ expect(sender.display_name).to eq(current_user.name)
+ expect(sender.address).to eq(gitlab_sender)
+ end
+
+ it 'has the correct subject and body' do
+ aggregate_failures do
+ is_expected.to have_referable_subject(merge_request, reply: true)
+ is_expected.to have_body_text(status)
+ is_expected.to have_body_text(current_user_sanitized)
+ is_expected.to have_body_text(project_merge_request_path(project, merge_request))
+ is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request))
+
+ expect(subject.text_part).to have_content(assignee.name)
+ expect(subject.text_part).to have_content(reviewer.name)
+ end
end
end
describe "#merge_when_pipeline_succeeds_email" do
- let(:user) { create(:user) }
- let(:merge_request) { create(:merge_request) }
- let(:current_user) { create(:user) }
- let(:project) { create(:project, :repository) }
let(:title) { "Merge request #{merge_request.to_reference} was scheduled to merge after pipeline succeeds by #{current_user.name}" }
- subject { Notify.merge_when_pipeline_succeeds_email(user.id, merge_request.id, current_user.id) }
+ subject { Notify.merge_when_pipeline_succeeds_email(recipient.id, merge_request.id, current_user.id) }
it "has required details" do
- expect(subject).to have_content title
- expect(subject).to have_content merge_request.to_reference
- expect(subject).to have_content current_user.name
+ aggregate_failures do
+ is_expected.to have_content(title)
+ is_expected.to have_content(merge_request.to_reference)
+ is_expected.to have_content(current_user.name)
+ is_expected.to have_text_part_content(assignee.name)
+ is_expected.to have_html_part_content(assignee.name)
+ is_expected.to have_text_part_content(reviewer.name)
+ is_expected.to have_html_part_content(reviewer.name)
+ end
+ end
+ end
+
+ describe "#resolved_all_discussions_email" do
+ subject { Notify.resolved_all_discussions_email(recipient.id, merge_request.id, current_user.id) }
+
+ it "includes the name of the resolver" do
+ expect(subject).to have_body_text current_user_sanitized
end
end
describe '#merge_requests_csv_email' do
- let(:user) { create(:user) }
- let(:project) { create(:project) }
let(:merge_requests) { create_list(:merge_request, 10) }
let(:export_status) do
{
@@ -48,10 +210,10 @@ RSpec.describe Emails::MergeRequests do
let(:csv_data) { MergeRequests::ExportCsvService.new(MergeRequest.all, project).csv_data }
- subject { Notify.merge_requests_csv_email(user, project, csv_data, export_status) }
+ subject { Notify.merge_requests_csv_email(recipient, project, csv_data, export_status) }
it { expect(subject.subject).to eq("#{project.name} | Exported merge requests") }
- it { expect(subject.to).to contain_exactly(user.notification_email_for(project.group)) }
+ it { expect(subject.to).to contain_exactly(recipient.notification_email_for(project.group)) }
it { expect(subject.html_part).to have_content("Your CSV export of 10 merge requests from project") }
it { expect(subject.text_part).to have_content("Your CSV export of 10 merge requests from project") }
diff --git a/spec/mailers/emails/pipelines_spec.rb b/spec/mailers/emails/pipelines_spec.rb
index 3ac68721357..a29835f3439 100644
--- a/spec/mailers/emails/pipelines_spec.rb
+++ b/spec/mailers/emails/pipelines_spec.rb
@@ -89,7 +89,7 @@ RSpec.describe Emails::Pipelines do
let(:sha) { project.commit(ref).sha }
it_behaves_like 'correct pipeline information' do
- let(:status) { 'Succesful' }
+ let(:status) { 'Successful' }
let(:status_text) { "Pipeline ##{pipeline.id} has passed!" }
end
end
diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb
index fdff2d837f8..a32e566fc90 100644
--- a/spec/mailers/emails/profile_spec.rb
+++ b/spec/mailers/emails/profile_spec.rb
@@ -125,8 +125,9 @@ RSpec.describe Emails::Profile do
describe 'user personal access token is about to expire' do
let_it_be(:user) { create(:user) }
+ let_it_be(:expiring_token) { create(:personal_access_token, user: user, expires_at: 5.days.from_now) }
- subject { Notify.access_token_about_to_expire_email(user) }
+ subject { Notify.access_token_about_to_expire_email(user, [expiring_token.name]) }
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
@@ -137,13 +138,17 @@ RSpec.describe Emails::Profile do
end
it 'has the correct subject' do
- is_expected.to have_subject /^Your Personal Access Tokens will expire in 7 days or less$/i
+ is_expected.to have_subject /^Your personal access tokens will expire in 7 days or less$/i
end
it 'mentions the access tokens will expire' do
is_expected.to have_body_text /One or more of your personal access tokens will expire in 7 days or less/
end
+ it 'provides the names of expiring tokens' do
+ is_expected.to have_body_text /#{expiring_token.name}/
+ end
+
it 'includes a link to personal access tokens page' do
is_expected.to have_body_text /#{profile_personal_access_tokens_path}/
end
diff --git a/spec/mailers/emails/service_desk_spec.rb b/spec/mailers/emails/service_desk_spec.rb
index 7d04b373be6..cb74194020d 100644
--- a/spec/mailers/emails/service_desk_spec.rb
+++ b/spec/mailers/emails/service_desk_spec.rb
@@ -13,8 +13,13 @@ RSpec.describe Emails::ServiceDesk do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:email) { 'someone@gitlab.com' }
let(:template) { double(content: template_content) }
+ before_all do
+ issue.issue_email_participants.create!(email: email)
+ end
+
before do
stub_const('ServiceEmailClass', Class.new(ApplicationMailer))
@@ -72,6 +77,10 @@ RSpec.describe Emails::ServiceDesk do
let(:template_content) { 'custom text' }
let(:issue) { create(:issue, project: project)}
+ before do
+ issue.issue_email_participants.create!(email: email)
+ end
+
context 'when a template is in the repository' do
let(:project) { create(:project, :custom_repo, files: { ".gitlab/service_desk_templates/#{template_key}.md" => template_content }) }
@@ -151,7 +160,7 @@ RSpec.describe Emails::ServiceDesk do
let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project) }
let_it_be(:default_text) { note.note }
- subject { ServiceEmailClass.service_desk_new_note_email(issue.id, note.id) }
+ subject { ServiceEmailClass.service_desk_new_note_email(issue.id, note.id, email) }
it_behaves_like 'read template from repository', 'new_note'
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index 89cf1aaedd2..79358d3e40c 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -16,12 +16,14 @@ RSpec.describe Notify do
let_it_be(:user, reload: true) { create(:user) }
let_it_be(:current_user, reload: true) { create(:user, email: "current@email.com", name: 'www.example.com') }
let_it_be(:assignee, reload: true) { create(:user, email: 'assignee@example.com', name: 'John Doe') }
+ let_it_be(:reviewer, reload: true) { create(:user, email: 'reviewer@example.com', name: 'Jane Doe') }
let_it_be(:merge_request) do
create(:merge_request, source_project: project,
target_project: project,
author: current_user,
assignees: [assignee],
+ reviewers: [reviewer],
description: 'Awesome description')
end
@@ -342,6 +344,7 @@ RSpec.describe Notify do
is_expected.to have_body_text(project_merge_request_path(project, merge_request))
is_expected.to have_body_text(merge_request.source_branch)
is_expected.to have_body_text(merge_request.target_branch)
+ is_expected.to have_body_text(reviewer.name)
end
end
@@ -362,7 +365,11 @@ RSpec.describe Notify do
it 'contains a link to merge request author' do
is_expected.to have_body_text merge_request.author_name
- is_expected.to have_body_text 'created a merge request:'
+ is_expected.to have_body_text 'created a'
+ end
+
+ it 'contains a link to the merge request url' do
+ is_expected.to have_link('merge request', href: project_merge_request_url(merge_request.target_project, merge_request))
end
end
@@ -461,106 +468,6 @@ RSpec.describe Notify do
end
end
- describe 'status changed' do
- let(:status) { 'reopened' }
-
- subject { described_class.merge_request_status_email(recipient.id, merge_request.id, status, current_user.id) }
-
- it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
- let(:model) { merge_request }
- end
-
- it_behaves_like 'it should show Gmail Actions View Merge request link'
- it_behaves_like 'an unsubscribeable thread'
- it_behaves_like 'appearance header and footer enabled'
- it_behaves_like 'appearance header and footer not enabled'
-
- it 'is sent as the author' do
- sender = subject.header[:from].addrs[0]
- expect(sender.display_name).to eq(current_user.name)
- expect(sender.address).to eq(gitlab_sender)
- end
-
- it 'has the correct subject and body' do
- aggregate_failures do
- is_expected.to have_referable_subject(merge_request, reply: true)
- is_expected.to have_body_text(status)
- is_expected.to have_body_text(current_user_sanitized)
- is_expected.to have_body_text(project_merge_request_path(project, merge_request))
- is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request))
- end
- end
- end
-
- describe 'that are merged' do
- let(:merge_author) { create(:user) }
-
- subject { described_class.merged_merge_request_email(recipient.id, merge_request.id, merge_author.id) }
-
- 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 }
- end
-
- it_behaves_like 'it should show Gmail Actions View Merge request link'
- it_behaves_like 'an unsubscribeable thread'
- it_behaves_like 'appearance header and footer enabled'
- it_behaves_like 'appearance header and footer not enabled'
-
- it 'is sent as the merge author' do
- sender = subject.header[:from].addrs[0]
- expect(sender.display_name).to eq(merge_author.name)
- expect(sender.address).to eq(gitlab_sender)
- end
-
- it 'has the correct subject and body' do
- aggregate_failures do
- is_expected.to have_referable_subject(merge_request, reply: true)
- is_expected.to have_body_text('merged')
- is_expected.to have_body_text(project_merge_request_path(project, merge_request))
- is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request))
- end
- end
- end
-
- describe 'that are unmergeable' do
- let_it_be(:merge_request) do
- create(:merge_request, :conflict,
- source_project: project,
- target_project: project,
- author: current_user,
- assignees: [assignee],
- description: 'Awesome description')
- end
-
- subject { described_class.merge_request_unmergeable_email(recipient.id, merge_request.id) }
-
- 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 }
- end
-
- it_behaves_like 'it should show Gmail Actions View Merge request link'
- it_behaves_like 'an unsubscribeable thread'
- it_behaves_like 'appearance header and footer enabled'
- it_behaves_like 'appearance header and footer not enabled'
-
- it 'is sent as the merge request author' do
- sender = subject.header[:from].addrs[0]
- expect(sender.display_name).to eq(merge_request.author.name)
- expect(sender.address).to eq(gitlab_sender)
- end
-
- it 'has the correct subject and body' do
- aggregate_failures do
- is_expected.to have_referable_subject(merge_request, reply: true)
- is_expected.to have_body_text(project_merge_request_path(project, merge_request))
- is_expected.to have_body_text('due to conflict.')
- is_expected.to have_link(merge_request.to_reference, href: project_merge_request_url(merge_request.target_project, merge_request))
- end
- end
- end
-
shared_examples 'a push to an existing merge request' do
let(:push_user) { create(:user) }
@@ -1311,6 +1218,7 @@ RSpec.describe Notify do
context 'for service desk issues' do
before do
issue.update!(external_author: 'service.desk@example.com')
+ issue.issue_email_participants.create!(email: 'service.desk@example.com')
end
def expect_sender(username)
@@ -1359,7 +1267,7 @@ RSpec.describe Notify do
describe 'new note email' do
let_it_be(:first_note) { create(:discussion_note_on_issue, note: 'Hello world') }
- subject { described_class.service_desk_new_note_email(issue.id, first_note.id) }
+ subject { described_class.service_desk_new_note_email(issue.id, first_note.id, 'service.desk@example.com') }
it_behaves_like 'an unsubscribeable thread'
diff --git a/spec/migrations/20201112130715_schedule_recalculate_uuid_on_vulnerabilities_occurrences_spec.rb b/spec/migrations/20201112130715_schedule_recalculate_uuid_on_vulnerabilities_occurrences_spec.rb
new file mode 100644
index 00000000000..fce32be4683
--- /dev/null
+++ b/spec/migrations/20201112130715_schedule_recalculate_uuid_on_vulnerabilities_occurrences_spec.rb
@@ -0,0 +1,138 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20201112130715_schedule_recalculate_uuid_on_vulnerabilities_occurrences.rb')
+
+RSpec.describe ScheduleRecalculateUuidOnVulnerabilitiesOccurrences, :migration do
+ let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') }
+ let(:users) { table(:users) }
+ let(:user) { create_user! }
+ let(:project) { table(:projects).create!(id: 123, namespace_id: namespace.id) }
+ let(:scanners) { table(:vulnerability_scanners) }
+ let(:scanner) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') }
+ let(:different_scanner) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') }
+ let(:vulnerabilities) { table(:vulnerabilities) }
+ let(:vulnerabilities_findings) { table(:vulnerability_occurrences) }
+ let(:vulnerability_identifiers) { table(:vulnerability_identifiers) }
+ let(:vulnerability_identifier) do
+ vulnerability_identifiers.create!(
+ project_id: project.id,
+ external_type: 'uuid-v5',
+ external_id: 'uuid-v5',
+ fingerprint: '7e394d1b1eb461a7406d7b1e08f057a1cf11287a',
+ name: 'Identifier for UUIDv5')
+ end
+
+ let(:different_vulnerability_identifier) do
+ vulnerability_identifiers.create!(
+ project_id: project.id,
+ external_type: 'uuid-v4',
+ external_id: 'uuid-v4',
+ fingerprint: '772da93d34a1ba010bcb5efa9fb6f8e01bafcc89',
+ name: 'Identifier for UUIDv4')
+ end
+
+ let!(:vulnerability_for_uuidv4) do
+ create_vulnerability!(
+ project_id: project.id,
+ author_id: user.id
+ )
+ end
+
+ let!(:vulnerability_for_uuidv5) do
+ create_vulnerability!(
+ project_id: project.id,
+ author_id: user.id
+ )
+ end
+
+ let(:known_uuid_v4) { "b3cc2518-5446-4dea-871c-89d5e999c1ac" }
+ let!(:finding_with_uuid_v4) do
+ create_finding!(
+ vulnerability_id: vulnerability_for_uuidv4.id,
+ project_id: project.id,
+ scanner_id: different_scanner.id,
+ primary_identifier_id: different_vulnerability_identifier.id,
+ report_type: 0, # "sast"
+ location_fingerprint: "fa18f432f1d56675f4098d318739c3cd5b14eb3e",
+ uuid: known_uuid_v4
+ )
+ end
+
+ let(:known_uuid_v5) { "e7d3d99d-04bb-5771-bb44-d80a9702d0a2" }
+ let!(:finding_with_uuid_v5) do
+ create_finding!(
+ vulnerability_id: vulnerability_for_uuidv5.id,
+ project_id: project.id,
+ scanner_id: scanner.id,
+ primary_identifier_id: vulnerability_identifier.id,
+ report_type: 0, # "sast"
+ location_fingerprint: "838574be0210968bf6b9f569df9c2576242cbf0a",
+ uuid: known_uuid_v5
+ )
+ end
+
+ before do
+ stub_const("#{described_class}::BATCH_SIZE", 1)
+ end
+
+ around do |example|
+ freeze_time { Sidekiq::Testing.fake! { example.run } }
+ end
+
+ it 'schedules background migration' do
+ migrate!
+
+ expect(BackgroundMigrationWorker.jobs.size).to eq(2)
+ expect(described_class::MIGRATION).to be_scheduled_migration(finding_with_uuid_v4.id, finding_with_uuid_v4.id)
+ expect(described_class::MIGRATION).to be_scheduled_migration(finding_with_uuid_v5.id, finding_with_uuid_v5.id)
+ end
+
+ private
+
+ def create_vulnerability!(project_id:, author_id:, title: 'test', severity: 7, confidence: 7, report_type: 0)
+ vulnerabilities.create!(
+ project_id: project_id,
+ author_id: author_id,
+ title: title,
+ severity: severity,
+ confidence: confidence,
+ report_type: report_type
+ )
+ end
+
+ # rubocop:disable Metrics/ParameterLists
+ def create_finding!(
+ vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:,
+ name: "test", severity: 7, confidence: 7, report_type: 0,
+ project_fingerprint: '123qweasdzxc', location_fingerprint: 'test',
+ metadata_version: 'test', raw_metadata: 'test', uuid: 'test')
+ vulnerabilities_findings.create!(
+ vulnerability_id: vulnerability_id,
+ project_id: project_id,
+ name: name,
+ severity: severity,
+ confidence: confidence,
+ report_type: report_type,
+ project_fingerprint: project_fingerprint,
+ scanner_id: scanner.id,
+ primary_identifier_id: vulnerability_identifier.id,
+ location_fingerprint: location_fingerprint,
+ metadata_version: metadata_version,
+ raw_metadata: raw_metadata,
+ uuid: uuid
+ )
+ end
+ # rubocop:enable Metrics/ParameterLists
+
+ def create_user!(name: "Example User", email: "user@example.com", user_type: nil, created_at: Time.now, confirmed_at: Time.now)
+ users.create!(
+ name: name,
+ email: email,
+ username: name,
+ projects_limit: 0,
+ user_type: user_type,
+ confirmed_at: confirmed_at
+ )
+ end
+end
diff --git a/spec/migrations/20210218040814_add_environment_scope_to_group_variables_spec.rb b/spec/migrations/20210218040814_add_environment_scope_to_group_variables_spec.rb
new file mode 100644
index 00000000000..e525101f3a0
--- /dev/null
+++ b/spec/migrations/20210218040814_add_environment_scope_to_group_variables_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!('add_environment_scope_to_group_variables')
+
+RSpec.describe AddEnvironmentScopeToGroupVariables do
+ let(:migration) { described_class.new }
+ let(:ci_group_variables) { table(:ci_group_variables) }
+ let(:group) { table(:namespaces).create!(name: 'group', path: 'group') }
+
+ def create_variable!(group, key:, environment_scope: '*')
+ table(:ci_group_variables).create!(
+ group_id: group.id,
+ key: key,
+ environment_scope: environment_scope
+ )
+ end
+
+ describe '#down' do
+ context 'group has variables with duplicate keys' do
+ it 'deletes all but the first record' do
+ migration.up
+
+ remaining_variable = create_variable!(group, key: 'key')
+ create_variable!(group, key: 'key', environment_scope: 'staging')
+ create_variable!(group, key: 'key', environment_scope: 'production')
+
+ migration.down
+
+ expect(ci_group_variables.pluck(:id)).to eq [remaining_variable.id]
+ end
+ end
+
+ context 'group does not have variables with duplicate keys' do
+ it 'does not delete any records' do
+ migration.up
+
+ create_variable!(group, key: 'key')
+ create_variable!(group, key: 'staging')
+ create_variable!(group, key: 'production')
+
+ expect { migration.down }.not_to change { ci_group_variables.count }
+ end
+ end
+ end
+end
diff --git a/spec/migrations/cleanup_projects_with_bad_has_external_issue_tracker_data_spec.rb b/spec/migrations/cleanup_projects_with_bad_has_external_issue_tracker_data_spec.rb
new file mode 100644
index 00000000000..8aedd1f9607
--- /dev/null
+++ b/spec/migrations/cleanup_projects_with_bad_has_external_issue_tracker_data_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe CleanupProjectsWithBadHasExternalIssueTrackerData, :migration do
+ let(:namespace) { table(:namespaces).create!(name: 'foo', path: 'bar') }
+ let(:projects) { table(:projects) }
+ let(:services) { table(:services) }
+
+ def create_projects!(num)
+ Array.new(num) do
+ projects.create!(namespace_id: namespace.id)
+ end
+ end
+
+ def create_active_external_issue_tracker_integrations!(*projects)
+ projects.each do |project|
+ services.create!(category: 'issue_tracker', project_id: project.id, active: true)
+ end
+ end
+
+ def create_disabled_external_issue_tracker_integrations!(*projects)
+ projects.each do |project|
+ services.create!(category: 'issue_tracker', project_id: project.id, active: false)
+ end
+ end
+
+ def create_active_other_integrations!(*projects)
+ projects.each do |project|
+ services.create!(category: 'not_an_issue_tracker', project_id: project.id, active: true)
+ end
+ end
+
+ it 'sets `projects.has_external_issue_tracker` correctly' do
+ allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false)
+
+ project_with_an_external_issue_tracker_1,
+ project_with_an_external_issue_tracker_2,
+ project_with_only_a_disabled_external_issue_tracker_1,
+ project_with_only_a_disabled_external_issue_tracker_2,
+ project_without_any_external_issue_trackers_1,
+ project_without_any_external_issue_trackers_2 = create_projects!(6)
+
+ create_active_external_issue_tracker_integrations!(
+ project_with_an_external_issue_tracker_1,
+ project_with_an_external_issue_tracker_2
+ )
+
+ create_disabled_external_issue_tracker_integrations!(
+ project_with_an_external_issue_tracker_1,
+ project_with_an_external_issue_tracker_2,
+ project_with_only_a_disabled_external_issue_tracker_1,
+ project_with_only_a_disabled_external_issue_tracker_2
+ )
+
+ create_active_other_integrations!(
+ project_with_an_external_issue_tracker_1,
+ project_with_an_external_issue_tracker_2,
+ project_without_any_external_issue_trackers_1,
+ project_without_any_external_issue_trackers_2
+ )
+
+ # PG triggers on the services table added in
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/51852 will have set
+ # the `has_external_issue_tracker` columns to correct data when the services
+ # records were created above.
+ #
+ # We set the `has_external_issue_tracker` columns for projects to incorrect
+ # data manually below to emulate projects in a state before the PG
+ # triggers were added.
+ project_with_an_external_issue_tracker_2.update!(has_external_issue_tracker: false)
+ project_with_only_a_disabled_external_issue_tracker_2.update!(has_external_issue_tracker: true)
+ project_without_any_external_issue_trackers_2.update!(has_external_issue_tracker: true)
+
+ migrate!
+
+ expected_true = [
+ project_with_an_external_issue_tracker_1,
+ project_with_an_external_issue_tracker_2
+ ].each(&:reload).map(&:has_external_issue_tracker)
+
+ expected_not_true = [
+ project_without_any_external_issue_trackers_1,
+ project_without_any_external_issue_trackers_2,
+ project_with_only_a_disabled_external_issue_tracker_1,
+ project_with_only_a_disabled_external_issue_tracker_2
+ ].each(&:reload).map(&:has_external_issue_tracker)
+
+ expect(expected_true).to all(eq(true))
+ expect(expected_not_true).to all(be_falsey)
+ end
+end
diff --git a/spec/migrations/migrate_delayed_project_removal_from_namespaces_to_namespace_settings_spec.rb b/spec/migrations/migrate_delayed_project_removal_from_namespaces_to_namespace_settings_spec.rb
new file mode 100644
index 00000000000..28a8dcf0d4c
--- /dev/null
+++ b/spec/migrations/migrate_delayed_project_removal_from_namespaces_to_namespace_settings_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require Rails.root.join('db', 'post_migrate', '20210215095328_migrate_delayed_project_removal_from_namespaces_to_namespace_settings.rb')
+
+RSpec.describe MigrateDelayedProjectRemovalFromNamespacesToNamespaceSettings, :migration do
+ let(:namespaces) { table(:namespaces) }
+ let(:namespace_settings) { table(:namespace_settings) }
+
+ let!(:namespace_wo_settings) { namespaces.create!(name: generate(:name), path: generate(:name), delayed_project_removal: true) }
+ let!(:namespace_wo_settings_delay_false) { namespaces.create!(name: generate(:name), path: generate(:name), delayed_project_removal: false) }
+ let!(:namespace_w_settings_delay_true) { namespaces.create!(name: generate(:name), path: generate(:name), delayed_project_removal: true) }
+ let!(:namespace_w_settings_delay_false) { namespaces.create!(name: generate(:name), path: generate(:name), delayed_project_removal: false) }
+
+ let!(:namespace_settings_delay_true) { namespace_settings.create!(namespace_id: namespace_w_settings_delay_true.id, delayed_project_removal: false, created_at: DateTime.now, updated_at: DateTime.now) }
+ let!(:namespace_settings_delay_false) { namespace_settings.create!(namespace_id: namespace_w_settings_delay_false.id, delayed_project_removal: false, created_at: DateTime.now, updated_at: DateTime.now) }
+
+ it 'migrates delayed_project_removal to namespace_settings' do
+ disable_migrations_output { migrate! }
+
+ expect(namespace_settings.count).to eq(3)
+
+ expect(namespace_settings.find_by(namespace_id: namespace_wo_settings.id).delayed_project_removal).to eq(true)
+ expect(namespace_settings.find_by(namespace_id: namespace_wo_settings_delay_false.id)).to be_nil
+
+ expect(namespace_settings_delay_true.reload.delayed_project_removal).to eq(true)
+ expect(namespace_settings_delay_false.reload.delayed_project_removal).to eq(false)
+ end
+end
diff --git a/spec/migrations/move_container_registry_enabled_to_project_features_spec.rb b/spec/migrations/move_container_registry_enabled_to_project_features_spec.rb
new file mode 100644
index 00000000000..c7b07f3ef37
--- /dev/null
+++ b/spec/migrations/move_container_registry_enabled_to_project_features_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20210226120851_move_container_registry_enabled_to_project_features.rb')
+
+RSpec.describe MoveContainerRegistryEnabledToProjectFeatures, :migration do
+ let(:namespace) { table(:namespaces).create!(name: 'gitlab', path: 'gitlab-org') }
+
+ let!(:projects) do
+ [
+ table(:projects).create!(namespace_id: namespace.id, name: 'project 1'),
+ table(:projects).create!(namespace_id: namespace.id, name: 'project 2'),
+ table(:projects).create!(namespace_id: namespace.id, name: 'project 3'),
+ table(:projects).create!(namespace_id: namespace.id, name: 'project 4')
+ ]
+ end
+
+ before do
+ stub_const("#{described_class.name}::BATCH_SIZE", 3)
+ end
+
+ around do |example|
+ Sidekiq::Testing.fake! do
+ freeze_time do
+ example.call
+ end
+ end
+ end
+
+ it 'schedules jobs for ranges of projects' do
+ migrate!
+
+ expect(described_class::MIGRATION)
+ .to be_scheduled_delayed_migration(2.minutes, projects[0].id, projects[2].id)
+
+ expect(described_class::MIGRATION)
+ .to be_scheduled_delayed_migration(4.minutes, projects[3].id, projects[3].id)
+ end
+
+ it 'schedules jobs according to the configured batch size' do
+ expect { migrate! }.to change { BackgroundMigrationWorker.jobs.size }.by(2)
+ end
+end
diff --git a/spec/migrations/reschedule_artifact_expiry_backfill_spec.rb b/spec/migrations/reschedule_artifact_expiry_backfill_spec.rb
new file mode 100644
index 00000000000..532035849cb
--- /dev/null
+++ b/spec/migrations/reschedule_artifact_expiry_backfill_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require Rails.root.join('db', 'post_migrate', '20210224150506_reschedule_artifact_expiry_backfill.rb')
+
+RSpec.describe RescheduleArtifactExpiryBackfill, :migration do
+ let(:migration_class) { Gitlab::BackgroundMigration::BackfillArtifactExpiryDate }
+ let(:migration_name) { migration_class.to_s.demodulize }
+
+ before do
+ table(:namespaces).create!(id: 123, name: 'test_namespace', path: 'test_namespace')
+ table(:projects).create!(id: 123, name: 'sample_project', path: 'sample_project', namespace_id: 123)
+ end
+
+ it 'correctly schedules background migrations' do
+ first_artifact = create_artifact(job_id: 0, expire_at: nil, created_at: Date.new(2020, 06, 21))
+ second_artifact = create_artifact(job_id: 1, expire_at: nil, created_at: Date.new(2020, 06, 21))
+ create_artifact(job_id: 2, expire_at: Date.yesterday, created_at: Date.new(2020, 06, 21))
+ create_artifact(job_id: 3, expire_at: nil, created_at: Date.new(2020, 06, 23))
+
+ Sidekiq::Testing.fake! do
+ freeze_time do
+ migrate!
+
+ expect(BackgroundMigrationWorker.jobs.size).to eq(1)
+ expect(migration_name).to be_scheduled_migration_with_multiple_args(first_artifact.id, second_artifact.id)
+ end
+ end
+ end
+
+ private
+
+ def create_artifact(params)
+ table(:ci_builds).create!(id: params[:job_id], project_id: 123)
+ table(:ci_job_artifacts).create!(project_id: 123, file_type: 1, **params)
+ end
+end
diff --git a/spec/migrations/reschedule_set_default_iteration_cadences_spec.rb b/spec/migrations/reschedule_set_default_iteration_cadences_spec.rb
new file mode 100644
index 00000000000..fb629c90d9f
--- /dev/null
+++ b/spec/migrations/reschedule_set_default_iteration_cadences_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RescheduleSetDefaultIterationCadences do
+ let(:namespaces) { table(:namespaces) }
+ let(:iterations) { table(:sprints) }
+
+ let(:group_1) { namespaces.create!(name: 'test_1', path: 'test_1') }
+ let!(:group_2) { namespaces.create!(name: 'test_2', path: 'test_2') }
+ let(:group_3) { namespaces.create!(name: 'test_3', path: 'test_3') }
+ let(:group_4) { namespaces.create!(name: 'test_4', path: 'test_4') }
+ let(:group_5) { namespaces.create!(name: 'test_5', path: 'test_5') }
+ let(:group_6) { namespaces.create!(name: 'test_6', path: 'test_6') }
+ let(:group_7) { namespaces.create!(name: 'test_7', path: 'test_7') }
+ let(:group_8) { namespaces.create!(name: 'test_8', path: 'test_8') }
+
+ let!(:iteration_1) { iterations.create!(iid: 1, title: 'iteration 1', group_id: group_1.id, start_date: 2.days.from_now, due_date: 3.days.from_now) }
+ let!(:iteration_2) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_3.id, start_date: 2.days.from_now, due_date: 3.days.from_now) }
+ let!(:iteration_3) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_4.id, start_date: 2.days.from_now, due_date: 3.days.from_now) }
+ let!(:iteration_4) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_5.id, start_date: 2.days.from_now, due_date: 3.days.from_now) }
+ let!(:iteration_5) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_6.id, start_date: 2.days.from_now, due_date: 3.days.from_now) }
+ let!(:iteration_6) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_7.id, start_date: 2.days.from_now, due_date: 3.days.from_now) }
+ let!(:iteration_7) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_8.id, start_date: 2.days.from_now, due_date: 3.days.from_now) }
+
+ around do |example|
+ freeze_time { Sidekiq::Testing.fake! { example.run } }
+ end
+
+ it 'schedules the background jobs', :aggregate_failures do
+ stub_const("#{described_class.name}::BATCH_SIZE", 3)
+
+ migrate!
+
+ expect(BackgroundMigrationWorker.jobs.size).to be(3)
+ expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(2.minutes, group_1.id, group_3.id, group_4.id)
+ expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(4.minutes, group_5.id, group_6.id, group_7.id)
+ expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(6.minutes, group_8.id)
+ end
+end
diff --git a/spec/migrations/schedule_merge_request_assignees_migration_progress_check_spec.rb b/spec/migrations/schedule_merge_request_assignees_migration_progress_check_spec.rb
deleted file mode 100644
index 0a69f49f10d..00000000000
--- a/spec/migrations/schedule_merge_request_assignees_migration_progress_check_spec.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require Rails.root.join('db', 'post_migrate', '20190402224749_schedule_merge_request_assignees_migration_progress_check.rb')
-
-RSpec.describe ScheduleMergeRequestAssigneesMigrationProgressCheck do
- describe '#up' do
- it 'schedules MergeRequestAssigneesMigrationProgressCheck background job' do
- expect(BackgroundMigrationWorker).to receive(:perform_async)
- .with(described_class::MIGRATION)
- .and_call_original
-
- subject.up
- end
- end
-end
diff --git a/spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb b/spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb
index 8678361fc64..faa993dfbc7 100644
--- a/spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb
+++ b/spec/migrations/schedule_populate_personal_snippet_statistics_spec.rb
@@ -6,11 +6,15 @@ require Rails.root.join('db', 'post_migrate', '20200714075739_schedule_populate_
RSpec.describe SchedulePopulatePersonalSnippetStatistics do
let(:users) { table(:users) }
+ let(:namespaces) { table(:namespaces) }
let(:snippets) { table(:snippets) }
let(:projects) { table(:projects) }
- let(:user1) { users.create!(id: 1, email: 'user1@example.com', projects_limit: 10, username: 'test1', name: 'Test1', state: 'active') }
- let(:user2) { users.create!(id: 2, email: 'user2@example.com', projects_limit: 10, username: 'test2', name: 'Test2', state: 'active') }
- let(:user3) { users.create!(id: 3, email: 'user3@example.com', projects_limit: 10, username: 'test3', name: 'Test3', state: 'active') }
+ let!(:user1) { users.create!(id: 1, email: 'user1@example.com', projects_limit: 10, username: 'test1', name: 'Test1', state: 'active') }
+ let!(:user2) { users.create!(id: 2, email: 'user2@example.com', projects_limit: 10, username: 'test2', name: 'Test2', state: 'active') }
+ let!(:user3) { users.create!(id: 3, email: 'user3@example.com', projects_limit: 10, username: 'test3', name: 'Test3', state: 'active') }
+ let!(:namespace1) { namespaces.create!(id: 1, owner_id: user1.id, name: 'test1', path: 'test1') }
+ let!(:namespace2) { namespaces.create!(id: 2, owner_id: user2.id, name: 'test2', path: 'test2') }
+ let!(:namespace3) { namespaces.create!(id: 3, owner_id: user3.id, name: 'test3', path: 'test3') }
def create_snippet(id, user_id, type = 'PersonalSnippet')
params = {
diff --git a/spec/models/alert_management/http_integration_spec.rb b/spec/models/alert_management/http_integration_spec.rb
index ddd65e723eb..f88a66a7c27 100644
--- a/spec/models/alert_management/http_integration_spec.rb
+++ b/spec/models/alert_management/http_integration_spec.rb
@@ -81,6 +81,32 @@ RSpec.describe AlertManagement::HttpIntegration do
end
end
+ describe 'before validation' do
+ describe '#ensure_payload_example_not_nil' do
+ subject(:integration) { build(:alert_management_http_integration, payload_example: payload_example) }
+
+ context 'when the payload_example is nil' do
+ let(:payload_example) { nil }
+
+ it 'sets the payload_example to empty JSON' do
+ integration.valid?
+
+ expect(integration.payload_example).to eq({})
+ end
+ end
+
+ context 'when the payload_example is not nil' do
+ let(:payload_example) { { 'key' => 'value' } }
+
+ it 'sets the payload_example to specified value' do
+ integration.valid?
+
+ expect(integration.payload_example).to eq(payload_example)
+ end
+ end
+ end
+ end
+
describe '#token' do
subject { integration.token }
diff --git a/spec/models/analytics/instance_statistics/measurement_spec.rb b/spec/models/analytics/usage_trends/measurement_spec.rb
index dbb16c5ffbe..d9a6b70c87a 100644
--- a/spec/models/analytics/instance_statistics/measurement_spec.rb
+++ b/spec/models/analytics/usage_trends/measurement_spec.rb
@@ -2,9 +2,9 @@
require 'spec_helper'
-RSpec.describe Analytics::InstanceStatistics::Measurement, type: :model do
+RSpec.describe Analytics::UsageTrends::Measurement, type: :model do
describe 'validation' do
- let!(:measurement) { create(:instance_statistics_measurement) }
+ let!(:measurement) { create(:usage_trends_measurement) }
it { is_expected.to validate_presence_of(:recorded_at) }
it { is_expected.to validate_presence_of(:identifier) }
@@ -33,9 +33,9 @@ RSpec.describe Analytics::InstanceStatistics::Measurement, type: :model do
end
describe 'scopes' do
- let_it_be(:measurement_1) { create(:instance_statistics_measurement, :project_count, recorded_at: 10.days.ago) }
- let_it_be(:measurement_2) { create(:instance_statistics_measurement, :project_count, recorded_at: 2.days.ago) }
- let_it_be(:measurement_3) { create(:instance_statistics_measurement, :group_count, recorded_at: 5.days.ago) }
+ let_it_be(:measurement_1) { create(:usage_trends_measurement, :project_count, recorded_at: 10.days.ago) }
+ let_it_be(:measurement_2) { create(:usage_trends_measurement, :project_count, recorded_at: 2.days.ago) }
+ let_it_be(:measurement_3) { create(:usage_trends_measurement, :group_count, recorded_at: 5.days.ago) }
describe '.order_by_latest' do
subject { described_class.order_by_latest }
@@ -101,15 +101,15 @@ RSpec.describe Analytics::InstanceStatistics::Measurement, type: :model do
describe '.find_latest_or_fallback' do
subject(:count) { described_class.find_latest_or_fallback(:pipelines_skipped).count }
- context 'with instance statistics' do
- let!(:measurement) { create(:instance_statistics_measurement, :pipelines_skipped_count) }
+ context 'with usage statistics' do
+ let!(:measurement) { create(:usage_trends_measurement, :pipelines_skipped_count) }
it 'returns the latest stored measurement' do
expect(count).to eq measurement.count
end
end
- context 'without instance statistics' do
+ context 'without usage statistics' do
it 'returns the realtime query of the measurement' do
expect(count).to eq 0
end
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index 5658057f588..808932ce7e4 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -105,14 +105,14 @@ RSpec.describe ApplicationSetting do
it { is_expected.not_to allow_value(false).for(:hashed_storage_enabled) }
- it { is_expected.not_to allow_value(101).for(:repository_storages_weighted_default) }
- it { is_expected.to allow_value('90').for(:repository_storages_weighted_default) }
- it { is_expected.not_to allow_value(-1).for(:repository_storages_weighted_default) }
- it { is_expected.to allow_value(100).for(:repository_storages_weighted_default) }
- it { is_expected.to allow_value(0).for(:repository_storages_weighted_default) }
- it { is_expected.to allow_value(50).for(:repository_storages_weighted_default) }
- it { is_expected.to allow_value(nil).for(:repository_storages_weighted_default) }
- it { is_expected.not_to allow_value({ default: 100, shouldntexist: 50 }).for(:repository_storages_weighted) }
+ it { is_expected.to allow_value('default' => 0).for(:repository_storages_weighted) }
+ it { is_expected.to allow_value('default' => 50).for(:repository_storages_weighted) }
+ it { is_expected.to allow_value('default' => 100).for(:repository_storages_weighted) }
+ it { is_expected.to allow_value('default' => '90').for(:repository_storages_weighted) }
+ it { is_expected.to allow_value('default' => nil).for(:repository_storages_weighted) }
+ it { is_expected.not_to allow_value('default' => -1).for(:repository_storages_weighted).with_message("value for 'default' must be between 0 and 100") }
+ it { is_expected.not_to allow_value('default' => 101).for(:repository_storages_weighted).with_message("value for 'default' must be between 0 and 100") }
+ it { is_expected.not_to allow_value('default' => 100, shouldntexist: 50).for(:repository_storages_weighted).with_message("can't include: shouldntexist") }
it { is_expected.to allow_value(400).for(:notes_create_limit) }
it { is_expected.not_to allow_value('two').for(:notes_create_limit) }
@@ -377,7 +377,7 @@ RSpec.describe ApplicationSetting do
end
end
- it_behaves_like 'an object with email-formated attributes', :abuse_notification_email do
+ it_behaves_like 'an object with email-formatted attributes', :abuse_notification_email do
subject { setting }
end
@@ -984,12 +984,6 @@ RSpec.describe ApplicationSetting do
it_behaves_like 'application settings examples'
- describe 'repository_storages_weighted_attributes' do
- it 'returns the keys for repository_storages_weighted' do
- expect(subject.class.repository_storages_weighted_attributes).to eq([:repository_storages_weighted_default])
- end
- end
-
describe 'kroki_format_supported?' do
it 'returns true when Excalidraw is enabled' do
subject.kroki_formats_excalidraw = true
@@ -1033,11 +1027,4 @@ RSpec.describe ApplicationSetting do
expect(subject.kroki_formats_excalidraw).to eq(true)
end
end
-
- it 'does not allow to set weight for non existing storage' do
- setting.repository_storages_weighted = { invalid_storage: 100 }
-
- expect(setting).not_to be_valid
- expect(setting.errors.messages[:repository_storages_weighted]).to match_array(["can't include: invalid_storage"])
- end
end
diff --git a/spec/models/board_spec.rb b/spec/models/board_spec.rb
index d309b4dbdb9..c8a9504d4fc 100644
--- a/spec/models/board_spec.rb
+++ b/spec/models/board_spec.rb
@@ -12,6 +12,7 @@ RSpec.describe Board do
end
describe 'validations' do
+ it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_presence_of(:project) }
end
diff --git a/spec/models/bulk_imports/entity_spec.rb b/spec/models/bulk_imports/entity_spec.rb
index e5ab96ca514..17ab4d5954c 100644
--- a/spec/models/bulk_imports/entity_spec.rb
+++ b/spec/models/bulk_imports/entity_spec.rb
@@ -14,7 +14,6 @@ RSpec.describe BulkImports::Entity, type: :model do
it { is_expected.to validate_presence_of(:source_type) }
it { is_expected.to validate_presence_of(:source_full_path) }
it { is_expected.to validate_presence_of(:destination_name) }
- it { is_expected.to validate_presence_of(:destination_namespace) }
it { is_expected.to define_enum_for(:source_type).with_values(%i[group_entity project_entity]) }
@@ -38,7 +37,11 @@ RSpec.describe BulkImports::Entity, type: :model do
context 'when associated with a group and no project' do
it 'is valid as a group_entity' do
entity = build(:bulk_import_entity, :group_entity, group: build(:group), project: nil)
+ expect(entity).to be_valid
+ end
+ it 'is valid when destination_namespace is empty' do
+ entity = build(:bulk_import_entity, :group_entity, group: build(:group), project: nil, destination_namespace: '')
expect(entity).to be_valid
end
@@ -57,6 +60,12 @@ RSpec.describe BulkImports::Entity, type: :model do
expect(entity).to be_valid
end
+ it 'is invalid when destination_namespace is nil' do
+ entity = build(:bulk_import_entity, :group_entity, group: build(:group), project: nil, destination_namespace: nil)
+ expect(entity).not_to be_valid
+ expect(entity.errors).to include(:destination_namespace)
+ end
+
it 'is invalid as a project_entity' do
entity = build(:bulk_import_entity, :group_entity, group: nil, project: build(:project))
@@ -94,7 +103,9 @@ RSpec.describe BulkImports::Entity, type: :model do
)
expect(entity).not_to be_valid
- expect(entity.errors).to include(:destination_namespace)
+ expect(entity.errors).to include(:base)
+ expect(entity.errors[:base])
+ .to include('Import failed: Destination cannot be a subgroup of the source group. Change the destination and try again.')
end
it 'is invalid if destination namespace is a descendant of the source' do
@@ -109,7 +120,8 @@ RSpec.describe BulkImports::Entity, type: :model do
)
expect(entity).not_to be_valid
- expect(entity.errors).to include(:destination_namespace)
+ expect(entity.errors[:base])
+ .to include('Import failed: Destination cannot be a subgroup of the source group. Change the destination and try again.')
end
end
end
diff --git a/spec/models/bulk_imports/tracker_spec.rb b/spec/models/bulk_imports/tracker_spec.rb
index 8eb5a6c27dd..77896105959 100644
--- a/spec/models/bulk_imports/tracker_spec.rb
+++ b/spec/models/bulk_imports/tracker_spec.rb
@@ -15,6 +15,8 @@ RSpec.describe BulkImports::Tracker, type: :model do
it { is_expected.to validate_presence_of(:relation) }
it { is_expected.to validate_uniqueness_of(:relation).scoped_to(:bulk_import_entity_id) }
+ it { is_expected.to validate_presence_of(:stage) }
+
context 'when has_next_page is true' do
it "validates presence of `next_page`" do
tracker = build(:bulk_import_tracker, has_next_page: true)
diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb
index b50e4204e0a..f3029598b02 100644
--- a/spec/models/ci/bridge_spec.rb
+++ b/spec/models/ci/bridge_spec.rb
@@ -41,7 +41,7 @@ RSpec.describe Ci::Bridge do
end
end
- describe '#scoped_variables_hash' do
+ describe '#scoped_variables' do
it 'returns a hash representing variables' do
variables = %w[
CI_JOB_NAME CI_JOB_STAGE CI_COMMIT_SHA CI_COMMIT_SHORT_SHA
@@ -53,7 +53,7 @@ RSpec.describe Ci::Bridge do
CI_COMMIT_TIMESTAMP
]
- expect(bridge.scoped_variables_hash.keys).to include(*variables)
+ expect(bridge.scoped_variables.map { |v| v[:key] }).to include(*variables)
end
context 'when bridge has dependency which has dotenv variable' do
@@ -63,7 +63,7 @@ RSpec.describe Ci::Bridge do
let!(:job_variable) { create(:ci_job_variable, :dotenv_source, job: test) }
it 'includes inherited variable' do
- expect(bridge.scoped_variables_hash).to include(job_variable.key => job_variable.value)
+ expect(bridge.scoped_variables.to_hash).to include(job_variable.key => job_variable.value)
end
end
end
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 4ad7ce70a44..5b07bd8923f 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -581,7 +581,7 @@ RSpec.describe Ci::Build do
end
it 'that cannot handle build' do
- expect_any_instance_of(Ci::Runner).to receive(:can_pick?).and_return(false)
+ expect_any_instance_of(Ci::Runner).to receive(:matches_build?).with(build).and_return(false)
is_expected.to be_falsey
end
end
@@ -817,7 +817,48 @@ RSpec.describe Ci::Build do
end
describe '#cache' do
- let(:options) { { cache: { key: "key", paths: ["public"], policy: "pull-push" } } }
+ let(:options) do
+ { cache: [{ key: "key", paths: ["public"], policy: "pull-push" }] }
+ end
+
+ context 'with multiple_cache_per_job FF disabled' do
+ before do
+ stub_feature_flags(multiple_cache_per_job: false)
+ end
+ let(:options) { { cache: { key: "key", paths: ["public"], policy: "pull-push" } } }
+
+ subject { build.cache }
+
+ context 'when build has cache' do
+ before do
+ allow(build).to receive(:options).and_return(options)
+ end
+
+ context 'when project has jobs_cache_index' do
+ before do
+ allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(1)
+ end
+
+ it { is_expected.to be_an(Array).and all(include(key: "key-1")) }
+ end
+
+ context 'when project does not have jobs_cache_index' do
+ before do
+ allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(nil)
+ end
+
+ it { is_expected.to eq([options[:cache]]) }
+ end
+ end
+
+ context 'when build does not have cache' do
+ before do
+ allow(build).to receive(:options).and_return({})
+ end
+
+ it { is_expected.to eq([]) }
+ end
+ end
subject { build.cache }
@@ -826,6 +867,21 @@ RSpec.describe Ci::Build do
allow(build).to receive(:options).and_return(options)
end
+ context 'when build has multiple caches' do
+ let(:options) do
+ { cache: [
+ { key: "key", paths: ["public"], policy: "pull-push" },
+ { key: "key2", paths: ["public"], policy: "pull-push" }
+ ] }
+ end
+
+ before do
+ allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(1)
+ end
+
+ it { is_expected.to match([a_hash_including(key: "key-1"), a_hash_including(key: "key2-1")]) }
+ end
+
context 'when project has jobs_cache_index' do
before do
allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(1)
@@ -839,7 +895,7 @@ RSpec.describe Ci::Build do
allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(nil)
end
- it { is_expected.to eq([options[:cache]]) }
+ it { is_expected.to eq(options[:cache]) }
end
end
@@ -848,7 +904,7 @@ RSpec.describe Ci::Build do
allow(build).to receive(:options).and_return({})
end
- it { is_expected.to eq([nil]) }
+ it { is_expected.to be_empty }
end
end
@@ -1205,6 +1261,21 @@ RSpec.describe Ci::Build do
end
end
+ describe '#environment_deployment_tier' do
+ subject { build.environment_deployment_tier }
+
+ let(:build) { described_class.new(options: options) }
+ let(:options) { { environment: { deployment_tier: 'production' } } }
+
+ it { is_expected.to eq('production') }
+
+ context 'when options does not include deployment_tier' do
+ let(:options) { { environment: { name: 'production' } } }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
describe 'deployment' do
describe '#outdated_deployment?' do
subject { build.outdated_deployment? }
@@ -2367,6 +2438,7 @@ RSpec.describe Ci::Build do
{ key: 'CI_JOB_ID', value: build.id.to_s, public: true, masked: false },
{ key: 'CI_JOB_URL', value: project.web_url + "/-/jobs/#{build.id}", public: true, masked: false },
{ key: 'CI_JOB_TOKEN', value: 'my-token', public: false, masked: true },
+ { key: 'CI_JOB_STARTED_AT', value: build.started_at&.iso8601, public: true, masked: false },
{ key: 'CI_BUILD_ID', value: build.id.to_s, public: true, masked: false },
{ key: 'CI_BUILD_TOKEN', value: 'my-token', public: false, masked: true },
{ key: 'CI_REGISTRY_USER', value: 'gitlab-ci-token', public: true, masked: false },
@@ -2405,17 +2477,18 @@ RSpec.describe Ci::Build do
{ key: 'CI_PROJECT_REPOSITORY_LANGUAGES', value: project.repository_languages.map(&:name).join(',').downcase, public: true, masked: false },
{ key: 'CI_DEFAULT_BRANCH', value: project.default_branch, public: true, masked: false },
{ key: 'CI_PROJECT_CONFIG_PATH', value: project.ci_config_path_or_default, public: true, masked: false },
+ { key: 'CI_CONFIG_PATH', value: project.ci_config_path_or_default, public: true, masked: false },
{ key: 'CI_PAGES_DOMAIN', value: Gitlab.config.pages.host, public: true, masked: false },
{ key: 'CI_PAGES_URL', value: project.pages_url, public: true, masked: false },
- { key: 'CI_DEPENDENCY_PROXY_SERVER', value: "#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}", public: true, masked: false },
+ { key: 'CI_DEPENDENCY_PROXY_SERVER', value: Gitlab.host_with_port, public: true, masked: false },
{ key: 'CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX',
- value: "#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}/#{project.namespace.root_ancestor.path}#{DependencyProxy::URL_SUFFIX}",
+ value: "#{Gitlab.host_with_port}/#{project.namespace.root_ancestor.path.downcase}#{DependencyProxy::URL_SUFFIX}",
public: true,
masked: false },
{ key: 'CI_API_V4_URL', value: 'http://localhost/api/v4', public: true, masked: false },
{ key: 'CI_PIPELINE_IID', value: pipeline.iid.to_s, public: true, masked: false },
{ key: 'CI_PIPELINE_SOURCE', value: pipeline.source, public: true, masked: false },
- { key: 'CI_CONFIG_PATH', value: pipeline.config_path, public: true, masked: false },
+ { key: 'CI_PIPELINE_CREATED_AT', value: pipeline.created_at.iso8601, public: true, masked: false },
{ key: 'CI_COMMIT_SHA', value: build.sha, public: true, masked: false },
{ key: 'CI_COMMIT_SHORT_SHA', value: build.short_sha, public: true, masked: false },
{ key: 'CI_COMMIT_BEFORE_SHA', value: build.before_sha, public: true, masked: false },
@@ -2440,7 +2513,8 @@ RSpec.describe Ci::Build do
build.yaml_variables = []
end
- it { is_expected.to eq(predefined_variables) }
+ it { is_expected.to be_instance_of(Gitlab::Ci::Variables::Collection) }
+ it { expect(subject.to_runner_variables).to eq(predefined_variables) }
context 'when ci_job_jwt feature flag is disabled' do
before do
@@ -2495,7 +2569,7 @@ RSpec.describe Ci::Build do
end
it 'returns variables in order depending on resource hierarchy' do
- is_expected.to eq(
+ expect(subject.to_runner_variables).to eq(
[dependency_proxy_var,
job_jwt_var,
build_pre_var,
@@ -2525,7 +2599,7 @@ RSpec.describe Ci::Build do
end
it 'matches explicit variables ordering' do
- received_variables = subject.map { |variable| variable.fetch(:key) }
+ received_variables = subject.map { |variable| variable[:key] }
expect(received_variables).to eq expected_variables
end
@@ -2584,14 +2658,14 @@ RSpec.describe Ci::Build do
end
shared_examples 'containing environment variables' do
- it { environment_variables.each { |v| is_expected.to include(v) } }
+ it { is_expected.to include(*environment_variables) }
end
context 'when no URL was set' do
it_behaves_like 'containing environment variables'
it 'does not have CI_ENVIRONMENT_URL' do
- keys = subject.map { |var| var[:key] }
+ keys = subject.pluck(:key)
expect(keys).not_to include('CI_ENVIRONMENT_URL')
end
@@ -2618,7 +2692,7 @@ RSpec.describe Ci::Build do
it_behaves_like 'containing environment variables'
it 'puts $CI_ENVIRONMENT_URL in the last so all other variables are available to be used when runners are trying to expand it' do
- expect(subject.last).to eq(environment_variables.last)
+ expect(subject.to_runner_variables.last).to eq(environment_variables.last)
end
end
end
@@ -2951,7 +3025,7 @@ RSpec.describe Ci::Build do
end
it 'overrides YAML variable using a pipeline variable' do
- variables = subject.reverse.uniq { |variable| variable[:key] }.reverse
+ variables = subject.to_runner_variables.reverse.uniq { |variable| variable[:key] }.reverse
expect(variables)
.not_to include(key: 'MYVAR', value: 'myvar', public: true, masked: false)
@@ -3248,47 +3322,6 @@ RSpec.describe Ci::Build do
end
end
- describe '#scoped_variables_hash' do
- context 'when overriding CI variables' do
- before do
- project.variables.create!(key: 'MY_VAR', value: 'my value 1')
- pipeline.variables.create!(key: 'MY_VAR', value: 'my value 2')
- end
-
- it 'returns a regular hash created using valid ordering' do
- expect(build.scoped_variables_hash).to include('MY_VAR': 'my value 2')
- expect(build.scoped_variables_hash).not_to include('MY_VAR': 'my value 1')
- end
- end
-
- context 'when overriding user-provided variables' do
- let(:build) do
- create(:ci_build, pipeline: pipeline, yaml_variables: [{ key: 'MY_VAR', value: 'myvar', public: true }])
- end
-
- before do
- pipeline.variables.build(key: 'MY_VAR', value: 'pipeline value')
- end
-
- it 'returns a hash including variable with higher precedence' do
- expect(build.scoped_variables_hash).to include('MY_VAR': 'pipeline value')
- expect(build.scoped_variables_hash).not_to include('MY_VAR': 'myvar')
- end
- end
-
- context 'when overriding CI instance variables' do
- before do
- create(:ci_instance_variable, key: 'MY_VAR', value: 'my value 1')
- group.variables.create!(key: 'MY_VAR', value: 'my value 2')
- end
-
- it 'returns a regular hash created using valid ordering' do
- expect(build.scoped_variables_hash).to include('MY_VAR': 'my value 2')
- expect(build.scoped_variables_hash).not_to include('MY_VAR': 'my value 1')
- end
- end
- end
-
describe '#any_unmet_prerequisites?' do
let(:build) { create(:ci_build, :created) }
diff --git a/spec/models/ci/daily_build_group_report_result_spec.rb b/spec/models/ci/daily_build_group_report_result_spec.rb
index f6e6a6a5e02..4e96ec7cecb 100644
--- a/spec/models/ci/daily_build_group_report_result_spec.rb
+++ b/spec/models/ci/daily_build_group_report_result_spec.rb
@@ -162,39 +162,5 @@ RSpec.describe Ci::DailyBuildGroupReportResult do
end
end
end
-
- describe '.by_date' do
- subject(:coverages) { described_class.by_date(start_date) }
-
- let!(:coverage_1) { create(:ci_daily_build_group_report_result, date: 1.week.ago) }
-
- context 'when project has several coverage' do
- let!(:coverage_2) { create(:ci_daily_build_group_report_result, date: 2.weeks.ago) }
- let(:start_date) { 1.week.ago.to_date.to_s }
-
- it 'returns the coverage from the start_date' do
- expect(coverages).to contain_exactly(coverage_1)
- end
- end
-
- context 'when start_date is over 90 days' do
- let!(:coverage_2) { create(:ci_daily_build_group_report_result, date: 90.days.ago) }
- let!(:coverage_3) { create(:ci_daily_build_group_report_result, date: 91.days.ago) }
- let(:start_date) { 1.year.ago.to_date.to_s }
-
- it 'returns the coverage in the last 90 days' do
- expect(coverages).to contain_exactly(coverage_1, coverage_2)
- end
- end
-
- context 'when start_date is not a string' do
- let!(:coverage_2) { create(:ci_daily_build_group_report_result, date: 90.days.ago) }
- let(:start_date) { 1.week.ago }
-
- it 'returns the coverage in the last 90 days' do
- expect(coverages).to contain_exactly(coverage_1, coverage_2)
- end
- end
- end
end
end
diff --git a/spec/models/ci/group_variable_spec.rb b/spec/models/ci/group_variable_spec.rb
index c8eac4d8765..f0eec549da7 100644
--- a/spec/models/ci/group_variable_spec.rb
+++ b/spec/models/ci/group_variable_spec.rb
@@ -9,7 +9,17 @@ RSpec.describe Ci::GroupVariable do
it { is_expected.to include_module(Presentable) }
it { is_expected.to include_module(Ci::Maskable) }
- it { is_expected.to validate_uniqueness_of(:key).scoped_to(:group_id).with_message(/\(\w+\) has already been taken/) }
+ it { is_expected.to include_module(HasEnvironmentScope) }
+ it { is_expected.to validate_uniqueness_of(:key).scoped_to([:group_id, :environment_scope]).with_message(/\(\w+\) has already been taken/) }
+
+ describe '.by_environment_scope' do
+ let!(:matching_variable) { create(:ci_group_variable, environment_scope: 'production ') }
+ let!(:non_matching_variable) { create(:ci_group_variable, environment_scope: 'staging') }
+
+ subject { Ci::GroupVariable.by_environment_scope('production') }
+
+ it { is_expected.to contain_exactly(matching_variable) }
+ end
describe '.unprotected' do
subject { described_class.unprotected }
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 94943fb3644..d57a39d133f 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -8,12 +8,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
include Ci::SourcePipelineHelpers
let_it_be(:user) { create(:user) }
- let_it_be(:namespace) { create_default(:namespace) }
- let_it_be(:project) { create_default(:project, :repository) }
-
- let(:pipeline) do
- create(:ci_empty_pipeline, status: :created, project: project)
- end
+ let_it_be(:namespace) { create_default(:namespace).freeze }
+ let_it_be(:project) { create_default(:project, :repository).freeze }
it_behaves_like 'having unique enum values'
@@ -53,6 +49,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe 'associations' do
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) }
+
it 'has a bidirectional relationship with projects' do
expect(described_class.reflect_on_association(:project).has_inverse?).to eq(:all_pipelines)
expect(Project.reflect_on_association(:all_pipelines).has_inverse?).to eq(:project)
@@ -82,6 +80,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#set_status' do
+ let(:pipeline) { build(:ci_empty_pipeline, :created) }
+
where(:from_status, :to_status) do
from_status_names = described_class.state_machines[:status].states.map(&:name)
to_status_names = from_status_names - [:created] # we never want to transition into created
@@ -105,6 +105,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '.processables' do
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) }
+
before do
create(:ci_build, name: 'build', pipeline: pipeline)
create(:ci_bridge, name: 'bridge', pipeline: pipeline)
@@ -142,7 +144,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
subject { described_class.for_sha(sha) }
let(:sha) { 'abc' }
- let!(:pipeline) { create(:ci_pipeline, sha: 'abc') }
+
+ let_it_be(:pipeline) { create(:ci_pipeline, sha: 'abc') }
it 'returns the pipeline' do
is_expected.to contain_exactly(pipeline)
@@ -170,7 +173,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
subject { described_class.for_source_sha(source_sha) }
let(:source_sha) { 'abc' }
- let!(:pipeline) { create(:ci_pipeline, source_sha: 'abc') }
+
+ let_it_be(:pipeline) { create(:ci_pipeline, source_sha: 'abc') }
it 'returns the pipeline' do
is_expected.to contain_exactly(pipeline)
@@ -228,7 +232,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
subject { described_class.for_branch(branch) }
let(:branch) { 'master' }
- let!(:pipeline) { create(:ci_pipeline, ref: 'master') }
+
+ let_it_be(:pipeline) { create(:ci_pipeline, ref: 'master') }
it 'returns the pipeline' do
is_expected.to contain_exactly(pipeline)
@@ -247,13 +252,16 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '.ci_sources' do
subject { described_class.ci_sources }
- let!(:push_pipeline) { create(:ci_pipeline, source: :push) }
- let!(:web_pipeline) { create(:ci_pipeline, source: :web) }
- let!(:api_pipeline) { create(:ci_pipeline, source: :api) }
- let!(:webide_pipeline) { create(:ci_pipeline, source: :webide) }
- let!(:child_pipeline) { create(:ci_pipeline, source: :parent_pipeline) }
+ let(:push_pipeline) { build(:ci_pipeline, source: :push) }
+ let(:web_pipeline) { build(:ci_pipeline, source: :web) }
+ let(:api_pipeline) { build(:ci_pipeline, source: :api) }
+ let(:webide_pipeline) { build(:ci_pipeline, source: :webide) }
+ let(:child_pipeline) { build(:ci_pipeline, source: :parent_pipeline) }
+ let(:pipelines) { [push_pipeline, web_pipeline, api_pipeline, webide_pipeline, child_pipeline] }
it 'contains pipelines having CI only sources' do
+ pipelines.map(&:save!)
+
expect(subject).to contain_exactly(push_pipeline, web_pipeline, api_pipeline)
end
@@ -365,8 +373,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
end
- describe '#merge_request_pipeline?' do
- subject { pipeline.merge_request_pipeline? }
+ describe '#merged_result_pipeline?' do
+ subject { pipeline.merged_result_pipeline? }
let!(:pipeline) do
create(:ci_pipeline, source: :merge_request_event, merge_request: merge_request, target_sha: target_sha)
@@ -387,6 +395,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#merge_request_ref?' do
subject { pipeline.merge_request_ref? }
+ let(:pipeline) { build(:ci_empty_pipeline, :created) }
+
it 'calls MergeRequest#merge_request_ref?' do
expect(MergeRequest).to receive(:merge_request_ref?).with(pipeline.ref)
@@ -606,7 +616,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#source' do
context 'when creating new pipeline' do
let(:pipeline) do
- build(:ci_empty_pipeline, status: :created, project: project, source: nil)
+ build(:ci_empty_pipeline, :created, project: project, source: nil)
end
it "prevents from creating an object" do
@@ -615,17 +625,21 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when updating existing pipeline' do
+ let(:pipeline) { create(:ci_empty_pipeline, :created) }
+
before do
pipeline.update_attribute(:source, nil)
end
- it "object is valid" do
+ it 'object is valid' do
expect(pipeline).to be_valid
end
end
end
describe '#block' do
+ let(:pipeline) { create(:ci_empty_pipeline, :created) }
+
it 'changes pipeline status to manual' do
expect(pipeline.block).to be true
expect(pipeline.reload).to be_manual
@@ -636,7 +650,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#delay' do
subject { pipeline.delay }
- let(:pipeline) { build(:ci_pipeline, status: :created) }
+ let(:pipeline) { build(:ci_pipeline, :created) }
it 'changes pipeline status to schedule' do
subject
@@ -646,6 +660,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#valid_commit_sha' do
+ let(:pipeline) { build_stubbed(:ci_empty_pipeline, :created, project: project) }
+
context 'commit.sha can not start with 00000000' do
before do
pipeline.sha = '0' * 40
@@ -659,6 +675,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#short_sha' do
subject { pipeline.short_sha }
+ let(:pipeline) { build_stubbed(:ci_empty_pipeline, :created) }
+
it 'has 8 items' do
expect(subject.size).to eq(8)
end
@@ -668,49 +686,58 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#retried' do
subject { pipeline.retried }
+ let(:pipeline) { create(:ci_empty_pipeline, :created, project: project) }
+ let!(:build1) { create(:ci_build, pipeline: pipeline, name: 'deploy', retried: true) }
+
before do
- @build1 = create(:ci_build, pipeline: pipeline, name: 'deploy', retried: true)
- @build2 = create(:ci_build, pipeline: pipeline, name: 'deploy')
+ create(:ci_build, pipeline: pipeline, name: 'deploy')
end
it 'returns old builds' do
- is_expected.to contain_exactly(@build1)
+ is_expected.to contain_exactly(build1)
end
end
describe '#coverage' do
- let(:project) { create(:project, build_coverage_regex: "/.*/") }
- let(:pipeline) { create(:ci_empty_pipeline, project: project) }
+ let_it_be_with_reload(:pipeline) { create(:ci_empty_pipeline) }
- it "calculates average when there are two builds with coverage" do
- create(:ci_build, name: "rspec", coverage: 30, pipeline: pipeline)
- create(:ci_build, name: "rubocop", coverage: 40, pipeline: pipeline)
- expect(pipeline.coverage).to eq("35.00")
- end
+ context 'with multiple pipelines' do
+ before_all do
+ create(:ci_build, name: "rspec", coverage: 30, pipeline: pipeline)
+ create(:ci_build, name: "rubocop", coverage: 40, pipeline: pipeline)
+ end
- it "calculates average when there are two builds with coverage and one with nil" do
- create(:ci_build, name: "rspec", coverage: 30, pipeline: pipeline)
- create(:ci_build, name: "rubocop", coverage: 40, pipeline: pipeline)
- create(:ci_build, pipeline: pipeline)
- expect(pipeline.coverage).to eq("35.00")
- end
+ it "calculates average when there are two builds with coverage" do
+ expect(pipeline.coverage).to eq("35.00")
+ end
+
+ it "calculates average when there are two builds with coverage and one with nil" do
+ create(:ci_build, pipeline: pipeline)
+
+ expect(pipeline.coverage).to eq("35.00")
+ end
- it "calculates average when there are two builds with coverage and one is retried" do
- create(:ci_build, name: "rspec", coverage: 30, pipeline: pipeline)
- create(:ci_build, name: "rubocop", coverage: 30, pipeline: pipeline, retried: true)
- create(:ci_build, name: "rubocop", coverage: 40, pipeline: pipeline)
- expect(pipeline.coverage).to eq("35.00")
+ it "calculates average when there are two builds with coverage and one is retried" do
+ create(:ci_build, name: "rubocop", coverage: 30, pipeline: pipeline, retried: true)
+
+ expect(pipeline.coverage).to eq("35.00")
+ end
end
- it "calculates average when there is one build without coverage" do
- FactoryBot.create(:ci_build, pipeline: pipeline)
- expect(pipeline.coverage).to be_nil
+ context 'when there is one build without coverage' do
+ it "calculates average to nil" do
+ create(:ci_build, pipeline: pipeline)
+
+ expect(pipeline.coverage).to be_nil
+ end
end
end
describe '#retryable?' do
subject { pipeline.retryable? }
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, :created, project: project) }
+
context 'no failed builds' do
before do
create_build('rspec', 'success')
@@ -772,13 +799,15 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#predefined_variables' do
subject { pipeline.predefined_variables }
+ let(:pipeline) { build(:ci_empty_pipeline, :created) }
+
it 'includes all predefined variables in a valid order' do
keys = subject.map { |variable| variable[:key] }
expect(keys).to eq %w[
CI_PIPELINE_IID
CI_PIPELINE_SOURCE
- CI_CONFIG_PATH
+ CI_PIPELINE_CREATED_AT
CI_COMMIT_SHA
CI_COMMIT_SHORT_SHA
CI_COMMIT_BEFORE_SHA
@@ -798,21 +827,18 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when merge request is present' do
+ let_it_be(:assignees) { create_list(:user, 2) }
+ let_it_be(:milestone) { create(:milestone, project: project) }
+ let_it_be(:labels) { create_list(:label, 2) }
let(:merge_request) do
- create(:merge_request,
+ create(:merge_request, :simple,
source_project: project,
- source_branch: 'feature',
target_project: project,
- target_branch: 'master',
assignees: assignees,
milestone: milestone,
labels: labels)
end
- let(:assignees) { create_list(:user, 2) }
- let(:milestone) { create(:milestone, project: project) }
- let(:labels) { create_list(:label, 2) }
-
context 'when pipeline for merge request is created' do
let(:pipeline) do
create(:ci_pipeline, :detached_merge_request_pipeline,
@@ -998,9 +1024,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#protected_ref?' do
- before do
- pipeline.project = create(:project, :repository)
- end
+ let(:pipeline) { build(:ci_empty_pipeline, :created) }
it 'delegates method to project' do
expect(pipeline).not_to be_protected_ref
@@ -1008,11 +1032,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#legacy_trigger' do
- let(:trigger_request) { create(:ci_trigger_request) }
-
- before do
- pipeline.trigger_requests << trigger_request
- end
+ let(:trigger_request) { build(:ci_trigger_request) }
+ let(:pipeline) { build(:ci_empty_pipeline, :created, trigger_requests: [trigger_request]) }
it 'returns first trigger request' do
expect(pipeline.legacy_trigger).to eq trigger_request
@@ -1022,6 +1043,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#auto_canceled?' do
subject { pipeline.auto_canceled? }
+ let(:pipeline) { build(:ci_empty_pipeline, :created) }
+
context 'when it is canceled' do
before do
pipeline.cancel
@@ -1029,7 +1052,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
context 'when there is auto_canceled_by' do
before do
- pipeline.update!(auto_canceled_by: create(:ci_empty_pipeline))
+ pipeline.auto_canceled_by = create(:ci_empty_pipeline)
end
it 'is auto canceled' do
@@ -1057,6 +1080,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe 'pipeline stages' do
+ let(:pipeline) { build(:ci_empty_pipeline, :created) }
+
describe 'legacy stages' do
before do
create(:commit_status, pipeline: pipeline,
@@ -1107,22 +1132,28 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when commit status is retried' do
- before do
+ let!(:old_commit_status) do
create(:commit_status, pipeline: pipeline,
- stage: 'build',
- name: 'mac',
- stage_idx: 0,
- status: 'success')
-
- Ci::ProcessPipelineService
- .new(pipeline)
- .execute
+ stage: 'build',
+ name: 'mac',
+ stage_idx: 0,
+ status: 'success')
end
- it 'ignores the previous state' do
- expect(statuses).to eq([%w(build success),
- %w(test success),
- %w(deploy running)])
+ context 'when FF ci_remove_update_retried_from_process_pipeline is disabled' do
+ before do
+ stub_feature_flags(ci_remove_update_retried_from_process_pipeline: false)
+
+ Ci::ProcessPipelineService
+ .new(pipeline)
+ .execute
+ end
+
+ it 'ignores the previous state' do
+ expect(statuses).to eq([%w(build success),
+ %w(test success),
+ %w(deploy running)])
+ end
end
end
end
@@ -1162,6 +1193,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#legacy_stage' do
subject { pipeline.legacy_stage('test') }
+ let(:pipeline) { build(:ci_empty_pipeline, :created) }
+
context 'with status in stage' do
before do
create(:commit_status, pipeline: pipeline, stage: 'test')
@@ -1184,6 +1217,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#stages' do
+ let(:pipeline) { build(:ci_empty_pipeline, :created) }
+
before do
create(:ci_stage_entity, project: project,
pipeline: pipeline,
@@ -1238,6 +1273,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe 'state machine' do
+ let_it_be_with_reload(:pipeline) { create(:ci_empty_pipeline, :created) }
let(:current) { Time.current.change(usec: 0) }
let(:build) { create_build('build1', queued_at: 0) }
let(:build_b) { create_build('build2', queued_at: 0) }
@@ -1401,28 +1437,10 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe 'pipeline caching' do
- context 'if pipeline is cacheable' do
- before do
- pipeline.source = 'push'
- end
-
- it 'performs ExpirePipelinesCacheWorker' do
- expect(ExpirePipelineCacheWorker).to receive(:perform_async).with(pipeline.id)
+ it 'performs ExpirePipelinesCacheWorker' do
+ expect(ExpirePipelineCacheWorker).to receive(:perform_async).with(pipeline.id)
- pipeline.cancel
- end
- end
-
- context 'if pipeline is not cacheable' do
- before do
- pipeline.source = 'webide'
- end
-
- it 'deos not perform ExpirePipelinesCacheWorker' do
- expect(ExpirePipelineCacheWorker).not_to receive(:perform_async)
-
- pipeline.cancel
- end
+ pipeline.cancel
end
end
@@ -1441,24 +1459,25 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe 'auto merge' do
- let(:merge_request) { create(:merge_request, :merge_when_pipeline_succeeds) }
-
- let(:pipeline) do
- create(:ci_pipeline, :running, project: merge_request.source_project,
- ref: merge_request.source_branch,
- sha: merge_request.diff_head_sha)
- end
+ context 'when auto merge is enabled' do
+ let_it_be_with_reload(:merge_request) { create(:merge_request, :merge_when_pipeline_succeeds) }
+ let_it_be_with_reload(:pipeline) do
+ create(:ci_pipeline, :running, project: merge_request.source_project,
+ ref: merge_request.source_branch,
+ sha: merge_request.diff_head_sha)
+ end
- before do
- merge_request.update_head_pipeline
- end
+ before_all do
+ merge_request.update_head_pipeline
+ end
- %w[succeed! drop! cancel! skip!].each do |action|
- context "when the pipeline recieved #{action} event" do
- it 'performs AutoMergeProcessWorker' do
- expect(AutoMergeProcessWorker).to receive(:perform_async).with(merge_request.id)
+ %w[succeed! drop! cancel! skip!].each do |action|
+ context "when the pipeline recieved #{action} event" do
+ it 'performs AutoMergeProcessWorker' do
+ expect(AutoMergeProcessWorker).to receive(:perform_async).with(merge_request.id)
- pipeline.public_send(action)
+ pipeline.public_send(action)
+ end
end
end
end
@@ -1610,15 +1629,15 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
context 'multi-project pipelines' do
let!(:downstream_project) { create(:project, :repository) }
- let!(:upstream_pipeline) { create(:ci_pipeline, project: project) }
+ let!(:upstream_pipeline) { create(:ci_pipeline) }
let!(:downstream_pipeline) { create(:ci_pipeline, :with_job, project: downstream_project) }
it_behaves_like 'upstream downstream pipeline'
end
context 'parent-child pipelines' do
- let!(:upstream_pipeline) { create(:ci_pipeline, project: project) }
- let!(:downstream_pipeline) { create(:ci_pipeline, :with_job, project: project) }
+ let!(:upstream_pipeline) { create(:ci_pipeline) }
+ let!(:downstream_pipeline) { create(:ci_pipeline, :with_job) }
it_behaves_like 'upstream downstream pipeline'
end
@@ -1637,6 +1656,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#branch?' do
subject { pipeline.branch? }
+ let(:pipeline) { build(:ci_empty_pipeline, :created) }
+
context 'when ref is not a tag' do
before do
pipeline.tag = false
@@ -1647,16 +1668,12 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when pipeline is merge request' do
- let(:pipeline) do
- create(:ci_pipeline, merge_request: merge_request)
- end
+ let(:pipeline) { build(:ci_pipeline, merge_request: merge_request) }
let(:merge_request) do
- create(:merge_request,
+ create(:merge_request, :simple,
source_project: project,
- source_branch: 'feature',
- target_project: project,
- target_branch: 'master')
+ target_project: project)
end
it 'returns false' do
@@ -1720,6 +1737,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
context 'when repository exists' do
using RSpec::Parameterized::TableSyntax
+ let_it_be(:pipeline, refind: true) { create(:ci_empty_pipeline) }
+
where(:tag, :ref, :result) do
false | 'master' | true
false | 'non-existent-branch' | false
@@ -1728,8 +1747,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
with_them do
- let(:pipeline) do
- create(:ci_empty_pipeline, project: project, tag: tag, ref: ref)
+ before do
+ pipeline.update!(tag: tag, ref: ref)
end
it "correctly detects ref" do
@@ -1739,10 +1758,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when repository does not exist' do
- let(:project) { create(:project) }
- let(:pipeline) do
- create(:ci_empty_pipeline, project: project, ref: 'master')
- end
+ let(:pipeline) { build(:ci_empty_pipeline, ref: 'master', project: build(:project)) }
it 'always returns false' do
expect(pipeline.ref_exists?).to eq false
@@ -1753,7 +1769,6 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
context 'with non-empty project' do
let(:pipeline) do
create(:ci_pipeline,
- project: project,
ref: project.default_branch,
sha: project.commit.sha)
end
@@ -1761,14 +1776,12 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#lazy_ref_commit' do
let(:another) do
create(:ci_pipeline,
- project: project,
ref: 'feature',
sha: project.commit('feature').sha)
end
let(:unicode) do
create(:ci_pipeline,
- project: project,
ref: 'ü/unicode/multi-byte')
end
@@ -1827,6 +1840,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#manual_actions' do
subject { pipeline.manual_actions }
+ let(:pipeline) { create(:ci_empty_pipeline, :created) }
+
it 'when none defined' do
is_expected.to be_empty
end
@@ -1853,9 +1868,11 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#branch_updated?' do
+ let(:pipeline) { create(:ci_empty_pipeline, :created) }
+
context 'when pipeline has before SHA' do
before do
- pipeline.update_column(:before_sha, 'a1b2c3d4')
+ pipeline.update!(before_sha: 'a1b2c3d4')
end
it 'runs on a branch update push' do
@@ -1866,7 +1883,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
context 'when pipeline does not have before SHA' do
before do
- pipeline.update_column(:before_sha, Gitlab::Git::BLANK_SHA)
+ pipeline.update!(before_sha: Gitlab::Git::BLANK_SHA)
end
it 'does not run on a branch updating push' do
@@ -1876,6 +1893,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#modified_paths' do
+ let(:pipeline) { create(:ci_empty_pipeline, :created) }
+
context 'when old and new revisions are set' do
before do
pipeline.update!(before_sha: '1234abcd', sha: '2345bcde')
@@ -1892,7 +1911,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
context 'when either old or new revision is missing' do
before do
- pipeline.update_column(:before_sha, Gitlab::Git::BLANK_SHA)
+ pipeline.update!(before_sha: Gitlab::Git::BLANK_SHA)
end
it 'returns nil' do
@@ -1906,11 +1925,9 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
let(:merge_request) do
- create(:merge_request,
+ create(:merge_request, :simple,
source_project: project,
- source_branch: 'feature',
- target_project: project,
- target_branch: 'master')
+ target_project: project)
end
it 'returns merge request modified paths' do
@@ -1944,6 +1961,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#has_kubernetes_active?' do
+ let(:pipeline) { create(:ci_empty_pipeline, :created, project: project) }
+
context 'when kubernetes is active' do
context 'when user configured kubernetes from CI/CD > Clusters' do
let!(:cluster) { create(:cluster, :project, :provided_by_gcp) }
@@ -1965,6 +1984,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#has_warnings?' do
subject { pipeline.has_warnings? }
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) }
+
context 'build which is allowed to fail fails' do
before do
create :ci_build, :success, pipeline: pipeline, name: 'rspec'
@@ -2021,6 +2042,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#number_of_warnings' do
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) }
+
it 'returns the number of warnings' do
create(:ci_build, :allowed_to_fail, :failed, pipeline: pipeline, name: 'rubocop')
create(:ci_bridge, :allowed_to_fail, :failed, pipeline: pipeline, name: 'rubocop')
@@ -2029,7 +2052,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
it 'supports eager loading of the number of warnings' do
- pipeline2 = create(:ci_empty_pipeline, status: :created, project: project)
+ pipeline2 = create(:ci_empty_pipeline, :created)
create(:ci_build, :allowed_to_fail, :failed, pipeline: pipeline, name: 'rubocop')
create(:ci_build, :allowed_to_fail, :failed, pipeline: pipeline2, name: 'rubocop')
@@ -2053,6 +2076,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
subject { pipeline.needs_processing? }
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) }
+
where(:processed, :result) do
nil | true
false | true
@@ -2072,122 +2097,107 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
end
- shared_context 'with some outdated pipelines' do
- before do
- create_pipeline(:canceled, 'ref', 'A', project)
- create_pipeline(:success, 'ref', 'A', project)
- create_pipeline(:failed, 'ref', 'B', project)
- create_pipeline(:skipped, 'feature', 'C', project)
+ context 'with outdated pipelines' do
+ before_all do
+ create_pipeline(:canceled, 'ref', 'A')
+ create_pipeline(:success, 'ref', 'A')
+ create_pipeline(:failed, 'ref', 'B')
+ create_pipeline(:skipped, 'feature', 'C')
end
- def create_pipeline(status, ref, sha, project)
+ def create_pipeline(status, ref, sha)
create(
:ci_empty_pipeline,
status: status,
ref: ref,
- sha: sha,
- project: project
+ sha: sha
)
end
- end
- describe '.newest_first' do
- include_context 'with some outdated pipelines'
-
- it 'returns the pipelines from new to old' do
- expect(described_class.newest_first.pluck(:status))
- .to eq(%w[skipped failed success canceled])
- end
+ describe '.newest_first' do
+ it 'returns the pipelines from new to old' do
+ expect(described_class.newest_first.pluck(:status))
+ .to eq(%w[skipped failed success canceled])
+ end
- it 'searches limited backlog' do
- expect(described_class.newest_first(limit: 1).pluck(:status))
- .to eq(%w[skipped])
+ it 'searches limited backlog' do
+ expect(described_class.newest_first(limit: 1).pluck(:status))
+ .to eq(%w[skipped])
+ end
end
- end
- describe '.latest_status' do
- include_context 'with some outdated pipelines'
-
- context 'when no ref is specified' do
- it 'returns the status of the latest pipeline' do
- expect(described_class.latest_status).to eq('skipped')
+ describe '.latest_status' do
+ context 'when no ref is specified' do
+ it 'returns the status of the latest pipeline' do
+ expect(described_class.latest_status).to eq('skipped')
+ end
end
- end
- context 'when ref is specified' do
- it 'returns the status of the latest pipeline for the given ref' do
- expect(described_class.latest_status('ref')).to eq('failed')
+ context 'when ref is specified' do
+ it 'returns the status of the latest pipeline for the given ref' do
+ expect(described_class.latest_status('ref')).to eq('failed')
+ end
end
end
- end
- describe '.latest_successful_for_ref' do
- include_context 'with some outdated pipelines'
-
- let!(:latest_successful_pipeline) do
- create_pipeline(:success, 'ref', 'D', project)
- end
+ describe '.latest_successful_for_ref' do
+ let!(:latest_successful_pipeline) do
+ create_pipeline(:success, 'ref', 'D')
+ end
- it 'returns the latest successful pipeline' do
- expect(described_class.latest_successful_for_ref('ref'))
- .to eq(latest_successful_pipeline)
+ it 'returns the latest successful pipeline' do
+ expect(described_class.latest_successful_for_ref('ref'))
+ .to eq(latest_successful_pipeline)
+ end
end
- end
- describe '.latest_running_for_ref' do
- include_context 'with some outdated pipelines'
-
- let!(:latest_running_pipeline) do
- create_pipeline(:running, 'ref', 'D', project)
- end
+ describe '.latest_running_for_ref' do
+ let!(:latest_running_pipeline) do
+ create_pipeline(:running, 'ref', 'D')
+ end
- it 'returns the latest running pipeline' do
- expect(described_class.latest_running_for_ref('ref'))
- .to eq(latest_running_pipeline)
+ it 'returns the latest running pipeline' do
+ expect(described_class.latest_running_for_ref('ref'))
+ .to eq(latest_running_pipeline)
+ end
end
- end
-
- describe '.latest_failed_for_ref' do
- include_context 'with some outdated pipelines'
- let!(:latest_failed_pipeline) do
- create_pipeline(:failed, 'ref', 'D', project)
- end
+ describe '.latest_failed_for_ref' do
+ let!(:latest_failed_pipeline) do
+ create_pipeline(:failed, 'ref', 'D')
+ end
- it 'returns the latest failed pipeline' do
- expect(described_class.latest_failed_for_ref('ref'))
- .to eq(latest_failed_pipeline)
+ it 'returns the latest failed pipeline' do
+ expect(described_class.latest_failed_for_ref('ref'))
+ .to eq(latest_failed_pipeline)
+ end
end
- end
-
- describe '.latest_successful_for_sha' do
- include_context 'with some outdated pipelines'
- let!(:latest_successful_pipeline) do
- create_pipeline(:success, 'ref', 'awesomesha', project)
- end
+ describe '.latest_successful_for_sha' do
+ let!(:latest_successful_pipeline) do
+ create_pipeline(:success, 'ref', 'awesomesha')
+ end
- it 'returns the latest successful pipeline' do
- expect(described_class.latest_successful_for_sha('awesomesha'))
- .to eq(latest_successful_pipeline)
+ it 'returns the latest successful pipeline' do
+ expect(described_class.latest_successful_for_sha('awesomesha'))
+ .to eq(latest_successful_pipeline)
+ end
end
- end
-
- describe '.latest_successful_for_refs' do
- include_context 'with some outdated pipelines'
- let!(:latest_successful_pipeline1) do
- create_pipeline(:success, 'ref1', 'D', project)
- end
+ describe '.latest_successful_for_refs' do
+ let!(:latest_successful_pipeline1) do
+ create_pipeline(:success, 'ref1', 'D')
+ end
- let!(:latest_successful_pipeline2) do
- create_pipeline(:success, 'ref2', 'D', project)
- end
+ let!(:latest_successful_pipeline2) do
+ create_pipeline(:success, 'ref2', 'D')
+ end
- it 'returns the latest successful pipeline for both refs' do
- refs = %w(ref1 ref2 ref3)
+ it 'returns the latest successful pipeline for both refs' do
+ refs = %w(ref1 ref2 ref3)
- expect(described_class.latest_successful_for_refs(refs)).to eq({ 'ref1' => latest_successful_pipeline1, 'ref2' => latest_successful_pipeline2 })
+ expect(described_class.latest_successful_for_refs(refs)).to eq({ 'ref1' => latest_successful_pipeline1, 'ref2' => latest_successful_pipeline2 })
+ end
end
end
@@ -2197,8 +2207,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
:ci_empty_pipeline,
status: 'success',
ref: 'master',
- sha: '123',
- project: project
+ sha: '123'
)
end
@@ -2207,8 +2216,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
:ci_empty_pipeline,
status: 'success',
ref: 'develop',
- sha: '123',
- project: project
+ sha: '123'
)
end
@@ -2217,8 +2225,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
:ci_empty_pipeline,
status: 'success',
ref: 'test',
- sha: '456',
- project: project
+ sha: '456'
)
end
@@ -2315,12 +2322,11 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#status', :sidekiq_inline do
- let(:build) do
- create(:ci_build, :created, pipeline: pipeline, name: 'test')
- end
-
subject { pipeline.reload.status }
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) }
+ let(:build) { create(:ci_build, :created, pipeline: pipeline, name: 'test') }
+
context 'on waiting for resource' do
before do
allow(build).to receive(:with_resource_group?) { true }
@@ -2412,8 +2418,10 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#detailed_status' do
subject { pipeline.detailed_status(user) }
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) }
+
context 'when pipeline is created' do
- let(:pipeline) { create(:ci_pipeline, status: :created) }
+ let(:pipeline) { create(:ci_pipeline, :created) }
it 'returns detailed status for created pipeline' do
expect(subject.text).to eq s_('CiStatusText|created')
@@ -2490,6 +2498,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#cancelable?' do
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) }
+
%i[created running pending].each do |status0|
context "when there is a build #{status0}" do
before do
@@ -2581,7 +2591,9 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#cancel_running' do
- let(:latest_status) { pipeline.statuses.pluck(:status) }
+ subject(:latest_status) { pipeline.statuses.pluck(:status) }
+
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) }
context 'when there is a running external job and a regular job' do
before do
@@ -2624,7 +2636,9 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#retry_failed' do
- let(:latest_status) { pipeline.latest_statuses.pluck(:status) }
+ subject(:latest_status) { pipeline.latest_statuses.pluck(:status) }
+
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) }
before do
stub_not_protect_default_branch
@@ -2673,11 +2687,12 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#execute_hooks' do
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) }
let!(:build_a) { create_build('a', 0) }
let!(:build_b) { create_build('b', 0) }
let!(:hook) do
- create(:project_hook, project: project, pipeline_events: enabled)
+ create(:project_hook, pipeline_events: enabled)
end
before do
@@ -2703,7 +2718,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
it 'builds hook data once' do
- create(:pipelines_email_service, project: project)
+ create(:pipelines_email_service)
expect(Gitlab::DataBuilder::Pipeline).to receive(:build).once.and_call_original
@@ -2789,7 +2804,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe "#merge_requests_as_head_pipeline" do
- let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: 'master', sha: 'a288a022a53a5a944fae87bcec6efc87b7061808') }
+ let_it_be_with_reload(:pipeline) { create(:ci_empty_pipeline, status: 'created', ref: 'master', sha: 'a288a022a53a5a944fae87bcec6efc87b7061808') }
it "returns merge requests whose `diff_head_sha` matches the pipeline's SHA" do
allow_next_instance_of(MergeRequest) do |instance|
@@ -2801,7 +2816,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
it "doesn't return merge requests whose source branch doesn't match the pipeline's ref" do
- create(:merge_request, source_project: project, source_branch: 'feature', target_branch: 'master')
+ create(:merge_request, :simple, source_project: project)
expect(pipeline.merge_requests_as_head_pipeline).to be_empty
end
@@ -2817,7 +2832,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#all_merge_requests' do
- let(:project) { create(:project) }
+ let_it_be_with_reload(:project) { create(:project) }
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, :created, project: project) }
shared_examples 'a method that returns all merge requests for a given pipeline' do
let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: pipeline_project, ref: 'master') }
@@ -2911,10 +2927,9 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#related_merge_requests' do
- let(:project) { create(:project, :repository) }
let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_branch: 'master') }
let(:other_merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_branch: 'stable') }
- let(:branch_pipeline) { create(:ci_pipeline, project: project, ref: 'feature') }
+ let(:branch_pipeline) { create(:ci_pipeline, ref: 'feature') }
let(:merge_pipeline) { create(:ci_pipeline, :detached_merge_request_pipeline, merge_request: merge_request) }
context 'for a branch pipeline' do
@@ -2951,9 +2966,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#open_merge_requests_refs' do
- let(:project) { create(:project) }
- let(:user) { create(:user) }
- let!(:pipeline) { create(:ci_pipeline, user: user, project: project, ref: 'feature') }
+ let!(:pipeline) { create(:ci_pipeline, user: user, ref: 'feature') }
let!(:merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_branch: 'master') }
subject { pipeline.open_merge_requests_refs }
@@ -3000,6 +3013,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#same_family_pipeline_ids' do
subject { pipeline.same_family_pipeline_ids.map(&:id) }
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) }
+
context 'when pipeline is not child nor parent' do
it 'returns just the pipeline id' do
expect(subject).to contain_exactly(pipeline.id)
@@ -3007,7 +3022,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when pipeline is child' do
- let(:parent) { create(:ci_pipeline, project: project) }
+ let(:parent) { create(:ci_pipeline) }
let!(:pipeline) { create(:ci_pipeline, child_of: parent) }
let!(:sibling) { create(:ci_pipeline, child_of: parent) }
@@ -3025,7 +3040,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when pipeline is a child of a child pipeline' do
- let(:ancestor) { create(:ci_pipeline, project: project) }
+ let(:ancestor) { create(:ci_pipeline) }
let!(:parent) { create(:ci_pipeline, child_of: ancestor) }
let!(:pipeline) { create(:ci_pipeline, child_of: parent) }
let!(:cousin_parent) { create(:ci_pipeline, child_of: ancestor) }
@@ -3050,10 +3065,10 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#root_ancestor' do
subject { pipeline.root_ancestor }
- let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
+ let_it_be(:pipeline) { create(:ci_pipeline) }
context 'when pipeline is child of child pipeline' do
- let!(:root_ancestor) { create(:ci_pipeline, project: project) }
+ let!(:root_ancestor) { create(:ci_pipeline) }
let!(:parent_pipeline) { create(:ci_pipeline, child_of: root_ancestor) }
let!(:pipeline) { create(:ci_pipeline, child_of: parent_pipeline) }
@@ -3088,6 +3103,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#stuck?' do
+ let(:pipeline) { create(:ci_empty_pipeline, :created) }
+
before do
create(:ci_build, :pending, pipeline: pipeline)
end
@@ -3132,6 +3149,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#has_yaml_errors?' do
+ let(:pipeline) { build_stubbed(:ci_pipeline) }
+
context 'when yaml_errors is set' do
before do
pipeline.yaml_errors = 'File not found'
@@ -3201,7 +3220,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
context 'when pipeline is not the latest' do
before do
- create(:ci_pipeline, :success, project: project, ci_ref: pipeline.ci_ref)
+ create(:ci_pipeline, :success, ci_ref: pipeline.ci_ref)
end
it 'does not pass ref_status' do
@@ -3302,7 +3321,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#builds_in_self_and_descendants' do
subject(:builds) { pipeline.builds_in_self_and_descendants }
- let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:pipeline) { create(:ci_pipeline) }
let!(:build) { create(:ci_build, pipeline: pipeline) }
context 'when pipeline is standalone' do
@@ -3333,6 +3352,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#build_with_artifacts_in_self_and_descendants' do
+ let_it_be(:pipeline) { create(:ci_pipeline) }
let!(:build) { create(:ci_build, name: 'test', pipeline: pipeline) }
let(:child_pipeline) { create(:ci_pipeline, child_of: pipeline) }
let!(:child_build) { create(:ci_build, :artifacts, name: 'test', pipeline: child_pipeline) }
@@ -3351,6 +3371,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#find_job_with_archive_artifacts' do
+ let(:pipeline) { create(:ci_pipeline) }
let!(:old_job) { create(:ci_build, name: 'rspec', retried: true, pipeline: pipeline) }
let!(:job_without_artifacts) { create(:ci_build, name: 'rspec', pipeline: pipeline) }
let!(:expected_job) { create(:ci_build, :artifacts, name: 'rspec', pipeline: pipeline ) }
@@ -3364,6 +3385,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#latest_builds_with_artifacts' do
+ let(:pipeline) { create(:ci_pipeline) }
let!(:fresh_build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) }
let!(:stale_build) { create(:ci_build, :success, :expired, :artifacts, pipeline: pipeline) }
@@ -3390,7 +3412,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#batch_lookup_report_artifact_for_file_type' do
context 'with code quality report artifact' do
- let(:pipeline) { create(:ci_pipeline, :with_codequality_reports, project: project) }
+ let(:pipeline) { create(:ci_pipeline, :with_codequality_reports) }
it "returns the code quality artifact" do
expect(pipeline.batch_lookup_report_artifact_for_file_type(:codequality)).to eq(pipeline.job_artifacts.sample)
@@ -3399,24 +3421,26 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#latest_report_builds' do
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
+
it 'returns build with test artifacts' do
- test_build = create(:ci_build, :test_reports, pipeline: pipeline, project: project)
- coverage_build = create(:ci_build, :coverage_reports, pipeline: pipeline, project: project)
+ test_build = create(:ci_build, :test_reports, pipeline: pipeline)
+ coverage_build = create(:ci_build, :coverage_reports, pipeline: pipeline)
create(:ci_build, :artifacts, pipeline: pipeline, project: project)
expect(pipeline.latest_report_builds).to contain_exactly(test_build, coverage_build)
end
it 'filters builds by scope' do
- test_build = create(:ci_build, :test_reports, pipeline: pipeline, project: project)
- create(:ci_build, :coverage_reports, pipeline: pipeline, project: project)
+ test_build = create(:ci_build, :test_reports, pipeline: pipeline)
+ create(:ci_build, :coverage_reports, pipeline: pipeline)
expect(pipeline.latest_report_builds(Ci::JobArtifact.test_reports)).to contain_exactly(test_build)
end
it 'only returns not retried builds' do
- test_build = create(:ci_build, :test_reports, pipeline: pipeline, project: project)
- create(:ci_build, :test_reports, :retried, pipeline: pipeline, project: project)
+ test_build = create(:ci_build, :test_reports, pipeline: pipeline)
+ create(:ci_build, :test_reports, :retried, pipeline: pipeline)
expect(pipeline.latest_report_builds).to contain_exactly(test_build)
end
@@ -3427,17 +3451,17 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
context 'when pipeline has builds with test reports' do
before do
- create(:ci_build, :test_reports, pipeline: pipeline, project: project)
+ create(:ci_build, :test_reports, pipeline: pipeline)
end
context 'when pipeline status is running' do
- let(:pipeline) { create(:ci_pipeline, :running, project: project) }
+ let(:pipeline) { create(:ci_pipeline, :running) }
it { is_expected.to be_falsey }
end
context 'when pipeline status is success' do
- let(:pipeline) { create(:ci_pipeline, :success, project: project) }
+ let(:pipeline) { create(:ci_pipeline, :success) }
it { is_expected.to be_truthy }
end
@@ -3445,20 +3469,20 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
context 'when pipeline does not have builds with test reports' do
before do
- create(:ci_build, :artifacts, pipeline: pipeline, project: project)
+ create(:ci_build, :artifacts, pipeline: pipeline)
end
- let(:pipeline) { create(:ci_pipeline, :success, project: project) }
+ let(:pipeline) { create(:ci_pipeline, :success) }
it { is_expected.to be_falsey }
end
context 'when retried build has test reports' do
before do
- create(:ci_build, :retried, :test_reports, pipeline: pipeline, project: project)
+ create(:ci_build, :retried, :test_reports, pipeline: pipeline)
end
- let(:pipeline) { create(:ci_pipeline, :success, project: project) }
+ let(:pipeline) { create(:ci_pipeline, :success) }
it { is_expected.to be_falsey }
end
@@ -3468,13 +3492,13 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
subject { pipeline.has_coverage_reports? }
context 'when pipeline has a code coverage artifact' do
- let(:pipeline) { create(:ci_pipeline, :with_coverage_report_artifact, :running, project: project) }
+ let(:pipeline) { create(:ci_pipeline, :with_coverage_report_artifact, :running) }
it { expect(subject).to be_truthy }
end
context 'when pipeline does not have a code coverage artifact' do
- let(:pipeline) { create(:ci_pipeline, :success, project: project) }
+ let(:pipeline) { create(:ci_pipeline, :success) }
it { expect(subject).to be_falsey }
end
@@ -3485,17 +3509,17 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
context 'when pipeline has builds with coverage reports' do
before do
- create(:ci_build, :coverage_reports, pipeline: pipeline, project: project)
+ create(:ci_build, :coverage_reports, pipeline: pipeline)
end
context 'when pipeline status is running' do
- let(:pipeline) { create(:ci_pipeline, :running, project: project) }
+ let(:pipeline) { create(:ci_pipeline, :running) }
it { expect(subject).to be_falsey }
end
context 'when pipeline status is success' do
- let(:pipeline) { create(:ci_pipeline, :success, project: project) }
+ let(:pipeline) { create(:ci_pipeline, :success) }
it { expect(subject).to be_truthy }
end
@@ -3503,10 +3527,10 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
context 'when pipeline does not have builds with coverage reports' do
before do
- create(:ci_build, :artifacts, pipeline: pipeline, project: project)
+ create(:ci_build, :artifacts, pipeline: pipeline)
end
- let(:pipeline) { create(:ci_pipeline, :success, project: project) }
+ let(:pipeline) { create(:ci_pipeline, :success) }
it { expect(subject).to be_falsey }
end
@@ -3516,13 +3540,13 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
subject { pipeline.has_codequality_mr_diff_report? }
context 'when pipeline has a codequality mr diff report' do
- let(:pipeline) { create(:ci_pipeline, :with_codequality_mr_diff_report, :running, project: project) }
+ let(:pipeline) { create(:ci_pipeline, :with_codequality_mr_diff_report, :running) }
it { expect(subject).to be_truthy }
end
context 'when pipeline does not have a codequality mr diff report' do
- let(:pipeline) { create(:ci_pipeline, :success, project: project) }
+ let(:pipeline) { create(:ci_pipeline, :success) }
it { expect(subject).to be_falsey }
end
@@ -3533,17 +3557,17 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
context 'when pipeline has builds with codequality reports' do
before do
- create(:ci_build, :codequality_reports, pipeline: pipeline, project: project)
+ create(:ci_build, :codequality_reports, pipeline: pipeline)
end
context 'when pipeline status is running' do
- let(:pipeline) { create(:ci_pipeline, :running, project: project) }
+ let(:pipeline) { create(:ci_pipeline, :running) }
it { expect(subject).to be_falsey }
end
context 'when pipeline status is success' do
- let(:pipeline) { create(:ci_pipeline, :success, project: project) }
+ let(:pipeline) { create(:ci_pipeline, :success) }
it 'can generate a codequality report' do
expect(subject).to be_truthy
@@ -3563,10 +3587,10 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
context 'when pipeline does not have builds with codequality reports' do
before do
- create(:ci_build, :artifacts, pipeline: pipeline, project: project)
+ create(:ci_build, :artifacts, pipeline: pipeline)
end
- let(:pipeline) { create(:ci_pipeline, :success, project: project) }
+ let(:pipeline) { create(:ci_pipeline, :success) }
it { expect(subject).to be_falsey }
end
@@ -3575,12 +3599,12 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#test_report_summary' do
subject { pipeline.test_report_summary }
- context 'when pipeline has multiple builds with report results' do
- let(:pipeline) { create(:ci_pipeline, :success, project: project) }
+ let(:pipeline) { create(:ci_pipeline, :success) }
+ context 'when pipeline has multiple builds with report results' do
before do
- create(:ci_build, :success, :report_results, name: 'rspec', pipeline: pipeline, project: project)
- create(:ci_build, :success, :report_results, name: 'java', pipeline: pipeline, project: project)
+ create(:ci_build, :success, :report_results, name: 'rspec', pipeline: pipeline)
+ create(:ci_build, :success, :report_results, name: 'java', pipeline: pipeline)
end
it 'returns test report summary with collected data' do
@@ -3598,13 +3622,15 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#test_reports' do
subject { pipeline.test_reports }
+ let_it_be(:pipeline) { create(:ci_pipeline) }
+
context 'when pipeline has multiple builds with test reports' do
- let!(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline, project: project) }
- let!(:build_java) { create(:ci_build, :success, name: 'java', pipeline: pipeline, project: project) }
+ let!(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline) }
+ let!(:build_java) { create(:ci_build, :success, name: 'java', pipeline: pipeline) }
before do
- create(:ci_job_artifact, :junit, job: build_rspec, project: project)
- create(:ci_job_artifact, :junit_with_ant, job: build_java, project: project)
+ create(:ci_job_artifact, :junit, job: build_rspec)
+ create(:ci_job_artifact, :junit_with_ant, job: build_java)
end
it 'returns test reports with collected data' do
@@ -3614,8 +3640,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when builds are retried' do
- let!(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline, project: project) }
- let!(:build_java) { create(:ci_build, :retried, :success, name: 'java', pipeline: pipeline, project: project) }
+ let!(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline) }
+ let!(:build_java) { create(:ci_build, :retried, :success, name: 'java', pipeline: pipeline) }
it 'does not take retried builds into account' do
expect(subject.total_count).to be(0)
@@ -3635,13 +3661,15 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#accessibility_reports' do
subject { pipeline.accessibility_reports }
+ let_it_be(:pipeline) { create(:ci_pipeline) }
+
context 'when pipeline has multiple builds with accessibility reports' do
- let(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline, project: project) }
- let(:build_golang) { create(:ci_build, :success, name: 'golang', pipeline: pipeline, project: project) }
+ let(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline) }
+ let(:build_golang) { create(:ci_build, :success, name: 'golang', pipeline: pipeline) }
before do
- create(:ci_job_artifact, :accessibility, job: build_rspec, project: project)
- create(:ci_job_artifact, :accessibility_without_errors, job: build_golang, project: project)
+ create(:ci_job_artifact, :accessibility, job: build_rspec)
+ create(:ci_job_artifact, :accessibility_without_errors, job: build_golang)
end
it 'returns accessibility report with collected data' do
@@ -3652,8 +3680,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when builds are retried' do
- let(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline, project: project) }
- let(:build_golang) { create(:ci_build, :retried, :success, name: 'golang', pipeline: pipeline, project: project) }
+ let(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline) }
+ let(:build_golang) { create(:ci_build, :retried, :success, name: 'golang', pipeline: pipeline) }
it 'returns empty urls for accessibility reports' do
expect(subject.urls).to be_empty
@@ -3671,13 +3699,15 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#coverage_reports' do
subject { pipeline.coverage_reports }
+ let_it_be(:pipeline) { create(:ci_pipeline) }
+
context 'when pipeline has multiple builds with coverage reports' do
- let!(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline, project: project) }
- let!(:build_golang) { create(:ci_build, :success, name: 'golang', pipeline: pipeline, project: project) }
+ let!(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline) }
+ let!(:build_golang) { create(:ci_build, :success, name: 'golang', pipeline: pipeline) }
before do
- create(:ci_job_artifact, :cobertura, job: build_rspec, project: project)
- create(:ci_job_artifact, :coverage_gocov_xml, job: build_golang, project: project)
+ create(:ci_job_artifact, :cobertura, job: build_rspec)
+ create(:ci_job_artifact, :coverage_gocov_xml, job: build_golang)
end
it 'returns coverage reports with collected data' do
@@ -3689,8 +3719,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
it 'does not execute N+1 queries' do
- single_build_pipeline = create(:ci_empty_pipeline, status: :created, project: project)
- single_rspec = create(:ci_build, :success, name: 'rspec', pipeline: single_build_pipeline, project: project)
+ single_build_pipeline = create(:ci_empty_pipeline, :created)
+ single_rspec = create(:ci_build, :success, name: 'rspec', pipeline: single_build_pipeline)
create(:ci_job_artifact, :cobertura, job: single_rspec, project: project)
control = ActiveRecord::QueryRecorder.new { single_build_pipeline.coverage_reports }
@@ -3699,8 +3729,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when builds are retried' do
- let!(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline, project: project) }
- let!(:build_golang) { create(:ci_build, :retried, :success, name: 'golang', pipeline: pipeline, project: project) }
+ let!(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline) }
+ let!(:build_golang) { create(:ci_build, :retried, :success, name: 'golang', pipeline: pipeline) }
it 'does not take retried builds into account' do
expect(subject.files).to eql({})
@@ -3718,13 +3748,15 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#codequality_reports' do
subject(:codequality_reports) { pipeline.codequality_reports }
+ let_it_be(:pipeline) { create(:ci_pipeline) }
+
context 'when pipeline has multiple builds with codequality reports' do
- let(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline, project: project) }
- let(:build_golang) { create(:ci_build, :success, name: 'golang', pipeline: pipeline, project: project) }
+ let(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline) }
+ let(:build_golang) { create(:ci_build, :success, name: 'golang', pipeline: pipeline) }
before do
- create(:ci_job_artifact, :codequality, job: build_rspec, project: project)
- create(:ci_job_artifact, :codequality_without_errors, job: build_golang, project: project)
+ create(:ci_job_artifact, :codequality, job: build_rspec)
+ create(:ci_job_artifact, :codequality_without_errors, job: build_golang)
end
it 'returns codequality report with collected data' do
@@ -3732,8 +3764,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when builds are retried' do
- let(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline, project: project) }
- let(:build_golang) { create(:ci_build, :retried, :success, name: 'golang', pipeline: pipeline, project: project) }
+ let(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline) }
+ let(:build_golang) { create(:ci_build, :retried, :success, name: 'golang', pipeline: pipeline) }
it 'returns a codequality reports without degradations' do
expect(codequality_reports.degradations).to be_empty
@@ -3749,6 +3781,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#total_size' do
+ let(:pipeline) { create(:ci_pipeline) }
let!(:build_job1) { create(:ci_build, pipeline: pipeline, stage_idx: 0) }
let!(:build_job2) { create(:ci_build, pipeline: pipeline, stage_idx: 0) }
let!(:test_job_failed_and_retried) { create(:ci_build, :failed, :retried, pipeline: pipeline, stage_idx: 1) }
@@ -3785,17 +3818,11 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#default_branch?' do
- let(:default_branch) { 'master'}
-
subject { pipeline.default_branch? }
- before do
- allow(project).to receive(:default_branch).and_return(default_branch)
- end
-
context 'when pipeline ref is the default branch of the project' do
let(:pipeline) do
- build(:ci_empty_pipeline, status: :created, project: project, ref: default_branch)
+ build(:ci_empty_pipeline, :created, project: project, ref: project.default_branch)
end
it "returns true" do
@@ -3805,7 +3832,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
context 'when pipeline ref is not the default branch of the project' do
let(:pipeline) do
- build(:ci_empty_pipeline, status: :created, project: project, ref: 'another_branch')
+ build(:ci_empty_pipeline, :created, project: project, ref: 'another_branch')
end
it "returns false" do
@@ -3815,7 +3842,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#find_stage_by_name' do
- let(:pipeline) { create(:ci_pipeline) }
+ let_it_be(:pipeline) { create(:ci_pipeline) }
let(:stage_name) { 'test' }
let(:stage) do
@@ -3895,10 +3922,10 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#parent_pipeline' do
- let_it_be(:project) { create(:project) }
+ let_it_be_with_reload(:pipeline) { create(:ci_pipeline) }
context 'when pipeline is triggered by a pipeline from the same project' do
- let_it_be(:upstream_pipeline) { create(:ci_pipeline, project: project) }
+ let_it_be(:upstream_pipeline) { create(:ci_pipeline) }
let_it_be(:pipeline) { create(:ci_pipeline, child_of: upstream_pipeline) }
it 'returns the parent pipeline' do
@@ -3911,7 +3938,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when pipeline is triggered by a pipeline from another project' do
- let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:pipeline) { create(:ci_pipeline) }
let!(:upstream_pipeline) { create(:ci_pipeline, project: create(:project), upstream_of: pipeline) }
it 'returns nil' do
@@ -3938,7 +3965,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#child_pipelines' do
let_it_be(:project) { create(:project) }
- let(:pipeline) { create(:ci_pipeline, project: project) }
+ let_it_be_with_reload(:pipeline) { create(:ci_pipeline, project: project) }
context 'when pipeline triggered other pipelines on same project' do
let(:downstream_pipeline) { create(:ci_pipeline, project: pipeline.project) }
@@ -3992,6 +4019,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe 'upstream status interactions' do
+ let_it_be_with_reload(:pipeline) { create(:ci_pipeline, :created) }
+
context 'when a pipeline has an upstream status' do
context 'when an upstream status is a bridge' do
let(:bridge) { create(:ci_bridge, status: :pending) }
@@ -4050,6 +4079,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#source_ref_path' do
subject { pipeline.source_ref_path }
+ let(:pipeline) { create(:ci_pipeline, :created) }
+
context 'when pipeline is for a branch' do
it { is_expected.to eq(Gitlab::Git::BRANCH_REF_PREFIX + pipeline.source_ref.to_s) }
end
@@ -4062,13 +4093,15 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when pipeline is for a tag' do
- let(:pipeline) { create(:ci_pipeline, project: project, tag: true) }
+ let(:pipeline) { create(:ci_pipeline, tag: true) }
it { is_expected.to eq(Gitlab::Git::TAG_REF_PREFIX + pipeline.source_ref.to_s) }
end
end
- describe "#builds_with_coverage" do
+ describe '#builds_with_coverage' do
+ let_it_be(:pipeline) { create(:ci_pipeline, :created) }
+
it 'returns builds with coverage only' do
rspec = create(:ci_build, name: 'rspec', coverage: 97.1, pipeline: pipeline)
jest = create(:ci_build, name: 'jest', coverage: 94.1, pipeline: pipeline)
@@ -4092,10 +4125,11 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#base_and_ancestors' do
- let(:same_project) { false }
-
subject { pipeline.base_and_ancestors(same_project: same_project) }
+ let_it_be(:pipeline) { create(:ci_pipeline, :created) }
+ let(:same_project) { false }
+
context 'when pipeline is not child nor parent' do
it 'returns just the pipeline itself' do
expect(subject).to contain_exactly(pipeline)
@@ -4103,8 +4137,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when pipeline is child' do
- let(:parent) { create(:ci_pipeline, project: pipeline.project) }
- let(:sibling) { create(:ci_pipeline, project: pipeline.project) }
+ let(:parent) { create(:ci_pipeline) }
+ let(:sibling) { create(:ci_pipeline) }
before do
create_source_pipeline(parent, pipeline)
@@ -4117,7 +4151,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when pipeline is parent' do
- let(:child) { create(:ci_pipeline, project: pipeline.project) }
+ let(:child) { create(:ci_pipeline) }
before do
create_source_pipeline(pipeline, child)
@@ -4129,8 +4163,9 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when pipeline is a child of a child pipeline' do
- let(:ancestor) { create(:ci_pipeline, project: pipeline.project) }
- let(:parent) { create(:ci_pipeline, project: pipeline.project) }
+ let_it_be(:pipeline) { create(:ci_pipeline, :created) }
+ let(:ancestor) { create(:ci_pipeline) }
+ let(:parent) { create(:ci_pipeline) }
before do
create_source_pipeline(ancestor, parent)
@@ -4143,6 +4178,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when pipeline is a triggered pipeline' do
+ let_it_be(:pipeline) { create(:ci_pipeline, :created) }
let(:upstream) { create(:ci_pipeline, project: create(:project)) }
before do
@@ -4166,8 +4202,10 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe 'reset_ancestor_bridges!' do
+ let_it_be(:pipeline) { create(:ci_pipeline, :created) }
+
context 'when the pipeline is a child pipeline and the bridge is depended' do
- let!(:parent_pipeline) { create(:ci_pipeline, project: project) }
+ let!(:parent_pipeline) { create(:ci_pipeline) }
let!(:bridge) { create_bridge(parent_pipeline, pipeline, true) }
it 'marks source bridge as pending' do
@@ -4191,7 +4229,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
context 'when the pipeline is a child pipeline and the bridge is not depended' do
- let!(:parent_pipeline) { create(:ci_pipeline, project: project) }
+ let!(:parent_pipeline) { create(:ci_pipeline) }
let!(:bridge) { create_bridge(parent_pipeline, pipeline, false) }
it 'does not touch source bridge' do
@@ -4227,6 +4265,8 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe 'test failure history processing' do
+ let(:pipeline) { build(:ci_pipeline, :created) }
+
it 'performs the service asynchronously when the pipeline is completed' do
service = double
@@ -4238,21 +4278,23 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#latest_test_report_builds' do
+ let_it_be(:pipeline) { create(:ci_pipeline, :created) }
+
it 'returns pipeline builds with test report artifacts' do
- test_build = create(:ci_build, :test_reports, pipeline: pipeline, project: project)
+ test_build = create(:ci_build, :test_reports, pipeline: pipeline)
create(:ci_build, :artifacts, pipeline: pipeline, project: project)
expect(pipeline.latest_test_report_builds).to contain_exactly(test_build)
end
it 'preloads project on each build to avoid N+1 queries' do
- create(:ci_build, :test_reports, pipeline: pipeline, project: project)
+ create(:ci_build, :test_reports, pipeline: pipeline)
control_count = ActiveRecord::QueryRecorder.new do
pipeline.latest_test_report_builds.map(&:project).map(&:full_path)
end
- multi_build_pipeline = create(:ci_empty_pipeline, status: :created, project: project)
+ multi_build_pipeline = create(:ci_empty_pipeline, :created)
create(:ci_build, :test_reports, pipeline: multi_build_pipeline, project: project)
create(:ci_build, :test_reports, pipeline: multi_build_pipeline, project: project)
@@ -4262,30 +4304,32 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#builds_with_failed_tests' do
+ let_it_be(:pipeline) { create(:ci_pipeline, :created) }
+
it 'returns pipeline builds with test report artifacts' do
- failed_build = create(:ci_build, :failed, :test_reports, pipeline: pipeline, project: project)
- create(:ci_build, :success, :test_reports, pipeline: pipeline, project: project)
+ failed_build = create(:ci_build, :failed, :test_reports, pipeline: pipeline)
+ create(:ci_build, :success, :test_reports, pipeline: pipeline)
expect(pipeline.builds_with_failed_tests).to contain_exactly(failed_build)
end
it 'supports limiting the number of builds to fetch' do
- create(:ci_build, :failed, :test_reports, pipeline: pipeline, project: project)
- create(:ci_build, :failed, :test_reports, pipeline: pipeline, project: project)
+ create(:ci_build, :failed, :test_reports, pipeline: pipeline)
+ create(:ci_build, :failed, :test_reports, pipeline: pipeline)
expect(pipeline.builds_with_failed_tests(limit: 1).count).to eq(1)
end
it 'preloads project on each build to avoid N+1 queries' do
- create(:ci_build, :failed, :test_reports, pipeline: pipeline, project: project)
+ create(:ci_build, :failed, :test_reports, pipeline: pipeline)
control_count = ActiveRecord::QueryRecorder.new do
pipeline.builds_with_failed_tests.map(&:project).map(&:full_path)
end
- multi_build_pipeline = create(:ci_empty_pipeline, status: :created, project: project)
- create(:ci_build, :failed, :test_reports, pipeline: multi_build_pipeline, project: project)
- create(:ci_build, :failed, :test_reports, pipeline: multi_build_pipeline, project: project)
+ multi_build_pipeline = create(:ci_empty_pipeline, :created)
+ create(:ci_build, :failed, :test_reports, pipeline: multi_build_pipeline)
+ create(:ci_build, :failed, :test_reports, pipeline: multi_build_pipeline)
expect { multi_build_pipeline.builds_with_failed_tests.map(&:project).map(&:full_path) }
.not_to exceed_query_limit(control_count)
diff --git a/spec/models/ci/processable_spec.rb b/spec/models/ci/processable_spec.rb
index 6290f4aef16..0a43f785598 100644
--- a/spec/models/ci/processable_spec.rb
+++ b/spec/models/ci/processable_spec.rb
@@ -112,8 +112,8 @@ RSpec.describe Ci::Processable do
it 'returns all needs attributes' do
is_expected.to contain_exactly(
- { 'artifacts' => true, 'name' => 'test1' },
- { 'artifacts' => true, 'name' => 'test2' }
+ { 'artifacts' => true, 'name' => 'test1', 'optional' => false },
+ { 'artifacts' => true, 'name' => 'test2', 'optional' => false }
)
end
end
diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb
index 3e5d068d780..ff3551d2a18 100644
--- a/spec/models/ci/runner_spec.rb
+++ b/spec/models/ci/runner_spec.rb
@@ -40,41 +40,39 @@ RSpec.describe Ci::Runner do
context 'runner_type validations' do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project) }
- let(:group_runner) { create(:ci_runner, :group, groups: [group]) }
- let(:project_runner) { create(:ci_runner, :project, projects: [project]) }
- let(:instance_runner) { create(:ci_runner, :instance) }
it 'disallows assigning group to project_type runner' do
- project_runner.groups << build(:group)
+ project_runner = build(:ci_runner, :project, groups: [group])
expect(project_runner).not_to be_valid
expect(project_runner.errors.full_messages).to include('Runner cannot have groups assigned')
end
it 'disallows assigning group to instance_type runner' do
- instance_runner.groups << build(:group)
+ instance_runner = build(:ci_runner, :instance, groups: [group])
expect(instance_runner).not_to be_valid
expect(instance_runner.errors.full_messages).to include('Runner cannot have groups assigned')
end
it 'disallows assigning project to group_type runner' do
- group_runner.projects << build(:project)
+ group_runner = build(:ci_runner, :instance, projects: [project])
expect(group_runner).not_to be_valid
expect(group_runner.errors.full_messages).to include('Runner cannot have projects assigned')
end
it 'disallows assigning project to instance_type runner' do
- instance_runner.projects << build(:project)
+ instance_runner = build(:ci_runner, :instance, projects: [project])
expect(instance_runner).not_to be_valid
expect(instance_runner.errors.full_messages).to include('Runner cannot have projects assigned')
end
it 'fails to save a group assigned to a project runner even if the runner is already saved' do
- group.runners << project_runner
- expect { group.save! }
+ project_runner = create(:ci_runner, :project, projects: [project])
+
+ expect { create(:group, runners: [project_runner]) }
.to raise_error(ActiveRecord::RecordInvalid)
end
end
@@ -352,6 +350,8 @@ RSpec.describe Ci::Runner do
end
describe '#can_pick?' do
+ using RSpec::Parameterized::TableSyntax
+
let_it_be(:pipeline) { create(:ci_pipeline) }
let(:build) { create(:ci_build, pipeline: pipeline) }
let(:runner_project) { build.project }
@@ -365,6 +365,11 @@ RSpec.describe Ci::Runner do
let(:other_project) { create(:project) }
let(:other_runner) { create(:ci_runner, :project, projects: [other_project], tag_list: tag_list, run_untagged: run_untagged) }
+ before do
+ # `can_pick?` is not used outside the runners available for the project
+ stub_feature_flags(ci_runners_short_circuit_assignable_for: false)
+ end
+
it 'cannot handle builds' do
expect(other_runner.can_pick?(build)).to be_falsey
end
@@ -432,9 +437,32 @@ RSpec.describe Ci::Runner do
expect(runner.can_pick?(build)).to be_truthy
end
end
+
+ it 'does not query for owned or instance runners' do
+ expect(described_class).not_to receive(:owned_or_instance_wide)
+
+ runner.can_pick?(build)
+ end
+
+ context 'when feature flag ci_runners_short_circuit_assignable_for is disabled' do
+ before do
+ stub_feature_flags(ci_runners_short_circuit_assignable_for: false)
+ end
+
+ it 'does not query for owned or instance runners' do
+ expect(described_class).to receive(:owned_or_instance_wide).and_call_original
+
+ runner.can_pick?(build)
+ end
+ end
end
context 'when runner is not shared' do
+ before do
+ # `can_pick?` is not used outside the runners available for the project
+ stub_feature_flags(ci_runners_short_circuit_assignable_for: false)
+ end
+
context 'when runner is assigned to a project' do
it 'can handle builds' do
expect(runner.can_pick?(build)).to be_truthy
@@ -502,6 +530,29 @@ RSpec.describe Ci::Runner do
it { is_expected.to be_falsey }
end
end
+
+ context 'matches tags' do
+ where(:run_untagged, :runner_tags, :build_tags, :result) do
+ true | [] | [] | true
+ true | [] | ['a'] | false
+ true | %w[a b] | ['a'] | true
+ true | ['a'] | %w[a b] | false
+ true | ['a'] | ['a'] | true
+ false | ['a'] | ['a'] | true
+ false | ['b'] | ['a'] | false
+ false | %w[a b] | ['a'] | true
+ end
+
+ with_them do
+ let(:tag_list) { runner_tags }
+
+ before do
+ build.tag_list = build_tags
+ end
+
+ it { is_expected.to eq(result) }
+ end
+ end
end
describe '#status' do
@@ -844,27 +895,50 @@ RSpec.describe Ci::Runner do
end
describe '#pick_build!' do
+ let(:build) { create(:ci_build) }
+ let(:runner) { create(:ci_runner) }
+
context 'runner can pick the build' do
it 'calls #tick_runner_queue' do
- ci_build = build(:ci_build)
- runner = build(:ci_runner)
- allow(runner).to receive(:can_pick?).with(ci_build).and_return(true)
-
expect(runner).to receive(:tick_runner_queue)
- runner.pick_build!(ci_build)
+ runner.pick_build!(build)
end
end
context 'runner cannot pick the build' do
- it 'does not call #tick_runner_queue' do
- ci_build = build(:ci_build)
- runner = build(:ci_runner)
- allow(runner).to receive(:can_pick?).with(ci_build).and_return(false)
+ before do
+ build.tag_list = [:docker]
+ end
+ it 'does not call #tick_runner_queue' do
expect(runner).not_to receive(:tick_runner_queue)
- runner.pick_build!(ci_build)
+ runner.pick_build!(build)
+ end
+ end
+
+ context 'build picking improvement enabled' do
+ before do
+ stub_feature_flags(ci_reduce_queries_when_ticking_runner_queue: true)
+ end
+
+ it 'does not check if the build is assignable to a runner' do
+ expect(runner).not_to receive(:can_pick?)
+
+ runner.pick_build!(build)
+ end
+ end
+
+ context 'build picking improvement disabled' do
+ before do
+ stub_feature_flags(ci_reduce_queries_when_ticking_runner_queue: false)
+ end
+
+ it 'checks if the build is assignable to a runner' do
+ expect(runner).to receive(:can_pick?).and_call_original
+
+ runner.pick_build!(build)
end
end
end
diff --git a/spec/models/ci/variable_spec.rb b/spec/models/ci/variable_spec.rb
index 26a7a2596af..93a24ba9157 100644
--- a/spec/models/ci/variable_spec.rb
+++ b/spec/models/ci/variable_spec.rb
@@ -14,6 +14,15 @@ RSpec.describe Ci::Variable do
it { is_expected.to validate_uniqueness_of(:key).scoped_to(:project_id, :environment_scope).with_message(/\(\w+\) has already been taken/) }
end
+ describe '.by_environment_scope' do
+ let!(:matching_variable) { create(:ci_variable, environment_scope: 'production ') }
+ let!(:non_matching_variable) { create(:ci_variable, environment_scope: 'staging') }
+
+ subject { Ci::Variable.by_environment_scope('production') }
+
+ it { is_expected.to contain_exactly(matching_variable) }
+ end
+
describe '.unprotected' do
subject { described_class.unprotected }
diff --git a/spec/models/clusters/agent_token_spec.rb b/spec/models/clusters/agent_token_spec.rb
index 5cb84ee131a..a1b45df1970 100644
--- a/spec/models/clusters/agent_token_spec.rb
+++ b/spec/models/clusters/agent_token_spec.rb
@@ -3,8 +3,11 @@
require 'spec_helper'
RSpec.describe Clusters::AgentToken do
- it { is_expected.to belong_to(:agent).class_name('Clusters::Agent') }
+ it { is_expected.to belong_to(:agent).class_name('Clusters::Agent').required }
it { is_expected.to belong_to(:created_by_user).class_name('User').optional }
+ it { is_expected.to validate_length_of(:description).is_at_most(1024) }
+ it { is_expected.to validate_length_of(:name).is_at_most(255) }
+ it { is_expected.to validate_presence_of(:name) }
describe '#token' do
it 'is generated on save' do
diff --git a/spec/models/concerns/ci/has_variable_spec.rb b/spec/models/concerns/ci/has_variable_spec.rb
index b5390281064..e917ec6b802 100644
--- a/spec/models/concerns/ci/has_variable_spec.rb
+++ b/spec/models/concerns/ci/has_variable_spec.rb
@@ -11,6 +11,17 @@ RSpec.describe Ci::HasVariable do
it { is_expected.not_to allow_value('foo bar').for(:key) }
it { is_expected.not_to allow_value('foo/bar').for(:key) }
+ describe 'scopes' do
+ describe '.by_key' do
+ let!(:matching_variable) { create(:ci_variable, key: 'example') }
+ let!(:non_matching_variable) { create(:ci_variable, key: 'other') }
+
+ subject { Ci::Variable.by_key('example') }
+
+ it { is_expected.to contain_exactly(matching_variable) }
+ end
+ end
+
describe '#key=' do
context 'when the new key is nil' do
it 'strips leading and trailing whitespaces' do
diff --git a/spec/models/concerns/project_features_compatibility_spec.rb b/spec/models/concerns/project_features_compatibility_spec.rb
index 2059e170446..62c9a041a85 100644
--- a/spec/models/concerns/project_features_compatibility_spec.rb
+++ b/spec/models/concerns/project_features_compatibility_spec.rb
@@ -4,8 +4,8 @@ require 'spec_helper'
RSpec.describe ProjectFeaturesCompatibility do
let(:project) { create(:project) }
- let(:features_enabled) { %w(issues wiki builds merge_requests snippets) }
- let(:features) { features_enabled + %w(repository pages operations) }
+ let(:features_enabled) { %w(issues wiki builds merge_requests snippets security_and_compliance) }
+ let(:features) { features_enabled + %w(repository pages operations container_registry) }
# We had issues_enabled, snippets_enabled, builds_enabled, merge_requests_enabled and issues_enabled fields on projects table
# All those fields got moved to a new table called project_feature and are now integers instead of booleans
diff --git a/spec/models/custom_emoji_spec.rb b/spec/models/custom_emoji_spec.rb
index 41ce480b02f..e34934d393a 100644
--- a/spec/models/custom_emoji_spec.rb
+++ b/spec/models/custom_emoji_spec.rb
@@ -4,8 +4,10 @@ require 'spec_helper'
RSpec.describe CustomEmoji do
describe 'Associations' do
- it { is_expected.to belong_to(:namespace) }
+ it { is_expected.to belong_to(:namespace).inverse_of(:custom_emoji) }
+ it { is_expected.to belong_to(:creator).inverse_of(:created_custom_emoji) }
it { is_expected.to have_db_column(:file) }
+ it { is_expected.to validate_presence_of(:creator) }
it { is_expected.to validate_length_of(:name).is_at_most(36) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to have_db_column(:external) }
@@ -36,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(name: ["has already been taken"])
+ expect(new_emoji.errors.messages).to include(name: ["has already been taken"])
end
it 'disallows non http and https file value' do
diff --git a/spec/models/dependency_proxy/manifest_spec.rb b/spec/models/dependency_proxy/manifest_spec.rb
index aa2e73356dd..4203644c003 100644
--- a/spec/models/dependency_proxy/manifest_spec.rb
+++ b/spec/models/dependency_proxy/manifest_spec.rb
@@ -29,24 +29,32 @@ RSpec.describe DependencyProxy::Manifest, type: :model do
end
end
- describe '.find_or_initialize_by_file_name' do
- subject { DependencyProxy::Manifest.find_or_initialize_by_file_name(file_name) }
+ describe '.find_or_initialize_by_file_name_or_digest' do
+ let_it_be(:file_name) { 'foo' }
+ let_it_be(:digest) { 'bar' }
- context 'no manifest exists' do
- let_it_be(:file_name) { 'foo' }
+ subject { DependencyProxy::Manifest.find_or_initialize_by_file_name_or_digest(file_name: file_name, digest: digest) }
+ context 'no manifest exists' do
it 'initializes a manifest' do
- expect(DependencyProxy::Manifest).to receive(:new).with(file_name: file_name)
+ expect(DependencyProxy::Manifest).to receive(:new).with(file_name: file_name, digest: digest)
subject
end
end
- context 'manifest exists' do
+ context 'manifest exists and matches file_name' do
let_it_be(:dependency_proxy_manifest) { create(:dependency_proxy_manifest) }
let_it_be(:file_name) { dependency_proxy_manifest.file_name }
it { is_expected.to eq(dependency_proxy_manifest) }
end
+
+ context 'manifest exists and matches digest' do
+ let_it_be(:dependency_proxy_manifest) { create(:dependency_proxy_manifest) }
+ let_it_be(:digest) { dependency_proxy_manifest.digest }
+
+ it { is_expected.to eq(dependency_proxy_manifest) }
+ end
end
end
diff --git a/spec/models/email_spec.rb b/spec/models/email_spec.rb
index ffdc621dd4c..62f2a53ab3c 100644
--- a/spec/models/email_spec.rb
+++ b/spec/models/email_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Email do
end
describe 'validations' do
- it_behaves_like 'an object with RFC3696 compliant email-formated attributes', :email do
+ it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :email do
subject { build(:email) }
end
end
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index 0c7d8e2969d..e021a6cf6d3 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -34,6 +34,27 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
it { is_expected.to validate_length_of(:external_url).is_at_most(255) }
+ describe '.before_save' do
+ it 'ensures environment tier when a new object is created' do
+ environment = build(:environment, name: 'gprd', tier: nil)
+
+ expect { environment.save }.to change { environment.tier }.from(nil).to('production')
+ end
+
+ it 'ensures environment tier when an existing object is updated' do
+ environment = create(:environment, name: 'gprd')
+ environment.update_column(:tier, nil)
+
+ expect { environment.stop! }.to change { environment.reload.tier }.from(nil).to('production')
+ end
+
+ it 'does not overwrite the existing environment tier' do
+ environment = create(:environment, name: 'gprd', tier: :production)
+
+ expect { environment.update!(name: 'gstg') }.not_to change { environment.reload.tier }
+ end
+ end
+
describe '.order_by_last_deployed_at' do
let!(:environment1) { create(:environment, project: project) }
let!(:environment2) { create(:environment, project: project) }
@@ -51,6 +72,62 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end
end
+ describe ".stopped_review_apps" do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:old_stopped_review_env) { create(:environment, :with_review_app, :stopped, created_at: 31.days.ago, project: project) }
+ let_it_be(:new_stopped_review_env) { create(:environment, :with_review_app, :stopped, project: project) }
+ let_it_be(:old_active_review_env) { create(:environment, :with_review_app, :available, created_at: 31.days.ago, project: project) }
+ let_it_be(:old_stopped_other_env) { create(:environment, :stopped, created_at: 31.days.ago, project: project) }
+ let_it_be(:new_stopped_other_env) { create(:environment, :stopped, project: project) }
+ let_it_be(:old_active_other_env) { create(:environment, :available, created_at: 31.days.ago, project: project) }
+
+ let(:before) { 30.days.ago }
+ let(:limit) { 1000 }
+
+ subject { project.environments.stopped_review_apps(before, limit) } # rubocop: disable RSpec/SingleLineHook
+
+ it { is_expected.to contain_exactly(old_stopped_review_env) }
+
+ context "current timestamp" do
+ let(:before) { Time.zone.now }
+
+ it { is_expected.to contain_exactly(old_stopped_review_env, new_stopped_review_env) }
+ end
+ end
+
+ describe "scheduled deletion" do
+ let_it_be(:deletable_environment) { create(:environment, auto_delete_at: Time.zone.now) }
+ let_it_be(:undeletable_environment) { create(:environment, auto_delete_at: nil) }
+
+ describe ".scheduled_for_deletion" do
+ subject { described_class.scheduled_for_deletion }
+
+ it { is_expected.to contain_exactly(deletable_environment) }
+ end
+
+ describe ".not_scheduled_for_deletion" do
+ subject { described_class.not_scheduled_for_deletion }
+
+ it { is_expected.to contain_exactly(undeletable_environment) }
+ end
+
+ describe ".schedule_to_delete" do
+ subject { described_class.for_id(deletable_environment).schedule_to_delete }
+
+ it "schedules the record for deletion" do
+ freeze_time do
+ subject
+
+ deletable_environment.reload
+ undeletable_environment.reload
+
+ expect(deletable_environment.auto_delete_at).to eq(1.week.from_now)
+ expect(undeletable_environment.auto_delete_at).to be_nil
+ end
+ end
+ end
+ end
+
describe 'state machine' do
it 'invalidates the cache after a change' do
expect(environment).to receive(:expire_etag_cache)
@@ -195,6 +272,62 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end
end
+ describe '.for_tier' do
+ let_it_be(:environment) { create(:environment, :production) }
+
+ it 'returns the production environment when searching for production tier' do
+ expect(described_class.for_tier(:production)).to eq([environment])
+ end
+
+ it 'returns nothing when searching for staging tier' do
+ expect(described_class.for_tier(:staging)).to be_empty
+ end
+ end
+
+ describe '#guess_tier' do
+ using RSpec::Parameterized::TableSyntax
+
+ subject { environment.send(:guess_tier) }
+
+ let(:environment) { build(:environment, name: name) }
+
+ where(:name, :tier) do
+ 'review/feature' | described_class.tiers[:development]
+ 'review/product' | described_class.tiers[:development]
+ 'DEV' | described_class.tiers[:development]
+ 'development' | described_class.tiers[:development]
+ 'trunk' | described_class.tiers[:development]
+ 'test' | described_class.tiers[:testing]
+ 'TEST' | described_class.tiers[:testing]
+ 'testing' | described_class.tiers[:testing]
+ 'testing-prd' | described_class.tiers[:testing]
+ 'acceptance-testing' | described_class.tiers[:testing]
+ 'QC' | described_class.tiers[:testing]
+ 'gstg' | described_class.tiers[:staging]
+ 'staging' | described_class.tiers[:staging]
+ 'stage' | described_class.tiers[:staging]
+ 'Model' | described_class.tiers[:staging]
+ 'MODL' | described_class.tiers[:staging]
+ 'Pre-production' | described_class.tiers[:staging]
+ 'pre' | described_class.tiers[:staging]
+ 'Demo' | described_class.tiers[:staging]
+ 'gprd' | described_class.tiers[:production]
+ 'gprd-cny' | described_class.tiers[:production]
+ 'production' | described_class.tiers[:production]
+ 'Production' | described_class.tiers[:production]
+ 'prod' | described_class.tiers[:production]
+ 'PROD' | described_class.tiers[:production]
+ 'Live' | described_class.tiers[:production]
+ 'canary' | described_class.tiers[:other]
+ 'other' | described_class.tiers[:other]
+ 'EXP' | described_class.tiers[:other]
+ end
+
+ with_them do
+ it { is_expected.to eq(tier) }
+ end
+ end
+
describe '#expire_etag_cache' do
let(:store) { Gitlab::EtagCaching::Store.new }
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 72ed11f6c74..3ae0666f7d0 100644
--- a/spec/models/error_tracking/project_error_tracking_setting_spec.rb
+++ b/spec/models/error_tracking/project_error_tracking_setting_spec.rb
@@ -111,7 +111,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
describe '#sentry_client' do
it 'returns sentry client' do
- expect(subject.sentry_client).to be_a(Sentry::Client)
+ expect(subject.sentry_client).to be_a(ErrorTracking::SentryClient)
end
end
@@ -152,7 +152,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
end
end
- context 'when sentry client raises Sentry::Client::Error' do
+ context 'when sentry client raises ErrorTracking::SentryClient::Error' do
let(:sentry_client) { spy(:sentry_client) }
before do
@@ -160,7 +160,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
allow(subject).to receive(:sentry_client).and_return(sentry_client)
allow(sentry_client).to receive(:list_issues).with(opts)
- .and_raise(Sentry::Client::Error, 'error message')
+ .and_raise(ErrorTracking::SentryClient::Error, 'error message')
end
it 'returns error' do
@@ -171,7 +171,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
end
end
- context 'when sentry client raises Sentry::Client::MissingKeysError' do
+ context 'when sentry client raises ErrorTracking::SentryClient::MissingKeysError' do
let(:sentry_client) { spy(:sentry_client) }
before do
@@ -179,7 +179,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
allow(subject).to receive(:sentry_client).and_return(sentry_client)
allow(sentry_client).to receive(:list_issues).with(opts)
- .and_raise(Sentry::Client::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
@@ -190,7 +190,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
end
end
- context 'when sentry client raises Sentry::Client::ResponseInvalidSizeError' do
+ 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}."}
@@ -199,7 +199,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
allow(subject).to receive(:sentry_client).and_return(sentry_client)
allow(sentry_client).to receive(:list_issues).with(opts)
- .and_raise(Sentry::Client::ResponseInvalidSizeError, error_msg)
+ .and_raise(ErrorTracking::SentryClient::ResponseInvalidSizeError, error_msg)
end
it 'returns error' do
diff --git a/spec/models/experiment_spec.rb b/spec/models/experiment_spec.rb
index 22bbf2df8fd..09dd1766acc 100644
--- a/spec/models/experiment_spec.rb
+++ b/spec/models/experiment_spec.rb
@@ -98,10 +98,11 @@ RSpec.describe Experiment do
describe '.record_conversion_event' do
let_it_be(:user) { build(:user) }
+ let_it_be(:context) { { a: 42 } }
let(:experiment_key) { :test_experiment }
- subject(:record_conversion_event) { described_class.record_conversion_event(experiment_key, user) }
+ subject(:record_conversion_event) { described_class.record_conversion_event(experiment_key, user, context) }
context 'when no matching experiment exists' do
it 'creates the experiment and uses it' do
@@ -127,22 +128,79 @@ RSpec.describe Experiment do
it 'sends record_conversion_event_for_user to the experiment instance' do
expect_next_found_instance_of(described_class) do |experiment|
- expect(experiment).to receive(:record_conversion_event_for_user).with(user)
+ expect(experiment).to receive(:record_conversion_event_for_user).with(user, context)
end
record_conversion_event
end
end
end
+ shared_examples 'experiment user with context' do
+ let_it_be(:context) { { a: 42, 'b' => 34, 'c': { c1: 100, c2: 'c2', e: :e }, d: [1, 3] } }
+ let_it_be(:initial_expected_context) { { 'a' => 42, 'b' => 34, 'c' => { 'c1' => 100, 'c2' => 'c2', 'e' => 'e' }, 'd' => [1, 3] } }
+
+ before do
+ subject
+ experiment.record_user_and_group(user, :experimental, {})
+ end
+
+ it 'has an initial context with stringified keys' do
+ expect(ExperimentUser.last.context).to eq(initial_expected_context)
+ end
+
+ context 'when updated' do
+ before do
+ subject
+ experiment.record_user_and_group(user, :experimental, new_context)
+ end
+
+ context 'with an empty context' do
+ let_it_be(:new_context) { {} }
+
+ it 'keeps the initial context' do
+ expect(ExperimentUser.last.context).to eq(initial_expected_context)
+ end
+ end
+
+ context 'with string keys' do
+ let_it_be(:new_context) { { f: :some_symbol } }
+
+ it 'adds new symbols stringified' do
+ expected_context = initial_expected_context.merge('f' => 'some_symbol')
+ expect(ExperimentUser.last.context).to eq(expected_context)
+ end
+ end
+
+ context 'with atomic values or array values' do
+ let_it_be(:new_context) { { b: 97, d: [99] } }
+
+ it 'overrides the values' do
+ expected_context = { 'a' => 42, 'b' => 97, 'c' => { 'c1' => 100, 'c2' => 'c2', 'e' => 'e' }, 'd' => [99] }
+ expect(ExperimentUser.last.context).to eq(expected_context)
+ end
+ end
+
+ context 'with nested hashes' do
+ let_it_be(:new_context) { { c: { g: 107 } } }
+
+ it 'inserts nested additional values in the same keys' do
+ expected_context = initial_expected_context.deep_merge('c' => { 'g' => 107 })
+ expect(ExperimentUser.last.context).to eq(expected_context)
+ end
+ end
+ end
+ end
+
describe '#record_conversion_event_for_user' do
let_it_be(:user) { create(:user) }
let_it_be(:experiment) { create(:experiment) }
+ let_it_be(:context) { { a: 42 } }
- subject(:record_conversion_event_for_user) { experiment.record_conversion_event_for_user(user) }
+ subject { experiment.record_conversion_event_for_user(user, context) }
context 'when no existing experiment_user record exists for the given user' do
it 'does not update or create an experiment_user record' do
- expect { record_conversion_event_for_user }.not_to change { ExperimentUser.all.to_a }
+ expect { subject }.not_to change { ExperimentUser.all.to_a }
end
end
@@ -151,7 +209,13 @@ RSpec.describe Experiment do
let!(:experiment_user) { create(:experiment_user, experiment: experiment, user: user, converted_at: 2.days.ago) }
it 'does not update the converted_at value' do
- expect { record_conversion_event_for_user }.not_to change { experiment_user.converted_at }
+ expect { subject }.not_to change { experiment_user.converted_at }
+ end
+
+ it_behaves_like 'experiment user with context' do
+ before do
+ experiment.record_user_and_group(user, :experimental, context)
+ end
end
end
@@ -159,7 +223,13 @@ RSpec.describe Experiment do
let(:experiment_user) { create(:experiment_user, experiment: experiment, user: user) }
it 'updates the converted_at value' do
- expect { record_conversion_event_for_user }.to change { experiment_user.reload.converted_at }
+ expect { subject }.to change { experiment_user.reload.converted_at }
+ end
+
+ it_behaves_like 'experiment user with context' do
+ before do
+ experiment.record_user_and_group(user, :experimental, context)
+ end
end
end
end
@@ -196,24 +266,25 @@ RSpec.describe Experiment do
describe '#record_user_and_group' do
let_it_be(:experiment) { create(:experiment) }
let_it_be(:user) { create(:user) }
+ let_it_be(:group) { :control }
+ let_it_be(:context) { { a: 42 } }
- let(:group) { :control }
- let(:context) { { a: 42 } }
-
- subject(:record_user_and_group) { experiment.record_user_and_group(user, group, context) }
+ subject { experiment.record_user_and_group(user, group, context) }
context 'when an experiment_user does not yet exist for the given user' do
it 'creates a new experiment_user record' do
- expect { record_user_and_group }.to change(ExperimentUser, :count).by(1)
+ expect { subject }.to change(ExperimentUser, :count).by(1)
end
it 'assigns the correct group_type to the experiment_user' do
- record_user_and_group
+ subject
+
expect(ExperimentUser.last.group_type).to eq('control')
end
it 'adds the correct context to the experiment_user' do
- record_user_and_group
+ subject
+
expect(ExperimentUser.last.context).to eq({ 'a' => 42 })
end
end
@@ -225,72 +296,18 @@ RSpec.describe Experiment do
end
it 'does not create a new experiment_user record' do
- expect { record_user_and_group }.not_to change(ExperimentUser, :count)
+ expect { subject }.not_to change(ExperimentUser, :count)
end
context 'but the group_type and context has changed' do
let(:group) { :experimental }
it 'updates the existing experiment_user record with group_type' do
- expect { record_user_and_group }.to change { ExperimentUser.last.group_type }
+ expect { subject }.to change { ExperimentUser.last.group_type }
end
end
- end
-
- context 'when a context already exists' do
- let_it_be(:context) { { a: 42, 'b' => 34, 'c': { c1: 100, c2: 'c2', e: :e }, d: [1, 3] } }
- let_it_be(:initial_expected_context) { { 'a' => 42, 'b' => 34, 'c' => { 'c1' => 100, 'c2' => 'c2', 'e' => 'e' }, 'd' => [1, 3] } }
-
- before do
- record_user_and_group
- experiment.record_user_and_group(user, :control, {})
- end
-
- it 'has an initial context with stringified keys' do
- expect(ExperimentUser.last.context).to eq(initial_expected_context)
- end
-
- context 'when updated' do
- before do
- record_user_and_group
- experiment.record_user_and_group(user, :control, new_context)
- end
-
- context 'with an empty context' do
- let_it_be(:new_context) { {} }
- it 'keeps the initial context' do
- expect(ExperimentUser.last.context).to eq(initial_expected_context)
- end
- end
-
- context 'with string keys' do
- let_it_be(:new_context) { { f: :some_symbol } }
-
- it 'adds new symbols stringified' do
- expected_context = initial_expected_context.merge('f' => 'some_symbol')
- expect(ExperimentUser.last.context).to eq(expected_context)
- end
- end
-
- context 'with atomic values or array values' do
- let_it_be(:new_context) { { b: 97, d: [99] } }
-
- it 'overrides the values' do
- expected_context = { 'a' => 42, 'b' => 97, 'c' => { 'c1' => 100, 'c2' => 'c2', 'e' => 'e' }, 'd' => [99] }
- expect(ExperimentUser.last.context).to eq(expected_context)
- end
- end
-
- context 'with nested hashes' do
- let_it_be(:new_context) { { c: { g: 107 } } }
-
- it 'inserts nested additional values in the same keys' do
- expected_context = initial_expected_context.deep_merge('c' => { 'g' => 107 })
- expect(ExperimentUser.last.context).to eq(expected_context)
- end
- end
- end
+ it_behaves_like 'experiment user with context'
end
end
end
diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb
index e79b54b4674..24d09d1c035 100644
--- a/spec/models/group_spec.rb
+++ b/spec/models/group_spec.rb
@@ -25,7 +25,6 @@ RSpec.describe Group do
it { is_expected.to have_many(:clusters).class_name('Clusters::Cluster') }
it { is_expected.to have_many(:container_repositories) }
it { is_expected.to have_many(:milestones) }
- it { is_expected.to have_many(:iterations) }
it { is_expected.to have_many(:group_deploy_keys) }
it { is_expected.to have_many(:services) }
it { is_expected.to have_one(:dependency_proxy_setting) }
@@ -65,6 +64,59 @@ RSpec.describe Group do
it { is_expected.to validate_presence_of :two_factor_grace_period }
it { is_expected.to validate_numericality_of(:two_factor_grace_period).is_greater_than_or_equal_to(0) }
+ context 'validating the parent of a group' do
+ context 'when the group has no parent' do
+ it 'allows a group to have no parent associated with it' do
+ group = build(:group)
+
+ expect(group).to be_valid
+ end
+ end
+
+ context 'when the group has a parent' do
+ it 'does not allow a group to have a namespace as its parent' do
+ group = build(:group, parent: build(:namespace))
+
+ expect(group).not_to be_valid
+ expect(group.errors[:parent_id].first).to eq('a group cannot have a user namespace as its parent')
+ end
+
+ it 'allows a group to have another group as its parent' do
+ group = build(:group, parent: build(:group))
+
+ expect(group).to be_valid
+ end
+ end
+
+ context 'when the feature flag `validate_namespace_parent_type` is disabled' do
+ before do
+ stub_feature_flags(validate_namespace_parent_type: false)
+ end
+
+ context 'when the group has no parent' do
+ it 'allows a group to have no parent associated with it' do
+ group = build(:group)
+
+ expect(group).to be_valid
+ end
+ end
+
+ context 'when the group has a parent' do
+ it 'allows a group to have a namespace as its parent' do
+ group = build(:group, parent: build(:namespace))
+
+ expect(group).to be_valid
+ end
+
+ it 'allows a group to have another group as its parent' do
+ group = build(:group, parent: build(:group))
+
+ expect(group).to be_valid
+ end
+ end
+ end
+ end
+
describe 'path validation' do
it 'rejects paths reserved on the root namespace when the group has no parent' do
group = build(:group, path: 'api')
@@ -513,6 +565,42 @@ RSpec.describe Group do
end
end
+ describe '#last_blocked_owner?' do
+ let(:blocked_user) { create(:user, :blocked) }
+
+ before do
+ group.add_user(blocked_user, GroupMember::OWNER)
+ end
+
+ it { expect(group.last_blocked_owner?(blocked_user)).to be_truthy }
+
+ context 'with another active owner' do
+ before do
+ group.add_user(create(:user), GroupMember::OWNER)
+ end
+
+ it { expect(group.last_blocked_owner?(blocked_user)).to be_falsy }
+ end
+
+ context 'with 2 blocked owners' do
+ before do
+ group.add_user(create(:user, :blocked), GroupMember::OWNER)
+ end
+
+ it { expect(group.last_blocked_owner?(blocked_user)).to be_falsy }
+ end
+
+ context 'with owners from a parent' do
+ before do
+ parent_group = create(:group)
+ create(:group_member, :owner, group: parent_group)
+ group.update(parent: parent_group)
+ end
+
+ it { expect(group.last_blocked_owner?(blocked_user)).to be_falsy }
+ end
+ end
+
describe '#lfs_enabled?' do
context 'LFS enabled globally' do
before do
@@ -729,8 +817,16 @@ RSpec.describe Group do
context 'evaluating admin access level' do
let_it_be(:admin) { create(:admin) }
- it 'returns OWNER by default' do
- expect(group.max_member_access_for_user(admin)).to eq(Gitlab::Access::OWNER)
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'returns OWNER by default' do
+ expect(group.max_member_access_for_user(admin)).to eq(Gitlab::Access::OWNER)
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it 'returns NO_ACCESS' do
+ expect(group.max_member_access_for_user(admin)).to eq(Gitlab::Access::NO_ACCESS)
+ end
end
it 'returns NO_ACCESS when only concrete membership should be considered' do
@@ -740,6 +836,33 @@ RSpec.describe Group do
end
end
+ describe '#direct_members' do
+ let_it_be(:group) { create(:group, :nested) }
+ let_it_be(:maintainer) { group.parent.add_user(create(:user), GroupMember::MAINTAINER) }
+ let_it_be(:developer) { group.add_user(create(:user), GroupMember::DEVELOPER) }
+
+ it 'does not return members of the parent' do
+ expect(group.direct_members).not_to include(maintainer)
+ end
+
+ it 'returns the direct member of the group' do
+ expect(group.direct_members).to include(developer)
+ end
+
+ context 'group sharing' do
+ let!(:shared_group) { create(:group) }
+
+ before do
+ create(:group_group_link, shared_group: shared_group, shared_with_group: group)
+ end
+
+ it 'does not return members of the shared_with group' do
+ expect(shared_group.direct_members).not_to(
+ include(developer))
+ end
+ end
+ end
+
describe '#members_with_parents' do
let!(:group) { create(:group, :nested) }
let!(:maintainer) { group.parent.add_user(create(:user), GroupMember::MAINTAINER) }
@@ -932,6 +1055,65 @@ RSpec.describe Group do
end
end
+ describe '#refresh_members_authorized_projects' do
+ let_it_be(:group) { create(:group, :nested) }
+ let_it_be(:parent_group_user) { create(:user) }
+ let_it_be(:group_user) { create(:user) }
+
+ before do
+ group.parent.add_maintainer(parent_group_user)
+ group.add_developer(group_user)
+ end
+
+ context 'users for which authorizations refresh is executed' do
+ it 'processes authorizations refresh for all members of the group' do
+ expect(UserProjectAccessChangedService).to receive(:new).with(contain_exactly(group_user.id, parent_group_user.id)).and_call_original
+
+ group.refresh_members_authorized_projects
+ end
+
+ context 'when explicitly specified to run only for direct members' do
+ it 'processes authorizations refresh only for direct members of the group' do
+ expect(UserProjectAccessChangedService).to receive(:new).with(contain_exactly(group_user.id)).and_call_original
+
+ group.refresh_members_authorized_projects(direct_members_only: true)
+ end
+ end
+ end
+ end
+
+ describe '#users_ids_of_direct_members' do
+ let_it_be(:group) { create(:group, :nested) }
+ let_it_be(:parent_group_user) { create(:user) }
+ let_it_be(:group_user) { create(:user) }
+
+ before do
+ group.parent.add_maintainer(parent_group_user)
+ group.add_developer(group_user)
+ end
+
+ it 'does not return user ids of the members of the parent' do
+ expect(group.users_ids_of_direct_members).not_to include(parent_group_user.id)
+ end
+
+ it 'returns the user ids of the direct member of the group' do
+ expect(group.users_ids_of_direct_members).to include(group_user.id)
+ end
+
+ context 'group sharing' do
+ let!(:shared_group) { create(:group) }
+
+ before do
+ create(:group_group_link, shared_group: shared_group, shared_with_group: group)
+ end
+
+ it 'does not return the user ids of members of the shared_with group' do
+ expect(shared_group.users_ids_of_direct_members).not_to(
+ include(group_user.id))
+ end
+ end
+ end
+
describe '#user_ids_for_project_authorizations' do
it 'returns the user IDs for which to refresh authorizations' do
maintainer = create(:user)
@@ -959,6 +1141,29 @@ RSpec.describe Group do
include(group_user.id))
end
end
+
+ context 'distinct user ids' do
+ let_it_be(:subgroup) { create(:group, :nested) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:shared_with_group) { create(:group) }
+ let_it_be(:other_subgroup_user) { create(:user) }
+
+ before do
+ create(:group_group_link, shared_group: subgroup, shared_with_group: shared_with_group)
+ subgroup.add_maintainer(other_subgroup_user)
+
+ # `user` is added as a direct member of the parent group, the subgroup
+ # and another group shared with the subgroup.
+ subgroup.parent.add_maintainer(user)
+ subgroup.add_developer(user)
+ shared_with_group.add_guest(user)
+ end
+
+ it 'returns only distinct user ids of users for which to refresh authorizations' do
+ expect(subgroup.user_ids_for_project_authorizations).to(
+ contain_exactly(user.id, other_subgroup_user.id))
+ end
+ end
end
describe '#update_two_factor_requirement' do
@@ -1149,9 +1354,10 @@ RSpec.describe Group do
describe '#ci_variables_for' do
let(:project) { create(:project, group: group) }
+ let(:environment_scope) { '*' }
let!(:ci_variable) do
- create(:ci_group_variable, value: 'secret', group: group)
+ create(:ci_group_variable, value: 'secret', group: group, environment_scope: environment_scope)
end
let!(:protected_variable) do
@@ -1160,13 +1366,16 @@ RSpec.describe Group do
subject { group.ci_variables_for('ref', project) }
- it 'memoizes the result by ref', :request_store do
+ it 'memoizes the result by ref and environment', :request_store do
+ scoped_variable = create(:ci_group_variable, value: 'secret', group: group, environment_scope: 'scoped')
+
expect(project).to receive(:protected_for?).with('ref').once.and_return(true)
- expect(project).to receive(:protected_for?).with('other').once.and_return(false)
+ expect(project).to receive(:protected_for?).with('other').twice.and_return(false)
2.times do
- expect(group.ci_variables_for('ref', project)).to contain_exactly(ci_variable, protected_variable)
+ expect(group.ci_variables_for('ref', project, environment: 'production')).to contain_exactly(ci_variable, protected_variable)
expect(group.ci_variables_for('other', project)).to contain_exactly(ci_variable)
+ expect(group.ci_variables_for('other', project, environment: 'scoped')).to contain_exactly(ci_variable, scoped_variable)
end
end
@@ -1203,6 +1412,120 @@ RSpec.describe Group do
it_behaves_like 'ref is protected'
end
+ context 'when environment name is specified' do
+ let(:environment) { 'review/name' }
+
+ subject do
+ group.ci_variables_for('ref', project, environment: environment)
+ end
+
+ context 'when environment scope is exactly matched' do
+ let(:environment_scope) { 'review/name' }
+
+ it { is_expected.to contain_exactly(ci_variable) }
+ end
+
+ context 'when environment scope is matched by wildcard' do
+ let(:environment_scope) { 'review/*' }
+
+ it { is_expected.to contain_exactly(ci_variable) }
+ end
+
+ context 'when environment scope does not match' do
+ let(:environment_scope) { 'review/*/special' }
+
+ it { is_expected.not_to contain_exactly(ci_variable) }
+ end
+
+ context 'when environment scope has _' do
+ let(:environment_scope) { '*_*' }
+
+ it 'does not treat it as wildcard' do
+ is_expected.not_to contain_exactly(ci_variable)
+ end
+
+ context 'when environment name contains underscore' do
+ let(:environment) { 'foo_bar/test' }
+ let(:environment_scope) { 'foo_bar/*' }
+
+ it 'matches literally for _' do
+ is_expected.to contain_exactly(ci_variable)
+ end
+ end
+ end
+
+ # The environment name and scope cannot have % at the moment,
+ # but we're considering relaxing it and we should also make sure
+ # it doesn't break in case some data sneaked in somehow as we're
+ # not checking this integrity in database level.
+ context 'when environment scope has %' do
+ it 'does not treat it as wildcard' do
+ ci_variable.update_attribute(:environment_scope, '*%*')
+
+ is_expected.not_to contain_exactly(ci_variable)
+ end
+
+ context 'when environment name contains a percent' do
+ let(:environment) { 'foo%bar/test' }
+
+ it 'matches literally for %' do
+ ci_variable.update(environment_scope: 'foo%bar/*')
+
+ is_expected.to contain_exactly(ci_variable)
+ end
+ end
+ end
+
+ context 'when variables with the same name have different environment scopes' do
+ let!(:partially_matched_variable) do
+ create(:ci_group_variable,
+ key: ci_variable.key,
+ value: 'partial',
+ environment_scope: 'review/*',
+ group: group)
+ end
+
+ let!(:perfectly_matched_variable) do
+ create(:ci_group_variable,
+ key: ci_variable.key,
+ value: 'prefect',
+ environment_scope: 'review/name',
+ group: group)
+ end
+
+ it 'puts variables matching environment scope more in the end' do
+ is_expected.to eq(
+ [ci_variable,
+ partially_matched_variable,
+ perfectly_matched_variable])
+ end
+ end
+
+ context 'when :scoped_group_variables feature flag is disabled' do
+ before do
+ stub_feature_flags(scoped_group_variables: false)
+ end
+
+ context 'when environment scope is exactly matched' do
+ let(:environment_scope) { 'review/name' }
+
+ it { is_expected.to contain_exactly(ci_variable) }
+ end
+
+ context 'when environment scope is partially matched' do
+ let(:environment_scope) { 'review/*' }
+
+ it { is_expected.to contain_exactly(ci_variable) }
+ end
+
+ context 'when environment scope does not match' do
+ let(:environment_scope) { 'review/*/special' }
+
+ it { is_expected.to contain_exactly(ci_variable) }
+ end
+ end
+ end
+
context 'when group has children' do
let(:group_child) { create(:group, parent: group) }
let(:group_child_2) { create(:group, parent: group_child) }
diff --git a/spec/models/issue_email_participant_spec.rb b/spec/models/issue_email_participant_spec.rb
index f19e65e31f3..09c231bbfda 100644
--- a/spec/models/issue_email_participant_spec.rb
+++ b/spec/models/issue_email_participant_spec.rb
@@ -11,9 +11,14 @@ RSpec.describe IssueEmailParticipant do
subject { build(:issue_email_participant) }
it { is_expected.to validate_presence_of(:issue) }
- it { is_expected.to validate_presence_of(:email) }
- it { is_expected.to validate_uniqueness_of(:email).scoped_to([:issue_id]) }
+ it { is_expected.to validate_uniqueness_of(:email).scoped_to([:issue_id]).ignoring_case_sensitivity }
- it_behaves_like 'an object with RFC3696 compliant email-formated attributes', :email
+ it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :email
+
+ it 'is invalid if the email is nil' do
+ subject.email = nil
+
+ expect(subject).to be_invalid
+ end
end
end
diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb
index 969d897e551..a3e245f4def 100644
--- a/spec/models/issue_spec.rb
+++ b/spec/models/issue_spec.rb
@@ -1258,4 +1258,23 @@ RSpec.describe Issue do
expect { issue.issue_type_supports?(:unkown_feature) }.to raise_error(ArgumentError)
end
end
+
+ describe '#email_participants_emails' do
+ let_it_be(:issue) { create(:issue) }
+
+ it 'returns a list of emails' do
+ participant1 = issue.issue_email_participants.create(email: 'a@gitlab.com')
+ participant2 = issue.issue_email_participants.create(email: 'b@gitlab.com')
+
+ expect(issue.email_participants_emails).to contain_exactly(participant1.email, participant2.email)
+ end
+ end
+
+ describe '#email_participants_downcase' do
+ it 'returns a list of emails with all uppercase letters replaced with their lowercase counterparts' do
+ participant = create(:issue_email_participant, email: 'SomEoNe@ExamPLe.com')
+
+ expect(participant.issue.email_participants_emails_downcase).to match([participant.email.downcase])
+ end
+ end
end
diff --git a/spec/models/iteration_spec.rb b/spec/models/iteration_spec.rb
deleted file mode 100644
index e7ec5de0ef1..00000000000
--- a/spec/models/iteration_spec.rb
+++ /dev/null
@@ -1,335 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Iteration do
- let_it_be(:project) { create(:project) }
- let_it_be(:group) { create(:group) }
-
- describe "#iid" do
- it "is properly scoped on project and group" do
- iteration1 = create(:iteration, :skip_project_validation, project: project)
- iteration2 = create(:iteration, :skip_project_validation, project: project)
- iteration3 = create(:iteration, group: group)
- iteration4 = create(:iteration, group: group)
- iteration5 = create(:iteration, :skip_project_validation, project: project)
-
- want = {
- iteration1: 1,
- iteration2: 2,
- iteration3: 1,
- iteration4: 2,
- iteration5: 3
- }
- got = {
- iteration1: iteration1.iid,
- iteration2: iteration2.iid,
- iteration3: iteration3.iid,
- iteration4: iteration4.iid,
- iteration5: iteration5.iid
- }
- expect(got).to eq(want)
- end
- end
-
- describe '.filter_by_state' do
- let_it_be(:closed_iteration) { create(:iteration, :closed, :skip_future_date_validation, group: group, start_date: 8.days.ago, due_date: 2.days.ago) }
- let_it_be(:started_iteration) { create(:iteration, :started, :skip_future_date_validation, group: group, start_date: 1.day.ago, due_date: 6.days.from_now) }
- let_it_be(:upcoming_iteration) { create(:iteration, :upcoming, group: group, start_date: 1.week.from_now, due_date: 2.weeks.from_now) }
-
- shared_examples_for 'filter_by_state' do
- it 'filters by the given state' do
- expect(described_class.filter_by_state(Iteration.all, state)).to match(expected_iterations)
- end
- end
-
- context 'filtering by closed iterations' do
- it_behaves_like 'filter_by_state' do
- let(:state) { 'closed' }
- let(:expected_iterations) { [closed_iteration] }
- end
- end
-
- context 'filtering by started iterations' do
- it_behaves_like 'filter_by_state' do
- let(:state) { 'started' }
- let(:expected_iterations) { [started_iteration] }
- end
- end
-
- context 'filtering by opened iterations' do
- it_behaves_like 'filter_by_state' do
- let(:state) { 'opened' }
- let(:expected_iterations) { [started_iteration, upcoming_iteration] }
- end
- end
-
- context 'filtering by upcoming iterations' do
- it_behaves_like 'filter_by_state' do
- let(:state) { 'upcoming' }
- let(:expected_iterations) { [upcoming_iteration] }
- end
- end
-
- context 'filtering by "all"' do
- it_behaves_like 'filter_by_state' do
- let(:state) { 'all' }
- let(:expected_iterations) { [closed_iteration, started_iteration, upcoming_iteration] }
- end
- end
-
- context 'filtering by nonexistent filter' do
- it 'raises ArgumentError' do
- expect { described_class.filter_by_state(Iteration.none, 'unknown') }.to raise_error(ArgumentError, 'Unknown state filter: unknown')
- end
- end
- end
-
- context 'Validations' do
- subject { build(:iteration, group: group, start_date: start_date, due_date: due_date) }
-
- describe '#not_belonging_to_project' do
- subject { build(:iteration, project: project, start_date: Time.current, due_date: 1.day.from_now) }
-
- it 'is invalid' do
- expect(subject).not_to be_valid
- expect(subject.errors[:project_id]).to include('is not allowed. We do not currently support project-level iterations')
- end
- end
-
- describe '#dates_do_not_overlap' do
- let_it_be(:existing_iteration) { create(:iteration, group: group, start_date: 4.days.from_now, due_date: 1.week.from_now) }
-
- context 'when no Iteration dates overlap' do
- let(:start_date) { 2.weeks.from_now }
- let(:due_date) { 3.weeks.from_now }
-
- it { is_expected.to be_valid }
- end
-
- context 'when updated iteration dates overlap with its own dates' do
- it 'is valid' do
- existing_iteration.start_date = 5.days.from_now
-
- expect(existing_iteration).to be_valid
- end
- end
-
- context 'when dates overlap' do
- let(:start_date) { 5.days.from_now }
- let(:due_date) { 6.days.from_now }
-
- shared_examples_for 'overlapping dates' do |skip_constraint_test: false|
- context 'when start_date is in range' do
- let(:start_date) { 5.days.from_now }
- let(:due_date) { 3.weeks.from_now }
-
- it 'is not valid' do
- expect(subject).not_to be_valid
- expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
- end
-
- unless skip_constraint_test
- it 'is not valid even if forced' do
- subject.validate # to generate iid/etc
- expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
- end
- end
- end
-
- context 'when end_date is in range' do
- let(:start_date) { Time.current }
- let(:due_date) { 6.days.from_now }
-
- it 'is not valid' do
- expect(subject).not_to be_valid
- expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
- end
-
- unless skip_constraint_test
- it 'is not valid even if forced' do
- subject.validate # to generate iid/etc
- expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
- end
- end
- end
-
- context 'when both overlap' do
- it 'is not valid' do
- expect(subject).not_to be_valid
- expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
- end
-
- unless skip_constraint_test
- it 'is not valid even if forced' do
- subject.validate # to generate iid/etc
- expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
- end
- end
- end
- end
-
- context 'group' do
- it_behaves_like 'overlapping dates' do
- let(:constraint_name) { 'iteration_start_and_due_daterange_group_id_constraint' }
- end
-
- context 'different group' do
- let(:group) { create(:group) }
-
- it { is_expected.to be_valid }
-
- it 'does not trigger exclusion constraints' do
- expect { subject.save! }.not_to raise_exception
- end
- end
-
- context 'sub-group' do
- let(:subgroup) { create(:group, parent: group) }
-
- subject { build(:iteration, group: subgroup, start_date: start_date, due_date: due_date) }
-
- it_behaves_like 'overlapping dates', skip_constraint_test: true
- end
- end
-
- context 'project' do
- let_it_be(:existing_iteration) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
-
- subject { build(:iteration, :skip_project_validation, project: project, start_date: start_date, due_date: due_date) }
-
- it_behaves_like 'overlapping dates' do
- let(:constraint_name) { 'iteration_start_and_due_daterange_project_id_constraint' }
- end
-
- context 'different project' do
- let(:project) { create(:project) }
-
- it { is_expected.to be_valid }
-
- it 'does not trigger exclusion constraints' do
- expect { subject.save! }.not_to raise_exception
- end
- end
-
- context 'in a group' do
- let(:group) { create(:group) }
-
- subject { build(:iteration, group: group, start_date: start_date, due_date: due_date) }
-
- it { is_expected.to be_valid }
-
- it 'does not trigger exclusion constraints' do
- expect { subject.save! }.not_to raise_exception
- end
- end
- end
-
- context 'project in a group' do
- let_it_be(:project) { create(:project, group: create(:group)) }
- let_it_be(:existing_iteration) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
-
- subject { build(:iteration, :skip_project_validation, project: project, start_date: start_date, due_date: due_date) }
-
- it_behaves_like 'overlapping dates' do
- let(:constraint_name) { 'iteration_start_and_due_daterange_project_id_constraint' }
- end
- end
- end
- end
-
- describe '#future_date' do
- context 'when dates are in the future' do
- let(:start_date) { Time.current }
- let(:due_date) { 1.week.from_now }
-
- it { is_expected.to be_valid }
- end
-
- context 'when start_date is in the past' do
- let(:start_date) { 1.week.ago }
- let(:due_date) { 1.week.from_now }
-
- it 'is not valid' do
- expect(subject).not_to be_valid
- expect(subject.errors[:start_date]).to include('cannot be in the past')
- end
- end
-
- context 'when due_date is in the past' do
- let(:start_date) { Time.current }
- let(:due_date) { 1.week.ago }
-
- it 'is not valid' do
- expect(subject).not_to be_valid
- expect(subject.errors[:due_date]).to include('cannot be in the past')
- end
- end
-
- context 'when start_date is over 500 years in the future' do
- let(:start_date) { 501.years.from_now }
- let(:due_date) { Time.current }
-
- it 'is not valid' do
- expect(subject).not_to be_valid
- expect(subject.errors[:start_date]).to include('cannot be more than 500 years in the future')
- end
- end
-
- context 'when due_date is over 500 years in the future' do
- let(:start_date) { Time.current }
- let(:due_date) { 501.years.from_now }
-
- it 'is not valid' do
- expect(subject).not_to be_valid
- expect(subject.errors[:due_date]).to include('cannot be more than 500 years in the future')
- end
- end
- end
- end
-
- context 'time scopes' do
- let_it_be(:project) { create(:project, :empty_repo) }
- let_it_be(:iteration_1) { create(:iteration, :skip_future_date_validation, :skip_project_validation, project: project, start_date: 3.days.ago, due_date: 1.day.from_now) }
- let_it_be(:iteration_2) { create(:iteration, :skip_future_date_validation, :skip_project_validation, project: project, start_date: 10.days.ago, due_date: 4.days.ago) }
- let_it_be(:iteration_3) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
-
- describe 'start_date_passed' do
- it 'returns iterations where start_date is in the past but due_date is in the future' do
- expect(described_class.start_date_passed).to contain_exactly(iteration_1)
- end
- end
-
- describe 'due_date_passed' do
- it 'returns iterations where due date is in the past' do
- expect(described_class.due_date_passed).to contain_exactly(iteration_2)
- end
- end
- end
-
- describe '.within_timeframe' do
- let_it_be(:now) { Time.current }
- let_it_be(:project) { create(:project, :empty_repo) }
- let_it_be(:iteration_1) { create(:iteration, :skip_project_validation, project: project, start_date: now, due_date: 1.day.from_now) }
- let_it_be(:iteration_2) { create(:iteration, :skip_project_validation, project: project, start_date: 2.days.from_now, due_date: 3.days.from_now) }
- let_it_be(:iteration_3) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
-
- it 'returns iterations with start_date and/or end_date between timeframe' do
- iterations = described_class.within_timeframe(2.days.from_now, 3.days.from_now)
-
- expect(iterations).to match_array([iteration_2])
- end
-
- it 'returns iterations which starts before the timeframe' do
- iterations = described_class.within_timeframe(1.day.from_now, 3.days.from_now)
-
- expect(iterations).to match_array([iteration_1, iteration_2])
- end
-
- it 'returns iterations which ends after the timeframe' do
- iterations = described_class.within_timeframe(3.days.from_now, 5.days.from_now)
-
- expect(iterations).to match_array([iteration_2, iteration_3])
- end
- end
-end
diff --git a/spec/models/list_spec.rb b/spec/models/list_spec.rb
index f0b1bc33e84..ad07ee1115b 100644
--- a/spec/models/list_spec.rb
+++ b/spec/models/list_spec.rb
@@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe List do
it_behaves_like 'having unique enum values'
it_behaves_like 'boards listable model', :list
+ it_behaves_like 'list_preferences_for user', :list, :list_id
describe 'relationships' do
it { is_expected.to belong_to(:board) }
@@ -17,70 +18,16 @@ RSpec.describe List do
it { is_expected.to validate_presence_of(:list_type) }
end
- describe '#update_preferences_for' do
- let(:user) { create(:user) }
- let(:list) { create(:list) }
+ describe '.without_types' do
+ it 'exclude lists of given types' do
+ board = create(:list, list_type: :label).board
+ # closed list is created by default
+ backlog_list = create(:list, list_type: :backlog, board: board)
- context 'when user is present' do
- context 'when there are no preferences for user' do
- it 'creates new user preferences' do
- expect { list.update_preferences_for(user, collapsed: true) }.to change { ListUserPreference.count }.by(1)
- expect(list.preferences_for(user).collapsed).to eq(true)
- end
- end
+ exclude_type = [described_class.list_types[:label], described_class.list_types[:closed]]
- context 'when there are preferences for user' do
- it 'updates user preferences' do
- list.update_preferences_for(user, collapsed: false)
-
- expect { list.update_preferences_for(user, collapsed: true) }.not_to change { ListUserPreference.count }
- expect(list.preferences_for(user).collapsed).to eq(true)
- end
- end
-
- context 'when user is nil' do
- it 'does not create user preferences' do
- expect { list.update_preferences_for(nil, collapsed: true) }.not_to change { ListUserPreference.count }
- end
- end
- end
- end
-
- describe '#preferences_for' do
- let(:user) { create(:user) }
- let(:list) { create(:list) }
-
- context 'when user is nil' do
- it 'returns not persisted preferences' do
- preferences = list.preferences_for(nil)
-
- expect(preferences.persisted?).to eq(false)
- expect(preferences.list_id).to eq(list.id)
- expect(preferences.user_id).to be_nil
- end
- end
-
- context 'when a user preference already exists' do
- before do
- list.update_preferences_for(user, collapsed: true)
- end
-
- it 'loads preference for user' do
- preferences = list.preferences_for(user)
-
- expect(preferences).to be_persisted
- expect(preferences.collapsed).to eq(true)
- end
- end
-
- context 'when preferences for user does not exist' do
- it 'returns not persisted preferences' do
- preferences = list.preferences_for(user)
-
- expect(preferences.persisted?).to eq(false)
- expect(preferences.user_id).to eq(user.id)
- expect(preferences.list_id).to eq(list.id)
- end
+ lists = described_class.without_types(exclude_type)
+ expect(lists.where(board: board)).to match_array([backlog_list])
end
end
end
diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb
index b60af7abade..c41f466456f 100644
--- a/spec/models/member_spec.rb
+++ b/spec/models/member_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe Member do
it { is_expected.to allow_value(nil).for(:expires_at) }
end
- it_behaves_like 'an object with email-formated attributes', :invite_email do
+ it_behaves_like 'an object with email-formatted attributes', :invite_email do
subject { build(:project_member) }
end
@@ -130,14 +130,18 @@ RSpec.describe Member do
@maintainer_user = create(:user).tap { |u| project.add_maintainer(u) }
@maintainer = project.members.find_by(user_id: @maintainer_user.id)
- @blocked_user = create(:user).tap do |u|
+ @blocked_maintainer_user = create(:user).tap do |u|
project.add_maintainer(u)
+
+ u.block!
+ end
+ @blocked_developer_user = create(:user).tap do |u|
project.add_developer(u)
u.block!
end
- @blocked_maintainer = project.members.find_by(user_id: @blocked_user.id, access_level: Gitlab::Access::MAINTAINER)
- @blocked_developer = project.members.find_by(user_id: @blocked_user.id, access_level: Gitlab::Access::DEVELOPER)
+ @blocked_maintainer = project.members.find_by(user_id: @blocked_maintainer_user.id, access_level: Gitlab::Access::MAINTAINER)
+ @blocked_developer = project.members.find_by(user_id: @blocked_developer_user.id, access_level: Gitlab::Access::DEVELOPER)
@invited_member = create(:project_member, :developer,
project: project,
@@ -161,7 +165,7 @@ RSpec.describe Member do
describe '.access_for_user_ids' do
it 'returns the right access levels' do
- users = [@owner_user.id, @maintainer_user.id, @blocked_user.id]
+ users = [@owner_user.id, @maintainer_user.id, @blocked_maintainer_user.id]
expected = {
@owner_user.id => Gitlab::Access::OWNER,
@maintainer_user.id => Gitlab::Access::MAINTAINER
@@ -382,6 +386,20 @@ RSpec.describe Member do
it { is_expected.not_to include @member_with_minimal_access }
end
+ describe '.blocked' do
+ subject { described_class.blocked.to_a }
+
+ it { is_expected.not_to include @owner }
+ it { is_expected.not_to include @maintainer }
+ it { is_expected.not_to include @invited_member }
+ it { is_expected.not_to include @accepted_invite_member }
+ it { is_expected.not_to include @requested_member }
+ it { is_expected.not_to include @accepted_request_member }
+ it { is_expected.to include @blocked_maintainer }
+ it { is_expected.to include @blocked_developer }
+ it { is_expected.not_to include @member_with_minimal_access }
+ end
+
describe '.active_without_invites_and_requests' do
subject { described_class.active_without_invites_and_requests.to_a }
@@ -425,12 +443,10 @@ RSpec.describe Member do
end
context 'when admin mode is disabled' do
- # Skipped because `Group#max_member_access_for_user` needs to be migrated to use admin mode
- # https://gitlab.com/gitlab-org/gitlab/-/issues/207950
- xit 'rejects setting members.created_by to the given admin current_user' do
+ it 'rejects setting members.created_by to the given admin current_user' do
member = described_class.add_user(source, user, :maintainer, current_user: admin)
- expect(member.created_by).not_to be_persisted
+ expect(member.created_by).to be_nil
end
end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index ebe2cd2ac03..8c7289adbcc 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -9,8 +9,8 @@ RSpec.describe MergeRequest, factory_default: :keep do
using RSpec::Parameterized::TableSyntax
- let_it_be(:namespace) { create_default(:namespace) }
- let_it_be(:project, refind: true) { create_default(:project, :repository) }
+ let_it_be(:namespace) { create_default(:namespace).freeze }
+ let_it_be(:project, refind: true) { create_default(:project, :repository).freeze }
subject { create(:merge_request) }
@@ -1366,6 +1366,10 @@ RSpec.describe MergeRequest, factory_default: :keep do
it "doesn't detect WIP by default" do
expect(subject.work_in_progress?).to eq false
end
+
+ it "is aliased to #draft?" do
+ expect(subject.method(:work_in_progress?)).to eq(subject.method(:draft?))
+ end
end
describe "#wipless_title" do
@@ -2895,6 +2899,14 @@ RSpec.describe MergeRequest, factory_default: :keep do
expect(subject.mergeable?).to be_truthy
end
+ it 'return true if #mergeable_state? is true and the MR #can_be_merged? is false' do
+ allow(subject).to receive(:mergeable_state?) { true }
+ expect(subject).to receive(:check_mergeability)
+ expect(subject).to receive(:can_be_merged?) { false }
+
+ expect(subject.mergeable?).to be_falsey
+ end
+
context 'with skip_ci_check option' do
before do
allow(subject).to receive_messages(check_mergeability: nil,
@@ -3072,6 +3084,7 @@ RSpec.describe MergeRequest, factory_default: :keep do
where(:status, :public_status) do
'cannot_be_merged_rechecking' | 'checking'
+ 'preparing' | 'checking'
'checking' | 'checking'
'cannot_be_merged' | 'cannot_be_merged'
end
@@ -3082,32 +3095,83 @@ RSpec.describe MergeRequest, factory_default: :keep do
end
describe "#head_pipeline_active? " do
- it do
- is_expected
- .to delegate_method(:active?)
- .to(:head_pipeline)
- .with_prefix
- .with_arguments(allow_nil: true)
+ context 'when project lacks a head_pipeline relation' do
+ before do
+ subject.head_pipeline = nil
+ end
+
+ it 'returns false' do
+ expect(subject.head_pipeline_active?).to be false
+ end
+ end
+
+ context 'when project has a head_pipeline relation' do
+ let(:pipeline) { create(:ci_empty_pipeline) }
+
+ before do
+ allow(subject).to receive(:head_pipeline) { pipeline }
+ end
+
+ it 'accesses the value from the head_pipeline' do
+ expect(subject.head_pipeline)
+ .to receive(:active?)
+
+ subject.head_pipeline_active?
+ end
end
end
describe "#actual_head_pipeline_success? " do
- it do
- is_expected
- .to delegate_method(:success?)
- .to(:actual_head_pipeline)
- .with_prefix
- .with_arguments(allow_nil: true)
+ context 'when project lacks an actual_head_pipeline relation' do
+ before do
+ allow(subject).to receive(:actual_head_pipeline) { nil }
+ end
+
+ it 'returns false' do
+ expect(subject.actual_head_pipeline_success?).to be false
+ end
+ end
+
+ context 'when project has a actual_head_pipeline relation' do
+ let(:pipeline) { create(:ci_empty_pipeline) }
+
+ before do
+ allow(subject).to receive(:actual_head_pipeline) { pipeline }
+ end
+
+ it 'accesses the value from the actual_head_pipeline' do
+ expect(subject.actual_head_pipeline)
+ .to receive(:success?)
+
+ subject.actual_head_pipeline_success?
+ end
end
end
describe "#actual_head_pipeline_active? " do
- it do
- is_expected
- .to delegate_method(:active?)
- .to(:actual_head_pipeline)
- .with_prefix
- .with_arguments(allow_nil: true)
+ context 'when project lacks an actual_head_pipeline relation' do
+ before do
+ allow(subject).to receive(:actual_head_pipeline) { nil }
+ end
+
+ it 'returns false' do
+ expect(subject.actual_head_pipeline_active?).to be false
+ end
+ end
+
+ context 'when project has a actual_head_pipeline relation' do
+ let(:pipeline) { create(:ci_empty_pipeline) }
+
+ before do
+ allow(subject).to receive(:actual_head_pipeline) { pipeline }
+ end
+
+ it 'accesses the value from the actual_head_pipeline' do
+ expect(subject.actual_head_pipeline)
+ .to receive(:active?)
+
+ subject.actual_head_pipeline_active?
+ end
end
end
@@ -3784,6 +3848,87 @@ RSpec.describe MergeRequest, factory_default: :keep do
end
end
+ describe '#use_merge_base_pipeline_for_comparison?' do
+ let(:project) { create(:project, :public, :repository) }
+ let(:merge_request) { create(:merge_request, :with_codequality_reports, source_project: project) }
+
+ subject { merge_request.use_merge_base_pipeline_for_comparison?(service_class) }
+
+ context 'when service class is Ci::CompareCodequalityReportsService' do
+ let(:service_class) { 'Ci::CompareCodequalityReportsService' }
+
+ context 'when feature flag is enabled' do
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(codequality_backend_comparison: false)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
+ context 'when service class is different' do
+ let(:service_class) { 'Ci::GenerateCoverageReportsService' }
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
+ describe '#comparison_base_pipeline' do
+ subject(:pipeline) { merge_request.comparison_base_pipeline(service_class) }
+
+ let(:project) { create(:project, :public, :repository) }
+ let(:merge_request) { create(:merge_request, :with_codequality_reports, source_project: project) }
+ let!(:base_pipeline) do
+ create(:ci_pipeline,
+ :with_test_reports,
+ project: project,
+ ref: merge_request.target_branch,
+ sha: merge_request.diff_base_sha
+ )
+ end
+
+ context 'when service class is Ci::CompareCodequalityReportsService' do
+ let(:service_class) { 'Ci::CompareCodequalityReportsService' }
+
+ context 'when merge request has a merge request pipeline' do
+ let(:merge_request) do
+ create(:merge_request, :with_merge_request_pipeline)
+ end
+
+ let(:merge_base_pipeline) do
+ create(:ci_pipeline, ref: merge_request.target_branch, sha: merge_request.target_branch_sha)
+ end
+
+ before do
+ merge_base_pipeline
+ merge_request.update_head_pipeline
+ end
+
+ it 'returns the merge_base_pipeline' do
+ expect(pipeline).to eq(merge_base_pipeline)
+ end
+ end
+
+ context 'when merge does not have a merge request pipeline' do
+ it 'returns the base_pipeline' do
+ expect(pipeline).to eq(base_pipeline)
+ end
+ end
+ end
+
+ context 'when service_class is different' do
+ let(:service_class) { 'Ci::GenerateCoverageReportsService' }
+
+ it 'returns the base_pipeline' do
+ expect(pipeline).to eq(base_pipeline)
+ end
+ end
+ end
+
describe '#base_pipeline' do
let(:pipeline_arguments) do
{
@@ -3963,6 +4108,65 @@ RSpec.describe MergeRequest, factory_default: :keep do
end
end
+ describe '#mark_as_unchecked' do
+ subject { create(:merge_request, source_project: project, merge_status: merge_status) }
+
+ shared_examples 'for an invalid state transition' do
+ it 'is not a valid state transition' do
+ expect { subject.mark_as_unchecked! }.to raise_error(StateMachines::InvalidTransition)
+ end
+ end
+
+ shared_examples 'for an valid state transition' do
+ it 'is a valid state transition' do
+ expect { subject.mark_as_unchecked! }
+ .to change { subject.merge_status }
+ .from(merge_status.to_s)
+ .to(expected_merge_status)
+ end
+ end
+
+ context 'when the status is unchecked' do
+ let(:merge_status) { :unchecked }
+
+ include_examples 'for an invalid state transition'
+ end
+
+ context 'when the status is checking' do
+ let(:merge_status) { :checking }
+ let(:expected_merge_status) { 'unchecked' }
+
+ include_examples 'for an valid state transition'
+ end
+
+ context 'when the status is can_be_merged' do
+ let(:merge_status) { :can_be_merged }
+ let(:expected_merge_status) { 'unchecked' }
+
+ include_examples 'for an valid state transition'
+ end
+
+ context 'when the status is cannot_be_merged_recheck' do
+ let(:merge_status) { :cannot_be_merged_recheck }
+
+ include_examples 'for an invalid state transition'
+ end
+
+ context 'when the status is cannot_be_merged' do
+ let(:merge_status) { :cannot_be_merged }
+ let(:expected_merge_status) { 'cannot_be_merged_recheck' }
+
+ include_examples 'for an valid state transition'
+ end
+
+ context 'when the status is cannot_be_merged' do
+ let(:merge_status) { :cannot_be_merged }
+ let(:expected_merge_status) { 'cannot_be_merged_recheck' }
+
+ include_examples 'for an valid state transition'
+ end
+ end
+
describe 'transition to cannot_be_merged' do
let(:notification_service) { double(:notification_service) }
let(:todo_service) { double(:todo_service) }
@@ -4661,4 +4865,33 @@ RSpec.describe MergeRequest, factory_default: :keep do
end
end
end
+
+ describe '#includes_ci_config?' do
+ let(:merge_request) { build(:merge_request) }
+ let(:project) { merge_request.project }
+
+ subject(:result) { merge_request.includes_ci_config? }
+
+ before do
+ allow(merge_request).to receive(:diff_stats).and_return(diff_stats)
+ end
+
+ context 'when diff_stats is nil' do
+ let(:diff_stats) {}
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when diff_stats does not include the ci config path of the project' do
+ let(:diff_stats) { [double(path: 'abc.txt')] }
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when diff_stats includes the ci config path of the project' do
+ let(:diff_stats) { [double(path: '.gitlab-ci.yml')] }
+
+ it { is_expected.to eq(true) }
+ end
+ end
end
diff --git a/spec/models/namespace/traversal_hierarchy_spec.rb b/spec/models/namespace/traversal_hierarchy_spec.rb
index 71b0e974106..83e6d704640 100644
--- a/spec/models/namespace/traversal_hierarchy_spec.rb
+++ b/spec/models/namespace/traversal_hierarchy_spec.rb
@@ -3,41 +3,41 @@
require 'spec_helper'
RSpec.describe Namespace::TraversalHierarchy, type: :model do
- let_it_be(:root, reload: true) { create(:namespace, :with_hierarchy) }
+ let_it_be(:root, reload: true) { create(:group, :with_hierarchy) }
describe '.for_namespace' do
- let(:hierarchy) { described_class.for_namespace(namespace) }
+ let(:hierarchy) { described_class.for_namespace(group) }
context 'with root group' do
- let(:namespace) { root }
+ let(:group) { root }
it { expect(hierarchy.root).to eq root }
end
context 'with child group' do
- let(:namespace) { root.children.first.children.first }
+ let(:group) { root.children.first.children.first }
it { expect(hierarchy.root).to eq root }
end
context 'with group outside of hierarchy' do
- let(:namespace) { create(:namespace) }
+ let(:group) { create(:namespace) }
it { expect(hierarchy.root).not_to eq root }
end
end
describe '.new' do
- let(:hierarchy) { described_class.new(namespace) }
+ let(:hierarchy) { described_class.new(group) }
context 'with root group' do
- let(:namespace) { root }
+ let(:group) { root }
it { expect(hierarchy.root).to eq root }
end
context 'with child group' do
- let(:namespace) { root.children.first }
+ let(:group) { root.children.first }
it { expect { hierarchy }.to raise_error(StandardError, 'Must specify a root node') }
end
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index 647e279bf83..65d787d334b 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Namespace do
include ProjectForksHelper
include GitHelpers
- let!(:namespace) { create(:namespace) }
+ let!(:namespace) { create(:namespace, :with_namespace_settings) }
let(:gitlab_shell) { Gitlab::Shell.new }
let(:repository_storage) { 'default' }
@@ -32,14 +32,68 @@ RSpec.describe Namespace do
it { is_expected.to validate_presence_of(:owner) }
it { is_expected.to validate_numericality_of(:max_artifacts_size).only_integer.is_greater_than(0) }
+ context 'validating the parent of a namespace' do
+ context 'when the namespace has no parent' do
+ it 'allows a namespace to have no parent associated with it' do
+ namespace = build(:namespace)
+
+ expect(namespace).to be_valid
+ end
+ end
+
+ context 'when the namespace has a parent' do
+ it 'does not allow a namespace to have a group as its parent' do
+ namespace = build(:namespace, parent: build(:group))
+
+ expect(namespace).not_to be_valid
+ expect(namespace.errors[:parent_id].first).to eq('a user namespace cannot have a parent')
+ end
+
+ it 'does not allow a namespace to have another namespace as its parent' do
+ namespace = build(:namespace, parent: build(:namespace))
+
+ expect(namespace).not_to be_valid
+ expect(namespace.errors[:parent_id].first).to eq('a user namespace cannot have a parent')
+ end
+ end
+
+ context 'when the feature flag `validate_namespace_parent_type` is disabled' do
+ before do
+ stub_feature_flags(validate_namespace_parent_type: false)
+ end
+
+ context 'when the namespace has no parent' do
+ it 'allows a namespace to have no parent associated with it' do
+ namespace = build(:namespace)
+
+ expect(namespace).to be_valid
+ end
+ end
+
+ context 'when the namespace has a parent' do
+ it 'allows a namespace to have a group as its parent' do
+ namespace = build(:namespace, parent: build(:group))
+
+ expect(namespace).to be_valid
+ end
+
+ it 'allows a namespace to have another namespace as its parent' do
+ namespace = build(:namespace, parent: build(:namespace))
+
+ expect(namespace).to be_valid
+ end
+ end
+ end
+ end
+
it 'does not allow too deep nesting' do
ancestors = (1..21).to_a
- nested = build(:namespace, parent: namespace)
+ group = build(:group)
- allow(nested).to receive(:ancestors).and_return(ancestors)
+ allow(group).to receive(:ancestors).and_return(ancestors)
- expect(nested).not_to be_valid
- expect(nested.errors[:parent_id].first).to eq('has too deep level of nesting')
+ expect(group).not_to be_valid
+ expect(group.errors[:parent_id].first).to eq('has too deep level of nesting')
end
describe 'reserved path validation' do
@@ -116,6 +170,28 @@ RSpec.describe Namespace do
it { is_expected.to include_module(Namespaces::Traversal::Recursive) }
end
+ describe 'callbacks' do
+ describe 'before_save :ensure_delayed_project_removal_assigned_to_namespace_settings' do
+ it 'sets the matching value in namespace_settings' do
+ expect { namespace.update!(delayed_project_removal: true) }.to change {
+ namespace.namespace_settings.delayed_project_removal
+ }.from(false).to(true)
+ end
+
+ context 'when the feature flag is disabled' do
+ before do
+ stub_feature_flags(migrate_delayed_project_removal: false)
+ end
+
+ it 'does not set the matching value in namespace_settings' do
+ expect { namespace.update!(delayed_project_removal: true) }.not_to change {
+ namespace.namespace_settings.delayed_project_removal
+ }
+ end
+ end
+ end
+ end
+
describe '#visibility_level_field' do
it { expect(namespace.visibility_level_field).to eq(:visibility_level) }
end
@@ -150,45 +226,45 @@ RSpec.describe Namespace do
end
describe '.search' do
- let_it_be(:first_namespace) { build(:namespace, name: 'my first namespace', path: 'old-path').tap(&:save!) }
- let_it_be(:parent_namespace) { build(:namespace, name: 'my parent namespace', path: 'parent-path').tap(&:save!) }
- let_it_be(:second_namespace) { build(:namespace, name: 'my second namespace', path: 'new-path', parent: parent_namespace).tap(&:save!) }
- let_it_be(:project_with_same_path) { create(:project, id: second_namespace.id, path: first_namespace.path) }
+ let_it_be(:first_group) { build(:group, name: 'my first namespace', path: 'old-path').tap(&:save!) }
+ let_it_be(:parent_group) { build(:group, name: 'my parent namespace', path: 'parent-path').tap(&:save!) }
+ let_it_be(:second_group) { build(:group, name: 'my second namespace', path: 'new-path', parent: parent_group).tap(&:save!) }
+ let_it_be(:project_with_same_path) { create(:project, id: second_group.id, path: first_group.path) }
it 'returns namespaces with a matching name' do
- expect(described_class.search('my first namespace')).to eq([first_namespace])
+ expect(described_class.search('my first namespace')).to eq([first_group])
end
it 'returns namespaces with a partially matching name' do
- expect(described_class.search('first')).to eq([first_namespace])
+ expect(described_class.search('first')).to eq([first_group])
end
it 'returns namespaces with a matching name regardless of the casing' do
- expect(described_class.search('MY FIRST NAMESPACE')).to eq([first_namespace])
+ expect(described_class.search('MY FIRST NAMESPACE')).to eq([first_group])
end
it 'returns namespaces with a matching path' do
- expect(described_class.search('old-path')).to eq([first_namespace])
+ expect(described_class.search('old-path')).to eq([first_group])
end
it 'returns namespaces with a partially matching path' do
- expect(described_class.search('old')).to eq([first_namespace])
+ expect(described_class.search('old')).to eq([first_group])
end
it 'returns namespaces with a matching path regardless of the casing' do
- expect(described_class.search('OLD-PATH')).to eq([first_namespace])
+ expect(described_class.search('OLD-PATH')).to eq([first_group])
end
it 'returns namespaces with a matching route path' do
- expect(described_class.search('parent-path/new-path', include_parents: true)).to eq([second_namespace])
+ expect(described_class.search('parent-path/new-path', include_parents: true)).to eq([second_group])
end
it 'returns namespaces with a partially matching route path' do
- expect(described_class.search('parent-path/new', include_parents: true)).to eq([second_namespace])
+ expect(described_class.search('parent-path/new', include_parents: true)).to eq([second_group])
end
it 'returns namespaces with a matching route path regardless of the casing' do
- expect(described_class.search('PARENT-PATH/NEW-PATH', include_parents: true)).to eq([second_namespace])
+ expect(described_class.search('PARENT-PATH/NEW-PATH', include_parents: true)).to eq([second_group])
end
end
@@ -285,6 +361,18 @@ RSpec.describe Namespace do
end
end
+ describe '.top_most' do
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:subgroup) { create(:group, parent: group) }
+
+ subject { described_class.top_most.ids }
+
+ it 'only contains root namespaces' do
+ is_expected.to contain_exactly(group.id, namespace.id)
+ end
+ end
+
describe '#ancestors_upto' do
let(:parent) { create(:group) }
let(:child) { create(:group, parent: parent) }
@@ -800,7 +888,7 @@ RSpec.describe Namespace do
end
describe '#all_projects' do
- shared_examples 'all projects for a group' do
+ context 'when namespace is a group' do
let(:namespace) { create(:group) }
let(:child) { create(:group, parent: namespace) }
let!(:project1) { create(:project_empty_repo, namespace: namespace) }
@@ -808,49 +896,39 @@ RSpec.describe Namespace do
it { expect(namespace.all_projects.to_a).to match_array([project2, project1]) }
it { expect(child.all_projects.to_a).to match_array([project2]) }
- end
-
- shared_examples 'all projects for personal namespace' do
- let_it_be(:user) { create(:user) }
- let_it_be(:user_namespace) { create(:namespace, owner: user) }
- let_it_be(:project) { create(:project, namespace: user_namespace) }
- it { expect(user_namespace.all_projects.to_a).to match_array([project]) }
- end
-
- context 'with recursive approach' do
- context 'when namespace is a group' do
- include_examples 'all projects for a group'
+ context 'when recursive_namespace_lookup_as_inner_join feature flag is on' do
+ before do
+ stub_feature_flags(recursive_namespace_lookup_as_inner_join: true)
+ end
it 'queries for the namespace and its descendants' do
- expect(Project).to receive(:where).with(namespace: [namespace, child])
-
- namespace.all_projects
+ expect(namespace.all_projects).to match_array([project1, project2])
end
end
- context 'when namespace is a user namespace' do
- include_examples 'all projects for personal namespace'
-
- it 'only queries for the namespace itself' do
- expect(Project).to receive(:where).with(namespace: user_namespace)
+ context 'when recursive_namespace_lookup_as_inner_join feature flag is off' do
+ before do
+ stub_feature_flags(recursive_namespace_lookup_as_inner_join: false)
+ end
- user_namespace.all_projects
+ it 'queries for the namespace and its descendants' do
+ expect(namespace.all_projects).to match_array([project1, project2])
end
end
end
- context 'with route path wildcard approach' do
- before do
- stub_feature_flags(recursive_approach_for_all_projects: false)
- end
+ context 'when namespace is a user namespace' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:user_namespace) { create(:namespace, owner: user) }
+ let_it_be(:project) { create(:project, namespace: user_namespace) }
- context 'when namespace is a group' do
- include_examples 'all projects for a group'
- end
+ it { expect(user_namespace.all_projects.to_a).to match_array([project]) }
+
+ it 'only queries for the namespace itself' do
+ expect(Project).to receive(:where).with(namespace: user_namespace)
- context 'when namespace is a user namespace' do
- include_examples 'all projects for personal namespace'
+ user_namespace.all_projects
end
end
end
@@ -1250,14 +1328,14 @@ RSpec.describe Namespace do
using RSpec::Parameterized::TableSyntax
shared_examples_for 'fetching closest setting' do
- let!(:root_namespace) { create(:namespace) }
- let!(:namespace) { create(:namespace, parent: root_namespace) }
+ let!(:parent) { create(:group) }
+ let!(:group) { create(:group, parent: parent) }
- let(:setting) { namespace.closest_setting(setting_name) }
+ let(:setting) { group.closest_setting(setting_name) }
before do
- root_namespace.update_attribute(setting_name, root_setting)
- namespace.update_attribute(setting_name, child_setting)
+ parent.update_attribute(setting_name, root_setting)
+ group.update_attribute(setting_name, child_setting)
end
it 'returns closest non-nil value' do
@@ -1348,30 +1426,30 @@ RSpec.describe Namespace do
context 'with a parent' do
context 'when parent has shared runners disabled' do
- let(:parent) { create(:namespace, :shared_runners_disabled) }
- let(:sub_namespace) { build(:namespace, shared_runners_enabled: true, parent_id: parent.id) }
+ let(:parent) { create(:group, :shared_runners_disabled) }
+ let(:group) { build(:group, shared_runners_enabled: true, parent_id: parent.id) }
it 'is invalid' do
- expect(sub_namespace).to be_invalid
- expect(sub_namespace.errors[:shared_runners_enabled]).to include('cannot be enabled because parent group has shared Runners disabled')
+ expect(group).to be_invalid
+ expect(group.errors[:shared_runners_enabled]).to include('cannot be enabled because parent group has shared Runners disabled')
end
end
context 'when parent has shared runners disabled but allows override' do
- let(:parent) { create(:namespace, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners) }
- let(:sub_namespace) { build(:namespace, shared_runners_enabled: true, parent_id: parent.id) }
+ let(:parent) { create(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners) }
+ let(:group) { build(:group, shared_runners_enabled: true, parent_id: parent.id) }
it 'is valid' do
- expect(sub_namespace).to be_valid
+ expect(group).to be_valid
end
end
context 'when parent has shared runners enabled' do
- let(:parent) { create(:namespace, shared_runners_enabled: true) }
- let(:sub_namespace) { build(:namespace, shared_runners_enabled: true, parent_id: parent.id) }
+ let(:parent) { create(:group, shared_runners_enabled: true) }
+ let(:group) { build(:group, shared_runners_enabled: true, parent_id: parent.id) }
it 'is valid' do
- expect(sub_namespace).to be_valid
+ expect(group).to be_valid
end
end
end
@@ -1401,30 +1479,30 @@ RSpec.describe Namespace do
context 'with a parent' do
context 'when parent does not allow shared runners' do
- let(:parent) { create(:namespace, :shared_runners_disabled) }
- let(:sub_namespace) { build(:namespace, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent_id: parent.id) }
+ let(:parent) { create(:group, :shared_runners_disabled) }
+ let(:group) { build(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent_id: parent.id) }
it 'is invalid' do
- expect(sub_namespace).to be_invalid
- expect(sub_namespace.errors[:allow_descendants_override_disabled_shared_runners]).to include('cannot be enabled because parent group does not allow it')
+ expect(group).to be_invalid
+ expect(group.errors[:allow_descendants_override_disabled_shared_runners]).to include('cannot be enabled because parent group does not allow it')
end
end
context 'when parent allows shared runners and setting to true' do
- let(:parent) { create(:namespace, shared_runners_enabled: true) }
- let(:sub_namespace) { build(:namespace, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent_id: parent.id) }
+ let(:parent) { create(:group, shared_runners_enabled: true) }
+ let(:group) { build(:group, :shared_runners_disabled, :allow_descendants_override_disabled_shared_runners, parent_id: parent.id) }
it 'is valid' do
- expect(sub_namespace).to be_valid
+ expect(group).to be_valid
end
end
context 'when parent allows shared runners and setting to false' do
- let(:parent) { create(:namespace, shared_runners_enabled: true) }
- let(:sub_namespace) { build(:namespace, :shared_runners_disabled, allow_descendants_override_disabled_shared_runners: false, parent_id: parent.id) }
+ let(:parent) { create(:group, shared_runners_enabled: true) }
+ let(:group) { build(:group, :shared_runners_disabled, allow_descendants_override_disabled_shared_runners: false, parent_id: parent.id) }
it 'is valid' do
- expect(sub_namespace).to be_valid
+ expect(group).to be_valid
end
end
end
@@ -1449,4 +1527,24 @@ RSpec.describe Namespace do
end
end
end
+
+ describe '#recent?' do
+ subject { namespace.recent? }
+
+ context 'when created more than 90 days ago' do
+ before do
+ namespace.update_attribute(:created_at, 91.days.ago)
+ end
+
+ it { is_expected.to be(false) }
+ end
+
+ context 'when created less than 90 days ago' do
+ before do
+ namespace.update_attribute(:created_at, 89.days.ago)
+ end
+
+ it { is_expected.to be(true) }
+ end
+ end
end
diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb
index 364b80e8601..590acfc0ac1 100644
--- a/spec/models/note_spec.rb
+++ b/spec/models/note_spec.rb
@@ -336,6 +336,25 @@ RSpec.describe Note do
end
end
+ describe "last_edited_at" do
+ let(:timestamp) { Time.current }
+ let(:note) { build(:note, last_edited_at: nil, created_at: timestamp, updated_at: timestamp + 5.hours) }
+
+ context "with last_edited_at" do
+ it "returns last_edited_at" do
+ note.last_edited_at = timestamp
+
+ expect(note.last_edited_at).to eq(timestamp)
+ end
+ end
+
+ context "without last_edited_at" do
+ it "returns updated_at" do
+ expect(note.last_edited_at).to eq(timestamp + 5.hours)
+ end
+ end
+ end
+
describe "edited?" do
let(:note) { build(:note, updated_by_id: nil, created_at: Time.current, updated_at: Time.current + 5.hours) }
diff --git a/spec/models/notification_recipient_spec.rb b/spec/models/notification_recipient_spec.rb
index 8429f577dc6..4debda0621c 100644
--- a/spec/models/notification_recipient_spec.rb
+++ b/spec/models/notification_recipient_spec.rb
@@ -337,6 +337,39 @@ RSpec.describe NotificationRecipient do
expect(recipient.suitable_notification_level?).to eq true
end
end
+
+ context 'with merge_when_pipeline_succeeds' do
+ let(:notification_setting) { user.notification_settings_for(project) }
+ let(:recipient) do
+ described_class.new(
+ user,
+ :watch,
+ custom_action: :merge_when_pipeline_succeeds,
+ target: target,
+ project: project
+ )
+ end
+
+ context 'custom event enabled' do
+ before do
+ notification_setting.update!(merge_when_pipeline_succeeds: true)
+ end
+
+ it 'returns true' do
+ expect(recipient.suitable_notification_level?).to eq true
+ end
+ end
+
+ context 'custom event disabled' do
+ before do
+ notification_setting.update!(merge_when_pipeline_succeeds: false)
+ end
+
+ it 'returns false' do
+ expect(recipient.suitable_notification_level?).to eq false
+ end
+ end
+ end
end
end
diff --git a/spec/models/notification_setting_spec.rb b/spec/models/notification_setting_spec.rb
index bc50e2af373..4ef5ab7af48 100644
--- a/spec/models/notification_setting_spec.rb
+++ b/spec/models/notification_setting_spec.rb
@@ -180,7 +180,8 @@ RSpec.describe NotificationSetting do
:failed_pipeline,
:success_pipeline,
:fixed_pipeline,
- :moved_project
+ :moved_project,
+ :merge_when_pipeline_succeeds
)
end
diff --git a/spec/models/onboarding_progress_spec.rb b/spec/models/onboarding_progress_spec.rb
index 0aa19345a25..779312c9fa0 100644
--- a/spec/models/onboarding_progress_spec.rb
+++ b/spec/models/onboarding_progress_spec.rb
@@ -106,7 +106,7 @@ RSpec.describe OnboardingProgress do
end
context 'when not given a root namespace' do
- let(:namespace) { create(:namespace, parent: build(:namespace)) }
+ let(:namespace) { create(:group, parent: build(:group)) }
it 'does not add a record for the namespace' do
expect { onboard }.not_to change(described_class, :count).from(0)
@@ -182,6 +182,30 @@ RSpec.describe OnboardingProgress do
end
end
+ describe '.not_completed?' do
+ subject { described_class.not_completed?(namespace.id, action) }
+
+ context 'when the namespace has not yet been onboarded' do
+ it { is_expected.to be(false) }
+ end
+
+ context 'when the namespace has been onboarded but not registered the action yet' do
+ before do
+ described_class.onboard(namespace)
+ end
+
+ it { is_expected.to be(true) }
+
+ context 'when the action has been registered' do
+ before do
+ described_class.register(namespace, action)
+ end
+
+ it { is_expected.to be(false) }
+ end
+ end
+ end
+
describe '.column_name' do
subject { described_class.column_name(action) }
diff --git a/spec/models/packages/maven/metadatum_spec.rb b/spec/models/packages/maven/metadatum_spec.rb
index 16f6929d710..94a0e558985 100644
--- a/spec/models/packages/maven/metadatum_spec.rb
+++ b/spec/models/packages/maven/metadatum_spec.rb
@@ -36,5 +36,38 @@ RSpec.describe Packages::Maven::Metadatum, type: :model do
expect(maven_metadatum.errors.to_a).to include('Package type must be Maven')
end
end
+
+ context 'with a package' do
+ let_it_be(:package) { create(:package) }
+
+ describe '.for_package_ids' do
+ let_it_be(:metadata) { create_list(:maven_metadatum, 3, package: package) }
+
+ subject { Packages::Maven::Metadatum.for_package_ids(package.id) }
+
+ it { is_expected.to match_array(metadata) }
+ end
+
+ describe '.order_created' do
+ let_it_be(:metadatum1) { create(:maven_metadatum, package: package) }
+ let_it_be(:metadatum2) { create(:maven_metadatum, package: package) }
+ let_it_be(:metadatum3) { create(:maven_metadatum, package: package) }
+ let_it_be(:metadatum4) { create(:maven_metadatum, package: package) }
+
+ subject { Packages::Maven::Metadatum.for_package_ids(package.id).order_created }
+
+ it { is_expected.to eq([metadatum1, metadatum2, metadatum3, metadatum4]) }
+ end
+
+ describe '.pluck_app_name' do
+ let_it_be(:metadatum1) { create(:maven_metadatum, package: package, app_name: 'one') }
+ let_it_be(:metadatum2) { create(:maven_metadatum, package: package, app_name: 'two') }
+ let_it_be(:metadatum3) { create(:maven_metadatum, package: package, app_name: 'three') }
+
+ subject { Packages::Maven::Metadatum.for_package_ids(package.id).pluck_app_name }
+
+ it { is_expected.to match_array([metadatum1, metadatum2, metadatum3].map(&:app_name)) }
+ end
+ end
end
end
diff --git a/spec/models/packages/package_file_spec.rb b/spec/models/packages/package_file_spec.rb
index ebb10e991ad..9cf998a0639 100644
--- a/spec/models/packages/package_file_spec.rb
+++ b/spec/models/packages/package_file_spec.rb
@@ -62,6 +62,21 @@ RSpec.describe Packages::PackageFile, type: :model do
end
end
+ describe '.for_rubygem_with_file_name' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:non_ruby_package) { create(:nuget_package, project: project, package_type: :nuget) }
+ let_it_be(:ruby_package) { create(:rubygems_package, project: project, package_type: :rubygems) }
+ let_it_be(:file_name) { 'other.gem' }
+
+ let_it_be(:non_ruby_file) { create(:package_file, :nuget, package: non_ruby_package, file_name: file_name) }
+ let_it_be(:gem_file1) { create(:package_file, :gem, package: ruby_package) }
+ let_it_be(:gem_file2) { create(:package_file, :gem, package: ruby_package, file_name: file_name) }
+
+ it 'returns the matching gem file only for ruby packages' do
+ expect(described_class.for_rubygem_with_file_name(project, file_name)).to contain_exactly(gem_file2)
+ end
+ end
+
describe '#update_file_store callback' do
let_it_be(:package_file) { build(:package_file, :nuget, size: nil) }
diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb
index 6c55d37b95f..82997acee3f 100644
--- a/spec/models/packages/package_spec.rb
+++ b/spec/models/packages/package_spec.rb
@@ -22,6 +22,14 @@ RSpec.describe Packages::Package, type: :model do
it { is_expected.to have_one(:rubygems_metadatum).inverse_of(:package) }
end
+ describe '.with_debian_codename' do
+ let_it_be(:publication) { create(:debian_publication) }
+
+ subject { described_class.with_debian_codename(publication.distribution.codename).to_a }
+
+ it { is_expected.to contain_exactly(publication.package) }
+ end
+
describe '.with_composer_target' do
let!(:package1) { create(:composer_package, :with_metadatum, sha: '123') }
let!(:package2) { create(:composer_package, :with_metadatum, sha: '123') }
@@ -162,6 +170,18 @@ RSpec.describe Packages::Package, type: :model do
it { is_expected.not_to allow_value('../../../my_package').for(:name) }
it { is_expected.not_to allow_value('%2e%2e%2fmy_package').for(:name) }
end
+
+ context 'npm package' do
+ subject { build_stubbed(:npm_package) }
+
+ it { is_expected.to allow_value("@group-1/package").for(:name) }
+ it { is_expected.to allow_value("@any-scope/package").for(:name) }
+ it { is_expected.to allow_value("unscoped-package").for(:name) }
+ it { is_expected.not_to allow_value("@inv@lid-scope/package").for(:name) }
+ it { is_expected.not_to allow_value("@scope/../../package").for(:name) }
+ it { is_expected.not_to allow_value("@scope%2e%2e%fpackage").for(:name) }
+ it { is_expected.not_to allow_value("@scope/sub/package").for(:name) }
+ end
end
describe '#version' do
@@ -342,16 +362,6 @@ RSpec.describe Packages::Package, type: :model do
end
describe '#package_already_taken' do
- context 'npm package' do
- let!(:package) { create(:npm_package) }
-
- it 'will not allow a package of the same name' do
- new_package = build(:npm_package, project: create(:project), name: package.name)
-
- expect(new_package).not_to be_valid
- end
- end
-
context 'maven package' do
let!(:package) { create(:maven_package) }
@@ -511,7 +521,7 @@ RSpec.describe Packages::Package, type: :model do
describe '.without_nuget_temporary_name' do
let!(:package1) { create(:nuget_package) }
- let!(:package2) { create(:nuget_package, name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) }
+ let!(:package2) { create(:nuget_package, name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) }
subject { described_class.without_nuget_temporary_name }
@@ -530,7 +540,7 @@ RSpec.describe Packages::Package, type: :model do
it { is_expected.to match_array([package1, package2, package3]) }
context 'with temporary packages' do
- let!(:package1) { create(:nuget_package, name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) }
+ let!(:package1) { create(:nuget_package, name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) }
it { is_expected.to match_array([package2, package3]) }
end
@@ -803,4 +813,63 @@ RSpec.describe Packages::Package, type: :model do
expect(package.package_settings).to eq(group.package_settings)
end
end
+
+ describe '#sync_maven_metadata' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:package) { create(:maven_package) }
+
+ subject { package.sync_maven_metadata(user) }
+
+ shared_examples 'not enqueuing a sync worker job' do
+ it 'does not enqueue a sync worker job' do
+ expect(::Packages::Maven::Metadata::SyncWorker)
+ .not_to receive(:perform_async)
+
+ subject
+ end
+ end
+
+ it 'enqueues a sync worker job' do
+ expect(::Packages::Maven::Metadata::SyncWorker)
+ .to receive(:perform_async).with(user.id, package.project.id, package.name)
+
+ subject
+ end
+
+ context 'with no user' do
+ let(:user) { nil }
+
+ it_behaves_like 'not enqueuing a sync worker job'
+ end
+
+ context 'with a versionless maven package' do
+ let_it_be(:package) { create(:maven_package, version: nil) }
+
+ it_behaves_like 'not enqueuing a sync worker job'
+ end
+
+ context 'with a non maven package' do
+ let_it_be(:package) { create(:npm_package) }
+
+ it_behaves_like 'not enqueuing a sync worker job'
+ end
+ end
+
+ context 'destroying a composer package' do
+ let_it_be(:package_name) { 'composer-package-name' }
+ let_it_be(:json) { { 'name' => package_name } }
+ let_it_be(:project) { create(:project, :custom_repo, files: { 'composer.json' => json.to_json } ) }
+ let!(:package) { create(:composer_package, :with_metadatum, project: project, name: package_name, version: '1.0.0', json: json) }
+
+ before do
+ Gitlab::Composer::Cache.new(project: project, name: package_name).execute
+ package.composer_metadatum.reload
+ end
+
+ it 'schedule the update job' do
+ expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, package.composer_metadatum.version_cache_sha)
+
+ package.destroy!
+ end
+ end
end
diff --git a/spec/models/pages/lookup_path_spec.rb b/spec/models/pages/lookup_path_spec.rb
index 0a2b04f1a7c..9e65635da91 100644
--- a/spec/models/pages/lookup_path_spec.rb
+++ b/spec/models/pages/lookup_path_spec.rb
@@ -117,14 +117,6 @@ RSpec.describe Pages::LookupPath do
end
end
- context 'when pages_serve_from_deployments feature flag is disabled' do
- before do
- stub_feature_flags(pages_serve_from_deployments: false)
- end
-
- include_examples 'uses disk storage'
- end
-
context 'when deployment were created during migration' do
before do
allow(deployment).to receive(:migrated?).and_return(true)
diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb
index 37402fa04c4..a56018f0fee 100644
--- a/spec/models/project_feature_spec.rb
+++ b/spec/models/project_feature_spec.rb
@@ -40,7 +40,7 @@ RSpec.describe ProjectFeature do
end
context 'public features' do
- features = %w(issues wiki builds merge_requests snippets repository metrics_dashboard operations)
+ features = ProjectFeature::FEATURES - %i(pages)
features.each do |feature|
it "does not allow public access level for #{feature}" do
@@ -187,4 +187,30 @@ RSpec.describe ProjectFeature do
expect(described_class.required_minimum_access_level_for_private_project(:issues)).to eq(Gitlab::Access::GUEST)
end
end
+
+ describe 'container_registry_access_level' do
+ context 'when the project is created with container_registry_enabled false' do
+ it 'creates project with DISABLED container_registry_access_level' do
+ project = create(:project, container_registry_enabled: false)
+
+ expect(project.project_feature.container_registry_access_level).to eq(described_class::DISABLED)
+ end
+ end
+
+ context 'when the project is created with container_registry_enabled true' do
+ it 'creates project with ENABLED container_registry_access_level' do
+ project = create(:project, container_registry_enabled: true)
+
+ expect(project.project_feature.container_registry_access_level).to eq(described_class::ENABLED)
+ end
+ end
+
+ context 'when the project is created with container_registry_enabled nil' do
+ it 'creates project with DISABLED container_registry_access_level' do
+ project = create(:project, container_registry_enabled: nil)
+
+ expect(project.project_feature.container_registry_access_level).to eq(described_class::DISABLED)
+ end
+ end
+ end
end
diff --git a/spec/models/project_repository_storage_move_spec.rb b/spec/models/project_repository_storage_move_spec.rb
index 88535f6dd6e..eb193a44680 100644
--- a/spec/models/project_repository_storage_move_spec.rb
+++ b/spec/models/project_repository_storage_move_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe ProjectRepositoryStorageMove, type: :model do
let(:container) { project }
let(:repository_storage_factory_key) { :project_repository_storage_move }
let(:error_key) { :project }
- let(:repository_storage_worker) { ProjectUpdateRepositoryStorageWorker }
+ let(:repository_storage_worker) { Projects::UpdateRepositoryStorageWorker }
end
describe 'state transitions' do
diff --git a/spec/models/project_services/discord_service_spec.rb b/spec/models/project_services/discord_service_spec.rb
index d4bd08ddeb6..ffe0a36dcdc 100644
--- a/spec/models/project_services/discord_service_spec.rb
+++ b/spec/models/project_services/discord_service_spec.rb
@@ -6,7 +6,16 @@ RSpec.describe DiscordService do
it_behaves_like "chat service", "Discord notifications" do
let(:client) { Discordrb::Webhooks::Client }
let(:client_arguments) { { url: webhook_url } }
- let(:content_key) { :content }
+ let(:payload) do
+ {
+ embeds: [
+ include(
+ author: include(name: be_present),
+ description: be_present
+ )
+ ]
+ }
+ end
end
describe '#execute' do
@@ -58,5 +67,16 @@ RSpec.describe DiscordService do
expect { subject.execute(sample_data) }.to raise_error(ArgumentError, /is blocked/)
end
end
+
+ context 'when the Discord request fails' do
+ before do
+ WebMock.stub_request(:post, webhook_url).to_return(status: 400)
+ end
+
+ it 'logs an error and returns false' do
+ expect(subject).to receive(:log_error).with('400 Bad Request')
+ expect(subject.execute(sample_data)).to be(false)
+ end
+ end
end
end
diff --git a/spec/models/project_services/hangouts_chat_service_spec.rb b/spec/models/project_services/hangouts_chat_service_spec.rb
index 042e32439d1..9d3bd457fc8 100644
--- a/spec/models/project_services/hangouts_chat_service_spec.rb
+++ b/spec/models/project_services/hangouts_chat_service_spec.rb
@@ -6,6 +6,10 @@ RSpec.describe HangoutsChatService do
it_behaves_like "chat service", "Hangouts Chat" do
let(:client) { HangoutsChat::Sender }
let(:client_arguments) { webhook_url }
- let(:content_key) { :text }
+ let(:payload) do
+ {
+ text: be_present
+ }
+ end
end
end
diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb
index 78bd0e91208..3fc39fd3266 100644
--- a/spec/models/project_services/jira_service_spec.rb
+++ b/spec/models/project_services/jira_service_spec.rb
@@ -474,21 +474,32 @@ RSpec.describe JiraService do
let(:custom_base_url) { 'http://custom_url' }
shared_examples 'close_issue' do
+ let(:issue_key) { 'JIRA-123' }
+ let(:issue_url) { "#{url}/rest/api/2/issue/#{issue_key}" }
+ let(:transitions_url) { "#{issue_url}/transitions" }
+ let(:comment_url) { "#{issue_url}/comment" }
+ let(:remote_link_url) { "#{issue_url}/remotelink" }
+ let(:transitions) { nil }
+
+ let(:issue_fields) do
+ {
+ id: issue_key,
+ self: issue_url,
+ transitions: transitions
+ }
+ end
+
+ subject(:close_issue) do
+ jira_service.close_issue(resource, ExternalIssue.new(issue_key, project))
+ end
+
before do
- @jira_service = described_class.new
- allow(@jira_service).to receive_messages(
- project_id: project.id,
- project: project,
- url: 'http://jira.example.com',
- username: 'gitlab_jira_username',
- password: 'gitlab_jira_password',
- jira_issue_transition_id: '999'
- )
+ allow(jira_service).to receive_messages(jira_issue_transition_id: '999')
# These stubs are needed to test JiraService#close_issue.
# We close the issue then do another request to API to check if it got closed.
# Here is stubbed the API return with a closed and an opened issues.
- open_issue = JIRA::Resource::Issue.new(@jira_service.client, attrs: { 'id' => 'JIRA-123' })
+ open_issue = JIRA::Resource::Issue.new(jira_service.client, attrs: issue_fields.deep_stringify_keys)
closed_issue = open_issue.dup
allow(open_issue).to receive(:resolution).and_return(false)
allow(closed_issue).to receive(:resolution).and_return(true)
@@ -497,29 +508,22 @@ RSpec.describe JiraService do
allow_any_instance_of(JIRA::Resource::Issue).to receive(:key).and_return('JIRA-123')
allow(JIRA::Resource::Remotelink).to receive(:all).and_return([])
- @jira_service.save!
-
- project_issues_url = 'http://jira.example.com/rest/api/2/issue/JIRA-123'
- @transitions_url = 'http://jira.example.com/rest/api/2/issue/JIRA-123/transitions'
- @comment_url = 'http://jira.example.com/rest/api/2/issue/JIRA-123/comment'
- @remote_link_url = 'http://jira.example.com/rest/api/2/issue/JIRA-123/remotelink'
-
- WebMock.stub_request(:get, project_issues_url).with(basic_auth: %w(gitlab_jira_username gitlab_jira_password))
- WebMock.stub_request(:post, @transitions_url).with(basic_auth: %w(gitlab_jira_username gitlab_jira_password))
- WebMock.stub_request(:post, @comment_url).with(basic_auth: %w(gitlab_jira_username gitlab_jira_password))
- WebMock.stub_request(:post, @remote_link_url).with(basic_auth: %w(gitlab_jira_username gitlab_jira_password))
+ WebMock.stub_request(:get, issue_url).with(basic_auth: %w(jira-username jira-password))
+ WebMock.stub_request(:post, transitions_url).with(basic_auth: %w(jira-username jira-password))
+ WebMock.stub_request(:post, comment_url).with(basic_auth: %w(jira-username jira-password))
+ WebMock.stub_request(:post, remote_link_url).with(basic_auth: %w(jira-username jira-password))
end
let(:external_issue) { ExternalIssue.new('JIRA-123', project) }
def close_issue
- @jira_service.close_issue(resource, external_issue, current_user)
+ jira_service.close_issue(resource, external_issue, current_user)
end
it 'calls Jira API' do
close_issue
- expect(WebMock).to have_requested(:post, @comment_url).with(
+ expect(WebMock).to have_requested(:post, comment_url).with(
body: /Issue solved with/
).once
end
@@ -546,9 +550,9 @@ RSpec.describe JiraService do
favicon_path = "http://localhost/assets/#{find_asset('favicon.png').digest_path}"
# Creates comment
- expect(WebMock).to have_requested(:post, @comment_url)
+ expect(WebMock).to have_requested(:post, comment_url)
# Creates Remote Link in Jira issue fields
- expect(WebMock).to have_requested(:post, @remote_link_url).with(
+ expect(WebMock).to have_requested(:post, remote_link_url).with(
body: hash_including(
GlobalID: 'GitLab',
relationship: 'mentioned on',
@@ -564,11 +568,11 @@ RSpec.describe JiraService do
context 'when "comment_on_event_enabled" is set to false' do
it 'creates Remote Link reference but does not create comment' do
- allow(@jira_service).to receive_messages(comment_on_event_enabled: false)
+ allow(jira_service).to receive_messages(comment_on_event_enabled: false)
close_issue
- expect(WebMock).not_to have_requested(:post, @comment_url)
- expect(WebMock).to have_requested(:post, @remote_link_url)
+ expect(WebMock).not_to have_requested(:post, comment_url)
+ expect(WebMock).to have_requested(:post, remote_link_url)
end
end
@@ -589,7 +593,7 @@ RSpec.describe JiraService do
close_issue
- expect(WebMock).not_to have_requested(:post, @comment_url)
+ expect(WebMock).not_to have_requested(:post, comment_url)
end
end
@@ -598,8 +602,8 @@ RSpec.describe JiraService do
close_issue
- expect(WebMock).not_to have_requested(:post, @comment_url)
- expect(WebMock).not_to have_requested(:post, @remote_link_url)
+ expect(WebMock).not_to have_requested(:post, comment_url)
+ expect(WebMock).not_to have_requested(:post, remote_link_url)
end
it 'does not send comment or remote links to issues with unknown resolution' do
@@ -607,8 +611,8 @@ RSpec.describe JiraService do
close_issue
- expect(WebMock).not_to have_requested(:post, @comment_url)
- expect(WebMock).not_to have_requested(:post, @remote_link_url)
+ expect(WebMock).not_to have_requested(:post, comment_url)
+ expect(WebMock).not_to have_requested(:post, remote_link_url)
end
it 'references the GitLab commit' do
@@ -616,7 +620,7 @@ RSpec.describe JiraService do
close_issue
- expect(WebMock).to have_requested(:post, @comment_url).with(
+ expect(WebMock).to have_requested(:post, comment_url).with(
body: %r{#{custom_base_url}/#{project.full_path}/-/commit/#{commit_id}}
).once
end
@@ -631,18 +635,18 @@ RSpec.describe JiraService do
close_issue
- expect(WebMock).to have_requested(:post, @comment_url).with(
+ expect(WebMock).to have_requested(:post, comment_url).with(
body: %r{#{Gitlab.config.gitlab.url}/#{project.full_path}/-/commit/#{commit_id}}
).once
end
it 'logs exception when transition id is not valid' do
- allow(@jira_service).to receive(:log_error)
- WebMock.stub_request(:post, @transitions_url).with(basic_auth: %w(gitlab_jira_username gitlab_jira_password)).and_raise("Bad Request")
+ allow(jira_service).to receive(:log_error)
+ WebMock.stub_request(:post, transitions_url).with(basic_auth: %w(jira-username jira-password)).and_raise("Bad Request")
close_issue
- expect(@jira_service).to have_received(:log_error).with(
+ expect(jira_service).to have_received(:log_error).with(
"Issue transition failed",
error: hash_including(
exception_class: 'StandardError',
@@ -655,34 +659,64 @@ RSpec.describe JiraService do
it 'calls the api with jira_issue_transition_id' do
close_issue
- expect(WebMock).to have_requested(:post, @transitions_url).with(
- body: /999/
+ expect(WebMock).to have_requested(:post, transitions_url).with(
+ body: /"id":"999"/
).once
end
- context 'when have multiple transition ids' do
- it 'calls the api with transition ids separated by comma' do
- allow(@jira_service).to receive_messages(jira_issue_transition_id: '1,2,3')
+ context 'when using multiple transition ids' do
+ before do
+ allow(jira_service).to receive_messages(jira_issue_transition_id: '1,2,3')
+ end
+ it 'calls the api with transition ids separated by comma' do
close_issue
1.upto(3) do |transition_id|
- expect(WebMock).to have_requested(:post, @transitions_url).with(
- body: /#{transition_id}/
+ expect(WebMock).to have_requested(:post, transitions_url).with(
+ body: /"id":"#{transition_id}"/
).once
end
+
+ expect(WebMock).to have_requested(:post, comment_url)
end
it 'calls the api with transition ids separated by semicolon' do
- allow(@jira_service).to receive_messages(jira_issue_transition_id: '1;2;3')
+ allow(jira_service).to receive_messages(jira_issue_transition_id: '1;2;3')
close_issue
1.upto(3) do |transition_id|
- expect(WebMock).to have_requested(:post, @transitions_url).with(
- body: /#{transition_id}/
+ expect(WebMock).to have_requested(:post, transitions_url).with(
+ body: /"id":"#{transition_id}"/
).once
end
+
+ expect(WebMock).to have_requested(:post, comment_url)
+ end
+
+ context 'when a transition fails' do
+ before do
+ WebMock.stub_request(:post, transitions_url).with(basic_auth: %w(jira-username jira-password)).to_return do |request|
+ { status: request.body.include?('"id":"2"') ? 500 : 200 }
+ end
+ end
+
+ it 'stops the sequence' do
+ close_issue
+
+ 1.upto(2) do |transition_id|
+ expect(WebMock).to have_requested(:post, transitions_url).with(
+ body: /"id":"#{transition_id}"/
+ )
+ end
+
+ expect(WebMock).not_to have_requested(:post, transitions_url).with(
+ body: /"id":"3"/
+ )
+
+ expect(WebMock).not_to have_requested(:post, comment_url)
+ end
end
end
end
diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb
index ea63406e615..366c3f68e1d 100644
--- a/spec/models/project_services/prometheus_service_spec.rb
+++ b/spec/models/project_services/prometheus_service_spec.rb
@@ -511,20 +511,23 @@ RSpec.describe PrometheusService, :use_clean_rails_memory_store_caching, :snowpl
type: 'checkbox',
name: 'manual_configuration',
title: s_('PrometheusService|Active'),
+ help: s_('PrometheusService|Select this checkbox to override the auto configuration settings with your own settings.'),
required: true
},
{
type: 'text',
name: 'api_url',
title: 'API URL',
- placeholder: s_('PrometheusService|Prometheus API Base URL, like http://prometheus.example.com/'),
+ placeholder: s_('PrometheusService|https://prometheus.example.com/'),
+ help: s_('PrometheusService|The Prometheus API base URL.'),
required: true
},
{
type: 'text',
name: 'google_iap_audience_client_id',
title: 'Google IAP Audience Client ID',
- placeholder: s_('PrometheusService|Client ID of the IAP secured resource (looks like IAP_CLIENT_ID.apps.googleusercontent.com)'),
+ placeholder: s_('PrometheusService|IAP_CLIENT_ID.apps.googleusercontent.com'),
+ help: s_('PrometheusService|PrometheusService|The ID of the IAP-secured resource.'),
autocomplete: 'off',
required: false
},
@@ -532,7 +535,8 @@ RSpec.describe PrometheusService, :use_clean_rails_memory_store_caching, :snowpl
type: 'textarea',
name: 'google_iap_service_account_json',
title: 'Google IAP Service Account JSON',
- placeholder: s_('PrometheusService|Contents of the credentials.json file of your service account, like: { "type": "service_account", "project_id": ... }'),
+ placeholder: s_('PrometheusService|{ "type": "service_account", "project_id": ... }'),
+ help: s_('PrometheusService|The contents of the credentials.json file of your service account.'),
required: false
}
]
diff --git a/spec/models/project_services/slack_service_spec.rb b/spec/models/project_services/slack_service_spec.rb
index 0b35b9e7b30..aa5d92e5c61 100644
--- a/spec/models/project_services/slack_service_spec.rb
+++ b/spec/models/project_services/slack_service_spec.rb
@@ -4,4 +4,116 @@ require 'spec_helper'
RSpec.describe SlackService do
it_behaves_like "slack or mattermost notifications", 'Slack'
+
+ describe '#execute' do
+ before do
+ stub_request(:post, "https://slack.service.url/")
+ end
+
+ let_it_be(:slack_service) { create(:slack_service, 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
+ end
+ end
+
+ context 'hook data includes a user object' do
+ let_it_be(:user) { create_default(:user) }
+ let_it_be(:project) { create_default(:project, :repository, :wiki_repo) }
+
+ shared_examples 'increases the usage data counter' do |event_name|
+ it 'increases the usage data counter' do
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(event_name, values: user.id).and_call_original
+
+ slack_service.execute(data)
+ end
+ end
+
+ context 'event is not supported for usage log' do
+ let_it_be(:pipeline) { create(:ci_pipeline) }
+ let(:data) { Gitlab::DataBuilder::Pipeline.build(pipeline) }
+
+ it 'does not increase the usage data counter' do
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event).with('i_ecosystem_slack_service_pipeline_notification', values: user.id)
+
+ slack_service.execute(data)
+ end
+ end
+
+ context 'issue notification' do
+ let_it_be(:issue) { create(:issue) }
+ let(:data) { issue.to_hook_data(user) }
+
+ it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_issue_notification'
+ end
+
+ context 'push notification' do
+ let(:data) { Gitlab::DataBuilder::Push.build_sample(project, user) }
+
+ it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_push_notification'
+ end
+
+ context 'deployment notification' do
+ let_it_be(:deployment) { create(:deployment, user: user) }
+ let(:data) { Gitlab::DataBuilder::Deployment.build(deployment) }
+
+ it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_deployment_notification'
+ end
+
+ context 'wiki_page notification' do
+ let_it_be(:wiki_page) { create(:wiki_page, wiki: project.wiki, message: 'user created page: Awesome wiki_page') }
+ let(:data) { Gitlab::DataBuilder::WikiPage.build(wiki_page, user, 'create') }
+
+ it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_wiki_page_notification'
+ end
+
+ context 'merge_request notification' do
+ let_it_be(:merge_request) { create(:merge_request) }
+ let(:data) { merge_request.to_hook_data(user) }
+
+ it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_merge_request_notification'
+ end
+
+ context 'note notification' do
+ let_it_be(:issue_note) { create(:note_on_issue, note: 'issue note') }
+ let(:data) { Gitlab::DataBuilder::Note.build(issue_note, user) }
+
+ it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_note_notification'
+ end
+
+ context 'tag_push notification' do
+ let(:oldrev) { Gitlab::Git::BLANK_SHA }
+ let(:newrev) { '8a2a6eb295bb170b34c24c76c49ed0e9b2eaf34b' } # gitlab-test: git rev-parse refs/tags/v1.1.0
+ let(:ref) { 'refs/tags/v1.1.0' }
+ let(:data) { Git::TagHooksService.new(project, user, change: { oldrev: oldrev, newrev: newrev, ref: ref }).send(:push_data) }
+
+ it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_tag_push_notification'
+ end
+
+ context 'confidential note notification' do
+ let_it_be(:confidential_issue_note) { create(:note_on_issue, note: 'issue note', confidential: true) }
+ let(:data) { Gitlab::DataBuilder::Note.build(confidential_issue_note, user) }
+
+ it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_confidential_note_notification'
+ end
+
+ context 'confidential issue notification' do
+ let_it_be(:issue) { create(:issue, confidential: true) }
+ let(:data) { issue.to_hook_data(user) }
+
+ it_behaves_like 'increases the usage data counter', 'i_ecosystem_slack_service_confidential_issue_notification'
+ end
+ end
+
+ context 'hook data does not include a user' do
+ let(:data) { Gitlab::DataBuilder::Pipeline.build(create(:ci_pipeline)) }
+
+ it 'does not increase the usage data counter' do
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
+
+ slack_service.execute(data)
+ end
+ end
+ end
end
diff --git a/spec/models/project_services/unify_circuit_service_spec.rb b/spec/models/project_services/unify_circuit_service_spec.rb
index 73702aa8471..0c749322e07 100644
--- a/spec/models/project_services/unify_circuit_service_spec.rb
+++ b/spec/models/project_services/unify_circuit_service_spec.rb
@@ -5,6 +5,12 @@ require "spec_helper"
RSpec.describe UnifyCircuitService do
it_behaves_like "chat service", "Unify Circuit" do
let(:client_arguments) { webhook_url }
- let(:content_key) { :subject }
+ let(:payload) do
+ {
+ subject: project.full_name,
+ text: be_present,
+ markdown: true
+ }
+ end
end
end
diff --git a/spec/models/project_services/webex_teams_service_spec.rb b/spec/models/project_services/webex_teams_service_spec.rb
index bd73d0c93b8..ed63f5bc48c 100644
--- a/spec/models/project_services/webex_teams_service_spec.rb
+++ b/spec/models/project_services/webex_teams_service_spec.rb
@@ -5,6 +5,10 @@ require "spec_helper"
RSpec.describe WebexTeamsService do
it_behaves_like "chat service", "Webex Teams" do
let(:client_arguments) { webhook_url }
- let(:content_key) { :markdown }
+ let(:payload) do
+ {
+ markdown: be_present
+ }
+ end
end
end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index fd7975bf65d..1cee494989d 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe Project, factory_default: :keep do
include ExternalAuthorizationServiceHelpers
using RSpec::Parameterized::TableSyntax
- let_it_be(:namespace) { create_default(:namespace) }
+ let_it_be(:namespace) { create_default(:namespace).freeze }
it_behaves_like 'having unique enum values'
@@ -145,7 +145,7 @@ RSpec.describe Project, factory_default: :keep do
end
it_behaves_like 'model with wiki' do
- let_it_be(:container) { create(:project, :wiki_repo) }
+ let_it_be(:container) { create(:project, :wiki_repo, namespace: create(:group)) }
let(:container_without_wiki) { create(:project) }
end
@@ -1599,7 +1599,7 @@ RSpec.describe Project, factory_default: :keep do
end
end
- describe '#any_runners?' do
+ describe '#any_active_runners?' do
context 'shared runners' do
let(:project) { create(:project, shared_runners_enabled: shared_runners_enabled) }
let(:specific_runner) { create(:ci_runner, :project, projects: [project]) }
@@ -1609,31 +1609,31 @@ RSpec.describe Project, factory_default: :keep do
let(:shared_runners_enabled) { false }
it 'has no runners available' do
- expect(project.any_runners?).to be_falsey
+ expect(project.any_active_runners?).to be_falsey
end
it 'has a specific runner' do
specific_runner
- expect(project.any_runners?).to be_truthy
+ expect(project.any_active_runners?).to be_truthy
end
it 'has a shared runner, but they are prohibited to use' do
shared_runner
- expect(project.any_runners?).to be_falsey
+ expect(project.any_active_runners?).to be_falsey
end
it 'checks the presence of specific runner' do
specific_runner
- expect(project.any_runners? { |runner| runner == specific_runner }).to be_truthy
+ expect(project.any_active_runners? { |runner| runner == specific_runner }).to be_truthy
end
it 'returns false if match cannot be found' do
specific_runner
- expect(project.any_runners? { false }).to be_falsey
+ expect(project.any_active_runners? { false }).to be_falsey
end
end
@@ -1643,19 +1643,19 @@ RSpec.describe Project, factory_default: :keep do
it 'has a shared runner' do
shared_runner
- expect(project.any_runners?).to be_truthy
+ expect(project.any_active_runners?).to be_truthy
end
it 'checks the presence of shared runner' do
shared_runner
- expect(project.any_runners? { |runner| runner == shared_runner }).to be_truthy
+ expect(project.any_active_runners? { |runner| runner == shared_runner }).to be_truthy
end
it 'returns false if match cannot be found' do
shared_runner
- expect(project.any_runners? { false }).to be_falsey
+ expect(project.any_active_runners? { false }).to be_falsey
end
end
end
@@ -1669,13 +1669,13 @@ RSpec.describe Project, factory_default: :keep do
let(:group_runners_enabled) { false }
it 'has no runners available' do
- expect(project.any_runners?).to be_falsey
+ expect(project.any_active_runners?).to be_falsey
end
it 'has a group runner, but they are prohibited to use' do
group_runner
- expect(project.any_runners?).to be_falsey
+ expect(project.any_active_runners?).to be_falsey
end
end
@@ -1685,19 +1685,19 @@ RSpec.describe Project, factory_default: :keep do
it 'has a group runner' do
group_runner
- expect(project.any_runners?).to be_truthy
+ expect(project.any_active_runners?).to be_truthy
end
it 'checks the presence of group runner' do
group_runner
- expect(project.any_runners? { |runner| runner == group_runner }).to be_truthy
+ expect(project.any_active_runners? { |runner| runner == group_runner }).to be_truthy
end
it 'returns false if match cannot be found' do
group_runner
- expect(project.any_runners? { false }).to be_falsey
+ expect(project.any_active_runners? { false }).to be_falsey
end
end
end
@@ -1799,7 +1799,8 @@ RSpec.describe Project, factory_default: :keep do
describe '#default_branch_protected?' do
using RSpec::Parameterized::TableSyntax
- let_it_be(:project) { create(:project) }
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:project) { create(:project, namespace: namespace) }
subject { project.default_branch_protected? }
@@ -2201,6 +2202,44 @@ RSpec.describe Project, factory_default: :keep do
end
end
+ describe '#set_container_registry_access_level' do
+ let_it_be_with_reload(:project) { create(:project) }
+
+ it 'updates project_feature', :aggregate_failures do
+ # Simulate an existing project that has container_registry enabled
+ project.update_column(:container_registry_enabled, true)
+ project.project_feature.update_column(:container_registry_access_level, ProjectFeature::DISABLED)
+
+ expect(project.container_registry_enabled).to eq(true)
+ expect(project.project_feature.container_registry_access_level).to eq(ProjectFeature::DISABLED)
+
+ project.update!(container_registry_enabled: false)
+
+ expect(project.container_registry_enabled).to eq(false)
+ expect(project.project_feature.container_registry_access_level).to eq(ProjectFeature::DISABLED)
+
+ project.update!(container_registry_enabled: true)
+
+ expect(project.container_registry_enabled).to eq(true)
+ expect(project.project_feature.container_registry_access_level).to eq(ProjectFeature::ENABLED)
+ end
+
+ it 'rollsback both projects and project_features row in case of error', :aggregate_failures do
+ project.update_column(:container_registry_enabled, true)
+ project.project_feature.update_column(:container_registry_access_level, ProjectFeature::DISABLED)
+
+ expect(project.container_registry_enabled).to eq(true)
+ expect(project.project_feature.container_registry_access_level).to eq(ProjectFeature::DISABLED)
+
+ allow(project).to receive(:valid?).and_return(false)
+
+ expect { project.update!(container_registry_enabled: false) }.to raise_error(ActiveRecord::RecordInvalid)
+
+ expect(project.reload.container_registry_enabled).to eq(true)
+ expect(project.project_feature.reload.container_registry_access_level).to eq(ProjectFeature::DISABLED)
+ end
+ end
+
describe '#has_container_registry_tags?' do
let(:project) { build(:project) }
@@ -2802,7 +2841,8 @@ RSpec.describe Project, factory_default: :keep do
end
describe '#emails_disabled?' do
- let(:project) { build(:project, emails_disabled: false) }
+ let_it_be(:namespace) { create(:namespace) }
+ let(:project) { build(:project, namespace: namespace, emails_disabled: false) }
context 'emails disabled in group' do
it 'returns true' do
@@ -2830,7 +2870,8 @@ RSpec.describe Project, factory_default: :keep do
end
describe '#lfs_enabled?' do
- let(:project) { build(:project) }
+ let(:namespace) { create(:namespace) }
+ let(:project) { build(:project, namespace: namespace) }
shared_examples 'project overrides group' do
it 'returns true when enabled in project' do
@@ -4463,7 +4504,11 @@ RSpec.describe Project, factory_default: :keep do
subject { project.predefined_project_variables.to_runner_variables }
specify do
- expect(subject).to include({ key: 'CI_PROJECT_CONFIG_PATH', value: Ci::Pipeline::DEFAULT_CONFIG_PATH, public: true, masked: false })
+ expect(subject).to include
+ [
+ { key: 'CI_PROJECT_CONFIG_PATH', value: Ci::Pipeline::DEFAULT_CONFIG_PATH, public: true, masked: false },
+ { key: 'CI_CONFIG_PATH', value: Ci::Pipeline::DEFAULT_CONFIG_PATH, public: true, masked: false }
+ ]
end
context 'when ci config path is overridden' do
@@ -4471,7 +4516,41 @@ RSpec.describe Project, factory_default: :keep do
project.update!(ci_config_path: 'random.yml')
end
- it { expect(subject).to include({ key: 'CI_PROJECT_CONFIG_PATH', value: 'random.yml', public: true, masked: false }) }
+ it do
+ expect(subject).to include
+ [
+ { key: 'CI_PROJECT_CONFIG_PATH', value: 'random.yml', public: true, masked: false },
+ { key: 'CI_CONFIG_PATH', value: 'random.yml', public: true, masked: false }
+ ]
+ end
+ end
+ end
+
+ describe '#dependency_proxy_variables' do
+ let_it_be(:namespace) { create(:namespace, path: 'NameWithUPPERcaseLetters') }
+ let_it_be(:project) { create(:project, :repository, namespace: namespace) }
+
+ subject { project.dependency_proxy_variables.to_runner_variables }
+
+ context 'when dependency_proxy is enabled' do
+ before do
+ stub_config(dependency_proxy: { enabled: true })
+ end
+
+ it 'contains the downcased name' do
+ expect(subject).to include({ key: 'CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX',
+ value: "#{Gitlab.host_with_port}/namewithuppercaseletters#{DependencyProxy::URL_SUFFIX}",
+ public: true,
+ masked: false })
+ end
+ end
+
+ context 'when dependency_proxy is disabled' do
+ before do
+ stub_config(dependency_proxy: { enabled: false })
+ end
+
+ it { expect(subject).to be_empty }
end
end
@@ -4877,7 +4956,8 @@ RSpec.describe Project, factory_default: :keep do
end
context 'branch protection' do
- let(:project) { create(:project, :repository) }
+ let_it_be(:namespace) { create(:namespace) }
+ let(:project) { create(:project, :repository, namespace: namespace) }
before do
create(:import_state, :started, project: project)
diff --git a/spec/models/projects/repository_storage_move_spec.rb b/spec/models/projects/repository_storage_move_spec.rb
new file mode 100644
index 00000000000..ab0ad81f77a
--- /dev/null
+++ b/spec/models/projects/repository_storage_move_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::RepositoryStorageMove, type: :model do
+ let_it_be_with_refind(:project) { create(:project) }
+
+ it_behaves_like 'handles repository moves' do
+ let(:container) { project }
+ let(:repository_storage_factory_key) { :project_repository_storage_move }
+ let(:error_key) { :project }
+ let(:repository_storage_worker) { Projects::UpdateRepositoryStorageWorker }
+ end
+
+ describe 'state transitions' do
+ let(:storage) { 'test_second_storage' }
+
+ before do
+ stub_storage_settings(storage => { 'path' => 'tmp/tests/extra_storage' })
+ end
+
+ context 'when started' do
+ subject(:storage_move) { create(:project_repository_storage_move, :started, container: project, destination_storage_name: storage) }
+
+ context 'and transits to replicated' do
+ it 'sets the repository storage and marks the container as writable' do
+ storage_move.finish_replication!
+
+ expect(project.repository_storage).to eq(storage)
+ expect(project).not_to be_repository_read_only
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/prometheus_alert_event_spec.rb b/spec/models/prometheus_alert_event_spec.rb
index 913ca7db0be..6bff549bc4b 100644
--- a/spec/models/prometheus_alert_event_spec.rb
+++ b/spec/models/prometheus_alert_event_spec.rb
@@ -52,7 +52,7 @@ RSpec.describe PrometheusAlertEvent do
let(:started_at) { Time.current }
context 'when status is none' do
- subject { build(:prometheus_alert_event, :none) }
+ subject { build(:prometheus_alert_event, status: nil, started_at: nil) }
it 'fires an event' do
result = subject.fire(started_at)
diff --git a/spec/models/protected_branch_spec.rb b/spec/models/protected_branch_spec.rb
index a89f8778780..a173ab48f17 100644
--- a/spec/models/protected_branch_spec.rb
+++ b/spec/models/protected_branch_spec.rb
@@ -207,6 +207,28 @@ RSpec.describe ProtectedBranch do
end
end
+ describe "#allow_force_push?" do
+ context "when the attr allow_force_push is true" do
+ let(:subject_branch) { create(:protected_branch, allow_force_push: true, name: "foo") }
+
+ it "returns true" do
+ project = subject_branch.project
+
+ expect(described_class.allow_force_push?(project, "foo")).to eq(true)
+ end
+ end
+
+ context "when the attr allow_force_push is false" do
+ let(:subject_branch) { create(:protected_branch, allow_force_push: false, name: "foo") }
+
+ it "returns false" do
+ project = subject_branch.project
+
+ expect(described_class.allow_force_push?(project, "foo")).to eq(false)
+ end
+ end
+ end
+
describe '#any_protected?' do
context 'existing project' do
let(:project) { create(:project, :repository) }
diff --git a/spec/models/snippet_repository_spec.rb b/spec/models/snippet_repository_spec.rb
index cdbc1feefce..11196f06529 100644
--- a/spec/models/snippet_repository_spec.rb
+++ b/spec/models/snippet_repository_spec.rb
@@ -286,6 +286,7 @@ RSpec.describe SnippetRepository do
context 'with git errors' do
it_behaves_like 'snippet repository with git errors', 'invalid://path/here', described_class::InvalidPathError
+ it_behaves_like 'snippet repository with git errors', '.git/hooks/pre-commit', described_class::InvalidPathError
it_behaves_like 'snippet repository with git errors', '../../path/traversal/here', described_class::InvalidPathError
it_behaves_like 'snippet repository with git errors', 'README', described_class::CommitError
diff --git a/spec/models/snippet_repository_storage_move_spec.rb b/spec/models/snippet_repository_storage_move_spec.rb
index 357951f8859..f5ad837fb36 100644
--- a/spec/models/snippet_repository_storage_move_spec.rb
+++ b/spec/models/snippet_repository_storage_move_spec.rb
@@ -8,6 +8,6 @@ RSpec.describe SnippetRepositoryStorageMove, type: :model do
let(:repository_storage_factory_key) { :snippet_repository_storage_move }
let(:error_key) { :snippet }
- let(:repository_storage_worker) { SnippetUpdateRepositoryStorageWorker }
+ let(:repository_storage_worker) { Snippets::UpdateRepositoryStorageWorker }
end
end
diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb
index 623767d19e0..09f9cf8e222 100644
--- a/spec/models/snippet_spec.rb
+++ b/spec/models/snippet_spec.rb
@@ -21,7 +21,7 @@ RSpec.describe Snippet do
it { is_expected.to have_many(:user_mentions).class_name("SnippetUserMention") }
it { is_expected.to have_one(:snippet_repository) }
it { is_expected.to have_one(:statistics).class_name('SnippetStatistics').dependent(:destroy) }
- it { is_expected.to have_many(:repository_storage_moves).class_name('SnippetRepositoryStorageMove').inverse_of(:container) }
+ it { is_expected.to have_many(:repository_storage_moves).class_name('Snippets::RepositoryStorageMove').inverse_of(:container) }
end
describe 'validation' do
@@ -496,6 +496,16 @@ RSpec.describe Snippet do
it 'returns array of blobs' do
expect(snippet.blobs).to all(be_a(Blob))
end
+
+ context 'when file does not exist' do
+ it 'removes nil values from the blobs array' do
+ allow(snippet).to receive(:list_files).and_return(%w(LICENSE non_existent_snippet_file))
+
+ blobs = snippet.blobs
+ expect(blobs.count).to eq 1
+ expect(blobs.first.name).to eq 'LICENSE'
+ end
+ end
end
end
diff --git a/spec/models/snippets/repository_storage_move_spec.rb b/spec/models/snippets/repository_storage_move_spec.rb
new file mode 100644
index 00000000000..ed518faf6ff
--- /dev/null
+++ b/spec/models/snippets/repository_storage_move_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Snippets::RepositoryStorageMove, type: :model do
+ it_behaves_like 'handles repository moves' do
+ let_it_be_with_refind(:container) { create(:snippet) }
+
+ let(:repository_storage_factory_key) { :snippet_repository_storage_move }
+ let(:error_key) { :snippet }
+ let(:repository_storage_worker) { Snippets::UpdateRepositoryStorageWorker }
+ end
+end
diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb
index a9c4c6680cd..855b1b0f3f7 100644
--- a/spec/models/todo_spec.rb
+++ b/spec/models/todo_spec.rb
@@ -363,23 +363,6 @@ RSpec.describe Todo do
end
end
- describe '.for_ids' do
- it 'returns the expected todos' do
- todo1 = create(:todo)
- todo2 = create(:todo)
- todo3 = create(:todo)
- create(:todo)
-
- expect(described_class.for_ids([todo2.id, todo1.id, todo3.id])).to contain_exactly(todo1, todo2, todo3)
- end
-
- it 'returns an empty collection when no ids are given' do
- create(:todo)
-
- expect(described_class.for_ids([])).to be_empty
- end
- end
-
describe '.for_user' do
it 'returns the expected todos' do
user1 = create(:user)
diff --git a/spec/models/upload_spec.rb b/spec/models/upload_spec.rb
index 18388b4cd83..6bac5e31435 100644
--- a/spec/models/upload_spec.rb
+++ b/spec/models/upload_spec.rb
@@ -221,7 +221,7 @@ RSpec.describe Upload do
it 'does not send a message to Sentry' do
upload = described_class.new(path: "#{__FILE__}-nope", store: ObjectStorage::Store::LOCAL)
- expect(Raven).not_to receive(:capture_message)
+ expect(Gitlab::ErrorTracking).not_to receive(:track_exception)
upload.exist?
end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 860c015e166..5f2842c9d16 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -41,6 +41,9 @@ RSpec.describe User do
it { is_expected.to delegate_method(:show_whitespace_in_diffs).to(:user_preference) }
it { is_expected.to delegate_method(:show_whitespace_in_diffs=).to(:user_preference).with_arguments(:args) }
+ it { is_expected.to delegate_method(:view_diffs_file_by_file).to(:user_preference) }
+ it { is_expected.to delegate_method(:view_diffs_file_by_file=).to(:user_preference).with_arguments(:args) }
+
it { is_expected.to delegate_method(:tab_width).to(:user_preference) }
it { is_expected.to delegate_method(:tab_width=).to(:user_preference).with_arguments(:args) }
@@ -59,6 +62,9 @@ RSpec.describe User do
it { is_expected.to delegate_method(:experience_level).to(:user_preference) }
it { is_expected.to delegate_method(:experience_level=).to(:user_preference).with_arguments(:args) }
+ 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(:job_title).to(:user_detail).allow_nil }
it { is_expected.to delegate_method(:job_title=).to(:user_detail).with_arguments(:args).allow_nil }
@@ -101,6 +107,7 @@ RSpec.describe User do
it { is_expected.to have_many(:reviews).inverse_of(:author) }
it { is_expected.to have_many(:merge_request_assignees).inverse_of(:assignee) }
it { is_expected.to have_many(:merge_request_reviewers).inverse_of(:reviewer) }
+ it { is_expected.to have_many(:created_custom_emoji).inverse_of(:creator) }
describe "#user_detail" do
it 'does not persist `user_detail` by default' do
@@ -380,11 +387,11 @@ RSpec.describe User do
it { is_expected.not_to allow_value(-1).for(:projects_limit) }
it { is_expected.not_to allow_value(Gitlab::Database::MAX_INT_VALUE + 1).for(:projects_limit) }
- it_behaves_like 'an object with email-formated attributes', :email do
+ it_behaves_like 'an object with email-formatted attributes', :email do
subject { build(:user) }
end
- it_behaves_like 'an object with RFC3696 compliant email-formated attributes', :public_email, :notification_email do
+ it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :public_email, :notification_email do
subject { create(:user).tap { |user| user.emails << build(:email, email: email_value, confirmed_at: Time.current) } }
end
@@ -1050,7 +1057,7 @@ RSpec.describe User do
let(:user) { create(:user) }
let(:external_user) { create(:user, external: true) }
- it "sets other properties aswell" do
+ it "sets other properties as well" do
expect(external_user.can_create_team).to be_falsey
expect(external_user.can_create_group).to be_falsey
expect(external_user.projects_limit).to be 0
@@ -1061,7 +1068,7 @@ RSpec.describe User do
let(:user) { create(:user) }
let(:secondary) { create(:email, :confirmed, email: 'secondary@example.com', user: user) }
- it 'allows a verfied secondary email to be used as the primary without needing reconfirmation' do
+ it 'allows a verified secondary email to be used as the primary without needing reconfirmation' do
user.update!(email: secondary.email)
user.reload
expect(user.email).to eq secondary.email
@@ -1827,7 +1834,7 @@ RSpec.describe User do
end
describe '.instance_access_request_approvers_to_be_notified' do
- let_it_be(:admin_list) { create_list(:user, 12, :admin, :with_sign_ins) }
+ let_it_be(:admin_issue_board_list) { create_list(:user, 12, :admin, :with_sign_ins) }
it 'returns up to the ten most recently active instance admins' do
active_admins_in_recent_sign_in_desc_order = User.admins.active.order_recent_sign_in.limit(10)
@@ -2492,6 +2499,38 @@ RSpec.describe User do
end
end
+ describe "#clear_avatar_caches" do
+ let(:user) { create(:user) }
+
+ context "when :avatar_cache_for_email flag is enabled" do
+ before do
+ stub_feature_flags(avatar_cache_for_email: true)
+ end
+
+ it "clears the avatar cache when saving" do
+ allow(user).to receive(:avatar_changed?).and_return(true)
+
+ expect(Gitlab::AvatarCache).to receive(:delete_by_email).with(*user.verified_emails)
+
+ user.update(avatar: fixture_file_upload('spec/fixtures/dk.png'))
+ end
+ end
+
+ context "when :avatar_cache_for_email flag is disabled" do
+ before do
+ stub_feature_flags(avatar_cache_for_email: false)
+ end
+
+ it "doesn't attempt to clear the avatar cache" do
+ allow(user).to receive(:avatar_changed?).and_return(true)
+
+ expect(Gitlab::AvatarCache).not_to receive(:delete_by_email)
+
+ user.update(avatar: fixture_file_upload('spec/fixtures/dk.png'))
+ end
+ end
+ end
+
describe '#accept_pending_invitations!' do
let(:user) { create(:user, email: 'user@email.com') }
let!(:project_member_invite) { create(:project_member, :invited, invite_email: user.email) }
@@ -3227,23 +3266,8 @@ RSpec.describe User do
create(:group_group_link, shared_group: private_group, shared_with_group: other_group)
end
- context 'when shared_group_membership_auth is enabled' do
- before do
- stub_feature_flags(shared_group_membership_auth: user)
- end
-
- it { is_expected.to include shared_group }
- it { is_expected.not_to include other_group }
- end
-
- context 'when shared_group_membership_auth is disabled' do
- before do
- stub_feature_flags(shared_group_membership_auth: false)
- end
-
- it { is_expected.not_to include shared_group }
- it { is_expected.not_to include other_group }
- end
+ it { is_expected.to include shared_group }
+ it { is_expected.not_to include other_group }
end
end
@@ -3937,6 +3961,37 @@ RSpec.describe User do
end
end
+ describe '#can_admin_all_resources?', :request_store do
+ it 'returns false for regular user' do
+ user = build_stubbed(:user)
+
+ expect(user.can_admin_all_resources?).to be_falsy
+ end
+
+ context 'for admin user' do
+ include_context 'custom session'
+
+ let(:user) { build_stubbed(:user, :admin) }
+
+ context 'when admin mode is disabled' do
+ it 'returns false' do
+ expect(user.can_admin_all_resources?).to be_falsy
+ end
+ end
+
+ context 'when admin mode is enabled' do
+ before do
+ Gitlab::Auth::CurrentUserMode.new(user).request_admin_mode!
+ Gitlab::Auth::CurrentUserMode.new(user).enable_admin_mode!(password: user.password)
+ end
+
+ it 'returns true' do
+ expect(user.can_admin_all_resources?).to be_truthy
+ end
+ end
+ end
+ end
+
describe '.ghost' do
it "creates a ghost user if one isn't already present" do
ghost = described_class.ghost
@@ -5370,6 +5425,40 @@ RSpec.describe User do
end
end
+ describe 'can_trigger_notifications?' do
+ context 'when user is not confirmed' do
+ let_it_be(:user) { create(:user, :unconfirmed) }
+
+ it 'returns false' do
+ expect(user.can_trigger_notifications?).to be(false)
+ end
+ end
+
+ context 'when user is blocked' do
+ let_it_be(:user) { create(:user, :blocked) }
+
+ it 'returns false' do
+ expect(user.can_trigger_notifications?).to be(false)
+ end
+ end
+
+ context 'when user is a ghost' do
+ let_it_be(:user) { create(:user, :ghost) }
+
+ it 'returns false' do
+ expect(user.can_trigger_notifications?).to be(false)
+ end
+ end
+
+ context 'when user is confirmed and neither blocked or a ghost' do
+ let_it_be(:user) { create(:user) }
+
+ it 'returns true' do
+ expect(user.can_trigger_notifications?).to be(true)
+ end
+ end
+ end
+
context 'bot users' do
shared_examples 'bot users' do |bot_type|
it 'creates the user if it does not exist' do
@@ -5412,4 +5501,89 @@ RSpec.describe User do
it_behaves_like 'bot user avatars', :support_bot, 'support-bot.png'
it_behaves_like 'bot user avatars', :security_bot, 'security-bot.png'
end
+
+ describe '#confirmation_required_on_sign_in?' do
+ subject { user.confirmation_required_on_sign_in? }
+
+ context 'when user is confirmed' do
+ let(:user) { build_stubbed(:user) }
+
+ it 'is falsey' do
+ expect(user.confirmed?).to be_truthy
+ expect(subject).to be_falsey
+ end
+ end
+
+ context 'when user is not confirmed' do
+ let_it_be(:user) { build_stubbed(:user, :unconfirmed, confirmation_sent_at: Time.current) }
+
+ it 'is truthy when soft_email_confirmation feature is disabled' do
+ stub_feature_flags(soft_email_confirmation: false)
+ expect(subject).to be_truthy
+ end
+
+ context 'when soft_email_confirmation feature is enabled' do
+ before do
+ stub_feature_flags(soft_email_confirmation: true)
+ end
+
+ it 'is falsey when confirmation period is valid' do
+ expect(subject).to be_falsey
+ end
+
+ it 'is truthy when confirmation period is expired' do
+ travel_to(User.allow_unconfirmed_access_for.from_now + 1.day) do
+ expect(subject).to be_truthy
+ end
+ end
+
+ context 'when user has no confirmation email sent' do
+ let(:user) { build(:user, :unconfirmed, confirmation_sent_at: nil) }
+
+ it 'is truthy' do
+ expect(subject).to be_truthy
+ end
+ end
+ end
+ end
+ end
+
+ describe '#find_or_initialize_callout' do
+ subject(:find_or_initialize_callout) { user.find_or_initialize_callout(feature_name) }
+
+ let(:user) { create(:user) }
+ let(:feature_name) { UserCallout.feature_names.each_key.first }
+
+ context 'when callout exists' do
+ let!(:callout) { create(:user_callout, user: user, feature_name: feature_name) }
+
+ it 'returns existing callout' do
+ expect(find_or_initialize_callout).to eq(callout)
+ end
+ end
+
+ context 'when callout does not exist' do
+ context 'when feature name is valid' do
+ it 'initializes a new callout' do
+ expect(find_or_initialize_callout).to be_a_new(UserCallout)
+ end
+
+ it 'is valid' do
+ expect(find_or_initialize_callout).to be_valid
+ end
+ end
+
+ context 'when feature name is not valid' do
+ let(:feature_name) { 'notvalid' }
+
+ it 'initializes a new callout' do
+ expect(find_or_initialize_callout).to be_a_new(UserCallout)
+ end
+
+ it 'is not valid' do
+ expect(find_or_initialize_callout).not_to be_valid
+ end
+ end
+ end
+ end
end
diff --git a/spec/policies/base_policy_spec.rb b/spec/policies/base_policy_spec.rb
index 226660dc955..44ff909872d 100644
--- a/spec/policies/base_policy_spec.rb
+++ b/spec/policies/base_policy_spec.rb
@@ -73,10 +73,14 @@ RSpec.describe BasePolicy do
end
end
- describe 'full private access' do
+ describe 'full private access: read_all_resources' do
it_behaves_like 'admin only access', :read_all_resources
end
+ describe 'full private access: admin_all_resources' do
+ it_behaves_like 'admin only access', :admin_all_resources
+ end
+
describe 'change_repository_storage' do
it_behaves_like 'admin only access', :change_repository_storage
end
diff --git a/spec/policies/group_member_policy_spec.rb b/spec/policies/group_member_policy_spec.rb
index 6099e4549b1..d283b0ffda5 100644
--- a/spec/policies/group_member_policy_spec.rb
+++ b/spec/policies/group_member_policy_spec.rb
@@ -90,6 +90,14 @@ RSpec.describe GroupMemberPolicy do
specify { expect_allowed(:read_group) }
end
+ context 'with one blocked owner' do
+ let(:owner) { create(:user, :blocked) }
+ let(:current_user) { owner }
+
+ specify { expect_disallowed(*member_related_permissions) }
+ specify { expect_disallowed(:read_group) }
+ end
+
context 'with more than one owner' do
let(:current_user) { owner }
diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb
index 7cded27e449..1794934dd20 100644
--- a/spec/policies/group_policy_spec.rb
+++ b/spec/policies/group_policy_spec.rb
@@ -193,16 +193,24 @@ RSpec.describe GroupPolicy do
let(:current_user) { admin }
specify do
- expect_allowed(*read_group_permissions)
- expect_allowed(*guest_permissions)
- expect_allowed(*reporter_permissions)
- expect_allowed(*developer_permissions)
- expect_allowed(*maintainer_permissions)
- expect_allowed(*owner_permissions)
+ expect_disallowed(*read_group_permissions)
+ expect_disallowed(*guest_permissions)
+ expect_disallowed(*reporter_permissions)
+ expect_disallowed(*developer_permissions)
+ expect_disallowed(*maintainer_permissions)
+ expect_disallowed(*owner_permissions)
end
context 'with admin mode', :enable_admin_mode do
- specify { expect_allowed(*admin_permissions) }
+ specify do
+ expect_allowed(*read_group_permissions)
+ expect_allowed(*guest_permissions)
+ expect_allowed(*reporter_permissions)
+ expect_allowed(*developer_permissions)
+ expect_allowed(*maintainer_permissions)
+ expect_allowed(*owner_permissions)
+ expect_allowed(*admin_permissions)
+ end
end
it_behaves_like 'deploy token does not get confused with user' do
@@ -773,7 +781,13 @@ RSpec.describe GroupPolicy do
context 'admin' do
let(:current_user) { admin }
- it { is_expected.to be_allowed(:create_jira_connect_subscription) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed(:create_jira_connect_subscription) }
+ end
+
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_disallowed(:create_jira_connect_subscription) }
+ end
end
context 'with owner' do
@@ -817,7 +831,13 @@ RSpec.describe GroupPolicy do
context 'admin' do
let(:current_user) { admin }
- it { is_expected.to be_allowed(:read_package) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed(:read_package) }
+ end
+
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_disallowed(:read_package) }
+ end
end
context 'with owner' do
diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb
index 6ba3ab6aace..60c54f97312 100644
--- a/spec/policies/project_policy_spec.rb
+++ b/spec/policies/project_policy_spec.rb
@@ -64,8 +64,8 @@ RSpec.describe ProjectPolicy do
end
it 'disables boards and lists permissions' do
- expect_disallowed :read_board, :create_board, :update_board
- expect_disallowed :read_list, :create_list, :update_list, :admin_list
+ expect_disallowed :read_issue_board, :create_board, :update_board
+ expect_disallowed :read_issue_board_list, :create_list, :update_list, :admin_issue_board_list
end
context 'when external tracker configured' do
@@ -105,6 +105,10 @@ RSpec.describe ProjectPolicy do
context 'pipeline feature' do
let(:project) { private_project }
+ before do
+ private_project.add_developer(current_user)
+ end
+
describe 'for unconfirmed user' do
let(:current_user) { create(:user, confirmed_at: nil) }
@@ -1263,4 +1267,90 @@ RSpec.describe ProjectPolicy do
end
end
end
+
+ describe 'access_security_and_compliance' do
+ context 'when the "Security & Compliance" is enabled' do
+ before do
+ project.project_feature.update!(security_and_compliance_access_level: Featurable::PRIVATE)
+ end
+
+ %w[owner maintainer developer].each do |role|
+ context "when the role is #{role}" do
+ let(:current_user) { public_send(role) }
+
+ it { is_expected.to be_allowed(:access_security_and_compliance) }
+ end
+ end
+
+ context 'with admin' do
+ let(:current_user) { admin }
+
+ context 'when admin mode enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed(:access_security_and_compliance) }
+ end
+
+ context 'when admin mode disabled' do
+ it { is_expected.to be_disallowed(:access_security_and_compliance) }
+ end
+ end
+
+ %w[reporter guest].each do |role|
+ context "when the role is #{role}" do
+ let(:current_user) { public_send(role) }
+
+ it { is_expected.to be_disallowed(:access_security_and_compliance) }
+ end
+ end
+
+ context 'with non member' do
+ let(:current_user) { non_member }
+
+ it { is_expected.to be_disallowed(:access_security_and_compliance) }
+ end
+
+ context 'with anonymous' do
+ let(:current_user) { anonymous }
+
+ it { is_expected.to be_disallowed(:access_security_and_compliance) }
+ end
+ end
+
+ context 'when the "Security & Compliance" is not enabled' do
+ before do
+ project.project_feature.update!(security_and_compliance_access_level: Featurable::DISABLED)
+ end
+
+ %w[owner maintainer developer reporter guest].each do |role|
+ context "when the role is #{role}" do
+ let(:current_user) { public_send(role) }
+
+ it { is_expected.to be_disallowed(:access_security_and_compliance) }
+ end
+ end
+
+ context 'with admin' do
+ let(:current_user) { admin }
+
+ context 'when admin mode enabled', :enable_admin_mode do
+ it { is_expected.to be_disallowed(:access_security_and_compliance) }
+ end
+
+ context 'when admin mode disabled' do
+ it { is_expected.to be_disallowed(:access_security_and_compliance) }
+ end
+ end
+
+ context 'with non member' do
+ let(:current_user) { non_member }
+
+ it { is_expected.to be_disallowed(:access_security_and_compliance) }
+ end
+
+ context 'with anonymous' do
+ let(:current_user) { anonymous }
+
+ it { is_expected.to be_disallowed(:access_security_and_compliance) }
+ end
+ end
+ end
end
diff --git a/spec/presenters/ci/build_runner_presenter_spec.rb b/spec/presenters/ci/build_runner_presenter_spec.rb
index 43b677483ce..1eecc9d1ce6 100644
--- a/spec/presenters/ci/build_runner_presenter_spec.rb
+++ b/spec/presenters/ci/build_runner_presenter_spec.rb
@@ -271,4 +271,28 @@ RSpec.describe Ci::BuildRunnerPresenter do
end
end
end
+
+ describe '#variables' do
+ subject { presenter.variables }
+
+ let(:build) { create(:ci_build) }
+
+ it 'returns a Collection' do
+ is_expected.to be_an_instance_of(Gitlab::Ci::Variables::Collection)
+ end
+ end
+
+ describe '#runner_variables' do
+ subject { presenter.runner_variables }
+
+ let(:build) { create(:ci_build) }
+
+ it 'returns an array' do
+ is_expected.to be_an_instance_of(Array)
+ end
+
+ it 'returns the expected variables' do
+ is_expected.to eq(presenter.variables.to_runner_variables)
+ end
+ end
end
diff --git a/spec/presenters/packages/composer/packages_presenter_spec.rb b/spec/presenters/packages/composer/packages_presenter_spec.rb
index 19d99a62468..c4217b6e37c 100644
--- a/spec/presenters/packages/composer/packages_presenter_spec.rb
+++ b/spec/presenters/packages/composer/packages_presenter_spec.rb
@@ -67,10 +67,15 @@ RSpec.describe ::Packages::Composer::PackagesPresenter do
{
'packages' => [],
'provider-includes' => { 'p/%hash%.json' => { 'sha256' => /^\h+$/ } },
- 'providers-url' => "/api/v4/group/#{group.id}/-/packages/composer/%package%$%hash%.json"
+ 'providers-url' => "prefix/api/v4/group/#{group.id}/-/packages/composer/%package%$%hash%.json",
+ 'metadata-url' => "prefix/api/v4/group/#{group.id}/-/packages/composer/p2/%package%.json"
}
end
+ before do
+ stub_config(gitlab: { relative_url_root: 'prefix' })
+ end
+
it 'returns the provider json' do
expect(subject).to match(expected_json)
end
diff --git a/spec/presenters/packages/detail/package_presenter_spec.rb b/spec/presenters/packages/detail/package_presenter_spec.rb
index e38bbbe600c..5e20eed877f 100644
--- a/spec/presenters/packages/detail/package_presenter_spec.rb
+++ b/spec/presenters/packages/detail/package_presenter_spec.rb
@@ -16,7 +16,10 @@ RSpec.describe ::Packages::Detail::PackagePresenter do
created_at: file.created_at,
download_path: file.download_path,
file_name: file.file_name,
- size: file.size
+ size: file.size,
+ file_md5: file.file_md5,
+ file_sha1: file.file_sha1,
+ file_sha256: file.file_sha256
}
end
end
diff --git a/spec/presenters/project_presenter_spec.rb b/spec/presenters/project_presenter_spec.rb
index 98bcbd8384b..a9a5ecb3299 100644
--- a/spec/presenters/project_presenter_spec.rb
+++ b/spec/presenters/project_presenter_spec.rb
@@ -183,6 +183,14 @@ RSpec.describe ProjectPresenter do
context 'not empty repo' do
let(:project) { create(:project, :repository) }
+ context 'if no current user' do
+ let(:user) { nil }
+
+ it 'returns false' do
+ expect(presenter.can_current_user_push_code?).to be(false)
+ end
+ end
+
it 'returns true if user can push to default branch' do
project.add_developer(user)
@@ -350,7 +358,7 @@ RSpec.describe ProjectPresenter do
is_link: false,
label: a_string_including("New file"),
link: presenter.project_new_blob_path(project, 'master'),
- class_modifier: 'dashed'
+ class_modifier: 'btn-dashed'
)
end
@@ -555,6 +563,51 @@ RSpec.describe ProjectPresenter do
end
end
end
+
+ describe '#upload_anchor_data' do
+ context 'with empty_repo_upload enabled' do
+ before do
+ stub_experiments(empty_repo_upload: :candidate)
+ end
+
+ context 'user can push to branch' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'returns upload_anchor_data' do
+ expect(presenter.upload_anchor_data).to have_attributes(
+ is_link: false,
+ label: a_string_including('Upload file'),
+ data: {
+ "can_push_code" => "true",
+ "original_branch" => "master",
+ "path" => "/#{project.full_path}/-/create/master",
+ "project_path" => project.path,
+ "target_branch" => "master"
+ }
+ )
+ end
+ end
+
+ context 'user cannot push to branch' do
+ it 'returns nil' do
+ expect(presenter.upload_anchor_data).to be_nil
+ end
+ end
+ end
+
+ context 'with empty_repo_upload disabled' do
+ before do
+ stub_experiments(empty_repo_upload: :control)
+ project.add_developer(user)
+ end
+
+ it 'returns nil' do
+ expect(presenter.upload_anchor_data).to be_nil
+ end
+ end
+ end
end
describe '#statistics_buttons' do
@@ -594,13 +647,47 @@ RSpec.describe ProjectPresenter do
end
end
+ describe 'experiment(:repo_integrations_link)' do
+ context 'when enabled' do
+ before do
+ stub_experiments(repo_integrations_link: :candidate)
+ end
+
+ it 'includes a button to configure integrations for maintainers' do
+ project.add_maintainer(user)
+
+ expect(empty_repo_statistics_buttons.map(&:label)).to include(
+ a_string_including('Configure Integration')
+ )
+ end
+
+ it 'does not include a button if not a maintainer' do
+ expect(empty_repo_statistics_buttons.map(&:label)).not_to include(
+ a_string_including('Configure Integration')
+ )
+ end
+ end
+
+ context 'when disabled' do
+ it 'does not include a button' do
+ project.add_maintainer(user)
+
+ expect(empty_repo_statistics_buttons.map(&:label)).not_to include(
+ a_string_including('Configure Integration')
+ )
+ end
+ end
+ end
+
context 'for a developer' do
before do
project.add_developer(user)
+ stub_experiments(empty_repo_upload: :candidate)
end
it 'orders the items correctly' do
expect(empty_repo_statistics_buttons.map(&:label)).to start_with(
+ a_string_including('Upload'),
a_string_including('New'),
a_string_including('README'),
a_string_including('LICENSE'),
@@ -609,6 +696,16 @@ RSpec.describe ProjectPresenter do
a_string_including('CI/CD')
)
end
+
+ context 'when not in the upload experiment' do
+ before do
+ stub_experiments(empty_repo_upload: :control)
+ end
+
+ it 'does not include upload button' do
+ expect(empty_repo_statistics_buttons.map(&:label)).not_to start_with(a_string_including('Upload'))
+ end
+ end
end
end
@@ -694,4 +791,20 @@ RSpec.describe ProjectPresenter do
end
end
end
+
+ describe 'empty_repo_upload_experiment?' do
+ subject { presenter.empty_repo_upload_experiment? }
+
+ it 'returns false when upload_anchor_data is nil' do
+ allow(presenter).to receive(:upload_anchor_data).and_return(nil)
+
+ expect(subject).to be false
+ end
+
+ it 'returns true when upload_anchor_data exists' do
+ allow(presenter).to receive(:upload_anchor_data).and_return(true)
+
+ expect(subject).to be true
+ end
+ end
end
diff --git a/spec/presenters/projects/import_export/project_export_presenter_spec.rb b/spec/presenters/projects/import_export/project_export_presenter_spec.rb
index b2b2ce35f34..d5776ba2323 100644
--- a/spec/presenters/projects/import_export/project_export_presenter_spec.rb
+++ b/spec/presenters/projects/import_export/project_export_presenter_spec.rb
@@ -86,14 +86,22 @@ RSpec.describe Projects::ImportExport::ProjectExportPresenter do
context 'as admin' do
let(:user) { create(:admin) }
- it 'exports group members as admin' do
- expect(member_emails).to include('group@member.com')
- end
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'exports group members as admin' do
+ expect(member_emails).to include('group@member.com')
+ end
+
+ it 'exports group members as project members' do
+ member_types = subject.project_members.map { |pm| pm.source_type }
- it 'exports group members as project members' do
- member_types = subject.project_members.map { |pm| pm.source_type }
+ expect(member_types).to all(eq('Project'))
+ end
+ end
- expect(member_types).to all(eq('Project'))
+ context 'when admin mode is disabled' do
+ it 'does not export group members' do
+ expect(member_emails).not_to include('group@member.com')
+ end
end
end
end
diff --git a/spec/presenters/snippet_presenter_spec.rb b/spec/presenters/snippet_presenter_spec.rb
index a1d987ed78f..b0387206bd9 100644
--- a/spec/presenters/snippet_presenter_spec.rb
+++ b/spec/presenters/snippet_presenter_spec.rb
@@ -159,7 +159,7 @@ RSpec.describe SnippetPresenter do
let(:snippet) { create(:snippet, :repository, author: user) }
it 'returns repository first blob' do
- expect(subject).to eq snippet.blobs.first
+ expect(subject.name).to eq snippet.blobs.first.name
end
end
end
diff --git a/spec/requests/api/admin/plan_limits_spec.rb b/spec/requests/api/admin/plan_limits_spec.rb
new file mode 100644
index 00000000000..6bc133f67c0
--- /dev/null
+++ b/spec/requests/api/admin/plan_limits_spec.rb
@@ -0,0 +1,177 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Admin::PlanLimits, 'PlanLimits' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:plan) { create(:plan, name: 'default') }
+
+ describe 'GET /application/plan_limits' do
+ context 'as a non-admin user' do
+ it 'returns 403' do
+ get api('/application/plan_limits', user)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'as an admin user' do
+ context 'no params' do
+ it 'returns plan limits' do
+ get api('/application/plan_limits', admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Hash
+ expect(json_response['conan_max_file_size']).to eq(Plan.default.actual_limits.conan_max_file_size)
+ expect(json_response['generic_packages_max_file_size']).to eq(Plan.default.actual_limits.generic_packages_max_file_size)
+ expect(json_response['maven_max_file_size']).to eq(Plan.default.actual_limits.maven_max_file_size)
+ expect(json_response['npm_max_file_size']).to eq(Plan.default.actual_limits.npm_max_file_size)
+ expect(json_response['nuget_max_file_size']).to eq(Plan.default.actual_limits.nuget_max_file_size)
+ expect(json_response['pypi_max_file_size']).to eq(Plan.default.actual_limits.pypi_max_file_size)
+ end
+ end
+
+ context 'correct plan name in params' do
+ before do
+ @params = { plan_name: 'default' }
+ end
+
+ it 'returns plan limits' do
+ get api('/application/plan_limits', admin), params: @params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Hash
+ expect(json_response['conan_max_file_size']).to eq(Plan.default.actual_limits.conan_max_file_size)
+ expect(json_response['generic_packages_max_file_size']).to eq(Plan.default.actual_limits.generic_packages_max_file_size)
+ expect(json_response['maven_max_file_size']).to eq(Plan.default.actual_limits.maven_max_file_size)
+ expect(json_response['npm_max_file_size']).to eq(Plan.default.actual_limits.npm_max_file_size)
+ expect(json_response['nuget_max_file_size']).to eq(Plan.default.actual_limits.nuget_max_file_size)
+ expect(json_response['pypi_max_file_size']).to eq(Plan.default.actual_limits.pypi_max_file_size)
+ end
+ end
+
+ context 'invalid plan name in params' do
+ before do
+ @params = { plan_name: 'my-plan' }
+ end
+
+ it 'returns validation error' do
+ get api('/application/plan_limits', admin), params: @params
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq('plan_name does not have a valid value')
+ end
+ end
+ end
+ end
+
+ describe 'PUT /application/plan_limits' do
+ context 'as a non-admin user' do
+ it 'returns 403' do
+ put api('/application/plan_limits', user), params: { plan_name: 'default' }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'as an admin user' do
+ context 'correct params' do
+ it 'updates multiple plan limits' do
+ put api('/application/plan_limits', admin), params: {
+ 'plan_name': 'default',
+ 'conan_max_file_size': 10,
+ 'generic_packages_max_file_size': 20,
+ 'maven_max_file_size': 30,
+ 'npm_max_file_size': 40,
+ 'nuget_max_file_size': 50,
+ 'pypi_max_file_size': 60
+ }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Hash
+ expect(json_response['conan_max_file_size']).to eq(10)
+ expect(json_response['generic_packages_max_file_size']).to eq(20)
+ expect(json_response['maven_max_file_size']).to eq(30)
+ expect(json_response['npm_max_file_size']).to eq(40)
+ expect(json_response['nuget_max_file_size']).to eq(50)
+ expect(json_response['pypi_max_file_size']).to eq(60)
+ end
+
+ it 'updates single plan limits' do
+ put api('/application/plan_limits', admin), params: {
+ 'plan_name': 'default',
+ 'maven_max_file_size': 100
+ }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Hash
+ expect(json_response['maven_max_file_size']).to eq(100)
+ end
+ end
+
+ context 'empty params' do
+ it 'fails to update plan limits' do
+ put api('/application/plan_limits', admin), params: {}
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to match('plan_name is missing')
+ end
+ end
+
+ context 'params with wrong type' do
+ it 'fails to update plan limits' do
+ put api('/application/plan_limits', admin), params: {
+ 'plan_name': 'default',
+ 'conan_max_file_size': 'a',
+ 'generic_packages_max_file_size': 'b',
+ 'maven_max_file_size': 'c',
+ 'npm_max_file_size': 'd',
+ 'nuget_max_file_size': 'e',
+ 'pypi_max_file_size': 'f'
+ }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to include(
+ 'conan_max_file_size is invalid',
+ 'generic_packages_max_file_size is invalid',
+ 'maven_max_file_size is invalid',
+ 'generic_packages_max_file_size is invalid',
+ 'npm_max_file_size is invalid',
+ 'nuget_max_file_size is invalid',
+ 'pypi_max_file_size is invalid'
+ )
+ end
+ end
+
+ context 'missing plan_name in params' do
+ it 'fails to update plan limits' do
+ put api('/application/plan_limits', admin), params: { 'conan_max_file_size': 0 }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to match('plan_name is missing')
+ end
+ end
+
+ context 'additional undeclared params' do
+ before do
+ Plan.default.actual_limits.update!({ 'golang_max_file_size': 1000 })
+ end
+
+ it 'updates only declared plan limits' do
+ put api('/application/plan_limits', admin), params: {
+ 'plan_name': 'default',
+ 'pypi_max_file_size': 200,
+ 'golang_max_file_size': 999
+ }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Hash
+ expect(json_response['pypi_max_file_size']).to eq(200)
+ expect(json_response['golang_max_file_size']).to be_nil
+ expect(Plan.default.actual_limits.golang_max_file_size).to eq(1000)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/api_spec.rb b/spec/requests/api/api_spec.rb
index 8bd6049e6fa..522030652bd 100644
--- a/spec/requests/api/api_spec.rb
+++ b/spec/requests/api/api_spec.rb
@@ -112,6 +112,7 @@ RSpec.describe API::API do
'meta.project' => project.full_path,
'meta.root_namespace' => project.namespace.full_path,
'meta.user' => user.username,
+ 'meta.client_id' => an_instance_of(String),
'meta.feature_category' => 'issue_tracking')
end
end
@@ -125,6 +126,7 @@ RSpec.describe API::API do
expect(log_context).to match('correlation_id' => an_instance_of(String),
'meta.caller_id' => '/api/:version/users',
'meta.remote_ip' => an_instance_of(String),
+ 'meta.client_id' => an_instance_of(String),
'meta.feature_category' => 'users')
end
end
@@ -133,6 +135,28 @@ RSpec.describe API::API do
end
end
+ describe 'Marginalia comments' do
+ context 'GET /user/:id' do
+ let_it_be(:user) { create(:user) }
+ let(:component_map) do
+ {
+ "application" => "test",
+ "endpoint_id" => "/api/:version/users/:id"
+ }
+ end
+
+ subject { ActiveRecord::QueryRecorder.new { get api("/users/#{user.id}", user) } }
+
+ it 'generates a query that includes the expected annotations' do
+ expect(subject.log.last).to match(/correlation_id:.*/)
+
+ component_map.each do |component, value|
+ expect(subject.log.last).to include("#{component}:#{value}")
+ end
+ end
+ end
+ end
+
describe 'supported content-types' do
context 'GET /user/:id.txt' do
let_it_be(:user) { create(:user) }
diff --git a/spec/requests/api/ci/pipelines_spec.rb b/spec/requests/api/ci/pipelines_spec.rb
index a9afbd8bd72..d0c2b383013 100644
--- a/spec/requests/api/ci/pipelines_spec.rb
+++ b/spec/requests/api/ci/pipelines_spec.rb
@@ -34,7 +34,7 @@ RSpec.describe API::Ci::Pipelines do
expect(json_response.first['sha']).to match(/\A\h{40}\z/)
expect(json_response.first['id']).to eq pipeline.id
expect(json_response.first['web_url']).to be_present
- expect(json_response.first.keys).to contain_exactly(*%w[id sha ref status web_url created_at updated_at])
+ expect(json_response.first.keys).to contain_exactly(*%w[id project_id sha ref status web_url created_at updated_at])
end
context 'when parameter is passed' do
@@ -350,6 +350,7 @@ RSpec.describe API::Ci::Pipelines do
expect(json_job['pipeline']).not_to be_empty
expect(json_job['pipeline']['id']).to eq job.pipeline.id
+ expect(json_job['pipeline']['project_id']).to eq job.pipeline.project_id
expect(json_job['pipeline']['ref']).to eq job.pipeline.ref
expect(json_job['pipeline']['sha']).to eq job.pipeline.sha
expect(json_job['pipeline']['status']).to eq job.pipeline.status
@@ -512,6 +513,7 @@ RSpec.describe API::Ci::Pipelines do
expect(json_bridge['pipeline']).not_to be_empty
expect(json_bridge['pipeline']['id']).to eq bridge.pipeline.id
+ expect(json_bridge['pipeline']['project_id']).to eq bridge.pipeline.project_id
expect(json_bridge['pipeline']['ref']).to eq bridge.pipeline.ref
expect(json_bridge['pipeline']['sha']).to eq bridge.pipeline.sha
expect(json_bridge['pipeline']['status']).to eq bridge.pipeline.status
@@ -522,6 +524,7 @@ RSpec.describe API::Ci::Pipelines do
expect(json_bridge['downstream_pipeline']).not_to be_empty
expect(json_bridge['downstream_pipeline']['id']).to eq downstream_pipeline.id
+ expect(json_bridge['downstream_pipeline']['project_id']).to eq downstream_pipeline.project_id
expect(json_bridge['downstream_pipeline']['ref']).to eq downstream_pipeline.ref
expect(json_bridge['downstream_pipeline']['sha']).to eq downstream_pipeline.sha
expect(json_bridge['downstream_pipeline']['status']).to eq downstream_pipeline.status
diff --git a/spec/requests/api/ci/runner/jobs_artifacts_spec.rb b/spec/requests/api/ci/runner/jobs_artifacts_spec.rb
index 4d8da50f8f0..9369b6aa464 100644
--- a/spec/requests/api/ci/runner/jobs_artifacts_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_artifacts_spec.rb
@@ -17,9 +17,9 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
describe '/api/v4/jobs' do
- let(:root_namespace) { create(:namespace) }
- let(:namespace) { create(:namespace, parent: root_namespace) }
- let(:project) { create(:project, namespace: namespace, shared_runners_enabled: false) }
+ let(:parent_group) { create(:group) }
+ let(:group) { create(:group, parent: parent_group) }
+ let(:project) { create(:project, namespace: group, shared_runners_enabled: false) }
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') }
let(:runner) { create(:ci_runner, :project, projects: [project]) }
let(:user) { create(:user) }
@@ -78,7 +78,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
before do
stub_application_setting(max_artifacts_size: application_max_size)
- root_namespace.update!(max_artifacts_size: sample_max_size)
+ parent_group.update!(max_artifacts_size: sample_max_size)
end
it_behaves_like 'failed request'
@@ -90,8 +90,8 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
before do
stub_application_setting(max_artifacts_size: application_max_size)
- root_namespace.update!(max_artifacts_size: root_namespace_max_size)
- namespace.update!(max_artifacts_size: sample_max_size)
+ parent_group.update!(max_artifacts_size: root_namespace_max_size)
+ group.update!(max_artifacts_size: sample_max_size)
end
it_behaves_like 'failed request'
@@ -104,8 +104,8 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
before do
stub_application_setting(max_artifacts_size: application_max_size)
- root_namespace.update!(max_artifacts_size: root_namespace_max_size)
- namespace.update!(max_artifacts_size: child_namespace_max_size)
+ parent_group.update!(max_artifacts_size: root_namespace_max_size)
+ group.update!(max_artifacts_size: child_namespace_max_size)
project.update!(max_artifacts_size: sample_max_size)
end
diff --git a/spec/requests/api/ci/runner/jobs_put_spec.rb b/spec/requests/api/ci/runner/jobs_put_spec.rb
index f4c99307b1a..b5d2c4608c5 100644
--- a/spec/requests/api/ci/runner/jobs_put_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_put_spec.rb
@@ -17,9 +17,8 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
describe '/api/v4/jobs' do
- let(:root_namespace) { create(:namespace) }
- let(:namespace) { create(:namespace, parent: root_namespace) }
- let(:project) { create(:project, namespace: namespace, shared_runners_enabled: false) }
+ let(:group) { create(:group, :nested) }
+ let(:project) { create(:project, namespace: group, shared_runners_enabled: false) }
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') }
let(:runner) { create(:ci_runner, :project, projects: [project]) }
let(:user) { create(:user) }
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 74d8e3f7ae8..aced094e219 100644
--- a/spec/requests/api/ci/runner/jobs_request_post_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_request_post_spec.rb
@@ -17,9 +17,8 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
describe '/api/v4/jobs' do
- let(:root_namespace) { create(:namespace) }
- let(:namespace) { create(:namespace, parent: root_namespace) }
- let(:project) { create(:project, namespace: namespace, shared_runners_enabled: false) }
+ let(:group) { create(:group, :nested) }
+ let(:project) { create(:project, namespace: group, shared_runners_enabled: false) }
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') }
let(:runner) { create(:ci_runner, :project, projects: [project]) }
let(:user) { create(:user) }
@@ -198,7 +197,12 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
'when' => 'on_success' }]
end
- let(:expected_features) { { 'trace_sections' => true } }
+ let(:expected_features) do
+ {
+ 'trace_sections' => true,
+ 'failure_reasons' => include('script_failure')
+ }
+ end
it 'picks a job' do
request_job info: { platform: :darwin }
@@ -220,7 +224,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
expect(json_response['artifacts']).to eq(expected_artifacts)
expect(json_response['cache']).to eq(expected_cache)
expect(json_response['variables']).to include(*expected_variables)
- expect(json_response['features']).to eq(expected_features)
+ expect(json_response['features']).to match(expected_features)
end
it 'creates persistent ref' do
@@ -793,6 +797,50 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
end
+ describe 'setting the application context' do
+ subject { request_job }
+
+ context 'when triggered by a user' do
+ let(:job) { create(:ci_build, user: user, project: project) }
+
+ subject { request_job(id: job.id) }
+
+ it_behaves_like 'storing arguments in the application context' do
+ let(:expected_params) { { user: user.username, project: project.full_path, client_id: "user/#{user.id}" } }
+ end
+
+ it_behaves_like 'not executing any extra queries for the application context', 3 do
+ # Extra queries: User, Project, Route
+ let(:subject_proc) { proc { request_job(id: job.id) } }
+ end
+ end
+
+ context 'when the runner is of project type' do
+ it_behaves_like 'storing arguments in the application context' do
+ let(:expected_params) { { project: project.full_path, client_id: "runner/#{runner.id}" } }
+ end
+
+ it_behaves_like 'not executing any extra queries for the application context', 2 do
+ # Extra queries: Project, Route
+ let(:subject_proc) { proc { request_job } }
+ end
+ end
+
+ context 'when the runner is of group type' do
+ let(:group) { create(:group) }
+ let(:runner) { create(:ci_runner, :group, groups: [group]) }
+
+ it_behaves_like 'storing arguments in the application context' do
+ let(:expected_params) { { root_namespace: group.full_path_components.first, client_id: "runner/#{runner.id}" } }
+ end
+
+ it_behaves_like 'not executing any extra queries for the application context', 2 do
+ # Extra queries: Group, Route
+ let(:subject_proc) { proc { request_job } }
+ end
+ end
+ end
+
def request_job(token = runner.token, **params)
new_params = params.merge(token: token, last_update: last_update)
post api('/jobs/request'), params: new_params.to_json, headers: { 'User-Agent' => user_agent, 'Content-Type': 'application/json' }
diff --git a/spec/requests/api/ci/runner/jobs_trace_spec.rb b/spec/requests/api/ci/runner/jobs_trace_spec.rb
index 5b7a33d23d8..659cf055023 100644
--- a/spec/requests/api/ci/runner/jobs_trace_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_trace_spec.rb
@@ -17,9 +17,8 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
describe '/api/v4/jobs' do
- let(:root_namespace) { create(:namespace) }
- let(:namespace) { create(:namespace, parent: root_namespace) }
- let(:project) { create(:project, namespace: namespace, shared_runners_enabled: false) }
+ let(:group) { create(:group, :nested) }
+ let(:project) { create(:project, namespace: group, shared_runners_enabled: false) }
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master') }
let(:runner) { create(:ci_runner, :project, projects: [project]) }
let(:user) { create(:user) }
diff --git a/spec/requests/api/ci/runner/runners_delete_spec.rb b/spec/requests/api/ci/runner/runners_delete_spec.rb
index 75960a1a1c0..6c6c465f161 100644
--- a/spec/requests/api/ci/runner/runners_delete_spec.rb
+++ b/spec/requests/api/ci/runner/runners_delete_spec.rb
@@ -37,8 +37,10 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
context 'when valid token is provided' do
let(:runner) { create(:ci_runner) }
+ subject { delete api('/runners'), params: { token: runner.token } }
+
it 'deletes Runner' do
- delete api('/runners'), params: { token: runner.token }
+ subject
expect(response).to have_gitlab_http_status(:no_content)
expect(::Ci::Runner.count).to eq(0)
@@ -48,6 +50,10 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
let(:request) { api('/runners') }
let(:params) { { token: runner.token } }
end
+
+ it_behaves_like 'storing arguments in the application context' do
+ let(:expected_params) { { client_id: "runner/#{runner.id}" } }
+ end
end
end
end
diff --git a/spec/requests/api/ci/runner/runners_post_spec.rb b/spec/requests/api/ci/runner/runners_post_spec.rb
index 7c362fae7d2..7984b1d4ca8 100644
--- a/spec/requests/api/ci/runner/runners_post_spec.rb
+++ b/spec/requests/api/ci/runner/runners_post_spec.rb
@@ -35,25 +35,44 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
context 'when valid token is provided' do
- it 'creates runner with default values' do
- post api('/runners'), params: { token: registration_token }
+ def request
+ post api('/runners'), params: { token: token }
+ end
- runner = ::Ci::Runner.first
+ context 'with a registration token' do
+ let(:token) { registration_token }
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['id']).to eq(runner.id)
- expect(json_response['token']).to eq(runner.token)
- expect(runner.run_untagged).to be true
- expect(runner.active).to be true
- expect(runner.token).not_to eq(registration_token)
- expect(runner).to be_instance_type
+ it 'creates runner with default values' do
+ request
+
+ runner = ::Ci::Runner.first
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['id']).to eq(runner.id)
+ expect(json_response['token']).to eq(runner.token)
+ expect(runner.run_untagged).to be true
+ expect(runner.active).to be true
+ expect(runner.token).not_to eq(registration_token)
+ expect(runner).to be_instance_type
+ end
+
+ it_behaves_like 'storing arguments in the application context' do
+ subject { request }
+
+ let(:expected_params) { { client_id: "runner/#{::Ci::Runner.first.id}" } }
+ end
+
+ it_behaves_like 'not executing any extra queries for the application context' do
+ let(:subject_proc) { proc { request } }
+ end
end
context 'when project token is used' do
let(:project) { create(:project) }
+ let(:token) { project.runners_token }
it 'creates project runner' do
- post api('/runners'), params: { token: project.runners_token }
+ request
expect(response).to have_gitlab_http_status(:created)
expect(project.runners.size).to eq(1)
@@ -62,13 +81,24 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
expect(runner.token).not_to eq(project.runners_token)
expect(runner).to be_project_type
end
+
+ it_behaves_like 'storing arguments in the application context' do
+ subject { request }
+
+ let(:expected_params) { { project: project.full_path, client_id: "runner/#{::Ci::Runner.first.id}" } }
+ end
+
+ it_behaves_like 'not executing any extra queries for the application context' do
+ let(:subject_proc) { proc { request } }
+ end
end
context 'when group token is used' do
let(:group) { create(:group) }
+ let(:token) { group.runners_token }
it 'creates a group runner' do
- post api('/runners'), params: { token: group.runners_token }
+ request
expect(response).to have_gitlab_http_status(:created)
expect(group.runners.reload.size).to eq(1)
@@ -77,6 +107,16 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
expect(runner.token).not_to eq(group.runners_token)
expect(runner).to be_group_type
end
+
+ it_behaves_like 'storing arguments in the application context' do
+ subject { request }
+
+ let(:expected_params) { { root_namespace: group.full_path_components.first, client_id: "runner/#{::Ci::Runner.first.id}" } }
+ end
+
+ it_behaves_like 'not executing any extra queries for the application context' do
+ let(:subject_proc) { proc { request } }
+ end
end
end
diff --git a/spec/requests/api/ci/runner/runners_verify_post_spec.rb b/spec/requests/api/ci/runner/runners_verify_post_spec.rb
index e2f5f9b2d68..c2e97446738 100644
--- a/spec/requests/api/ci/runner/runners_verify_post_spec.rb
+++ b/spec/requests/api/ci/runner/runners_verify_post_spec.rb
@@ -37,11 +37,17 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
context 'when valid token is provided' do
+ subject { post api('/runners/verify'), params: { token: runner.token } }
+
it 'verifies Runner credentials' do
- post api('/runners/verify'), params: { token: runner.token }
+ subject
expect(response).to have_gitlab_http_status(:ok)
end
+
+ it_behaves_like 'storing arguments in the application context' do
+ let(:expected_params) { { client_id: "runner/#{runner.id}" } }
+ end
end
end
end
diff --git a/spec/requests/api/commit_statuses_spec.rb b/spec/requests/api/commit_statuses_spec.rb
index bec15b788c3..10fa15d468f 100644
--- a/spec/requests/api/commit_statuses_spec.rb
+++ b/spec/requests/api/commit_statuses_spec.rb
@@ -291,7 +291,7 @@ RSpec.describe API::CommitStatuses do
end
context 'when retrying a commit status' do
- before do
+ subject(:post_request) do
post api(post_url, developer),
params: { state: 'failed', name: 'test', ref: 'master' }
@@ -300,15 +300,45 @@ RSpec.describe API::CommitStatuses do
end
it 'correctly posts a new commit status' do
+ post_request
+
expect(response).to have_gitlab_http_status(:created)
expect(json_response['sha']).to eq(commit.id)
expect(json_response['status']).to eq('success')
end
- it 'retries a commit status', :sidekiq_might_not_need_inline do
- expect(CommitStatus.count).to eq 2
- expect(CommitStatus.first).to be_retried
- expect(CommitStatus.last.pipeline).to be_success
+ context 'feature flags' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:ci_fix_commit_status_retried, :ci_remove_update_retried_from_process_pipeline, :previous_statuses_retried) do
+ true | true | true
+ true | false | true
+ false | true | false
+ false | false | true
+ end
+
+ with_them do
+ before do
+ stub_feature_flags(
+ ci_fix_commit_status_retried: ci_fix_commit_status_retried,
+ ci_remove_update_retried_from_process_pipeline: ci_remove_update_retried_from_process_pipeline
+ )
+ end
+
+ it 'retries a commit status', :sidekiq_might_not_need_inline do
+ post_request
+
+ expect(CommitStatus.count).to eq 2
+
+ if previous_statuses_retried
+ expect(CommitStatus.first).to be_retried
+ expect(CommitStatus.last.pipeline).to be_success
+ else
+ expect(CommitStatus.first).not_to be_retried
+ expect(CommitStatus.last.pipeline).to be_failed
+ end
+ end
+ end
end
end
diff --git a/spec/requests/api/composer_packages_spec.rb b/spec/requests/api/composer_packages_spec.rb
index 06d4a2c6017..30a831d24fd 100644
--- a/spec/requests/api/composer_packages_spec.rb
+++ b/spec/requests/api/composer_packages_spec.rb
@@ -222,6 +222,52 @@ RSpec.describe API::ComposerPackages do
it_behaves_like 'rejects Composer access with unknown group id'
end
+ describe 'GET /api/v4/group/:id/-/packages/composer/p2/*package_name.json' do
+ let(:package_name) { 'foobar' }
+ let(:url) { "/group/#{group.id}/-/packages/composer/p2/#{package_name}.json" }
+
+ subject { get api(url), headers: headers }
+
+ context 'with no packages' do
+ include_context 'Composer user type', :developer, true do
+ it_behaves_like 'returning response status', :not_found
+ end
+ end
+
+ context 'with valid project' do
+ let!(:package) { create(:composer_package, :with_metadatum, name: package_name, project: project) }
+
+ where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do
+ 'PUBLIC' | :developer | true | true | 'Composer package api request' | :success
+ 'PUBLIC' | :developer | true | false | 'process Composer api request' | :unauthorized
+ 'PUBLIC' | :developer | false | true | 'Composer package api request' | :success
+ 'PUBLIC' | :developer | false | false | 'process Composer api request' | :unauthorized
+ 'PUBLIC' | :guest | true | true | 'Composer package api request' | :success
+ 'PUBLIC' | :guest | true | false | 'process Composer api request' | :unauthorized
+ 'PUBLIC' | :guest | false | true | 'Composer package api request' | :success
+ 'PUBLIC' | :guest | false | false | 'process Composer api request' | :unauthorized
+ 'PUBLIC' | :anonymous | false | true | 'Composer package api request' | :success
+ 'PRIVATE' | :developer | true | true | 'Composer package api request' | :success
+ 'PRIVATE' | :developer | true | false | 'process Composer api request' | :unauthorized
+ 'PRIVATE' | :developer | false | true | 'process Composer api request' | :not_found
+ 'PRIVATE' | :developer | false | false | 'process Composer api request' | :unauthorized
+ 'PRIVATE' | :guest | true | true | 'process Composer api request' | :not_found
+ 'PRIVATE' | :guest | true | false | 'process Composer api request' | :unauthorized
+ 'PRIVATE' | :guest | false | true | 'process Composer api request' | :not_found
+ 'PRIVATE' | :guest | false | false | 'process Composer api request' | :unauthorized
+ 'PRIVATE' | :anonymous | false | true | 'process Composer api request' | :not_found
+ end
+
+ with_them do
+ include_context 'Composer api group access', params[:project_visibility_level], params[:user_role], params[:user_token] do
+ it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member]
+ end
+ end
+ end
+
+ it_behaves_like 'rejects Composer access with unknown group id'
+ end
+
describe 'POST /api/v4/projects/:id/packages/composer' do
let(:url) { "/projects/#{project.id}/packages/composer" }
let(:params) { {} }
diff --git a/spec/requests/api/discussions_spec.rb b/spec/requests/api/discussions_spec.rb
index bdfc1589c9e..258bd26c05a 100644
--- a/spec/requests/api/discussions_spec.rb
+++ b/spec/requests/api/discussions_spec.rb
@@ -68,18 +68,11 @@ RSpec.describe API::Discussions do
mr_commit = '0b4bc9a49b562e85de7cc9e834518ea6828729b9'
parent_commit = 'ae73cb07c9eeaf35924a10f713b364d32b2dd34f'
file = "files/ruby/feature.rb"
- line_range = {
- "start_line_code" => Gitlab::Git.diff_line_code(file, 1, 1),
- "end_line_code" => Gitlab::Git.diff_line_code(file, 1, 1),
- "start_line_type" => "text",
- "end_line_type" => "text"
- }
position = build(
:text_diff_position,
:added,
file: file,
new_line: 1,
- line_range: line_range,
base_sha: parent_commit,
head_sha: mr_commit,
start_sha: parent_commit
diff --git a/spec/requests/api/environments_spec.rb b/spec/requests/api/environments_spec.rb
index b1ac8f9eeec..303e510883d 100644
--- a/spec/requests/api/environments_spec.rb
+++ b/spec/requests/api/environments_spec.rb
@@ -265,4 +265,76 @@ RSpec.describe API::Environments do
end
end
end
+
+ describe "DELETE /projects/:id/environments/review_apps" do
+ shared_examples "delete stopped review environments" do
+ around do |example|
+ freeze_time { example.run }
+ end
+
+ it "deletes the old stopped review apps" do
+ old_stopped_review_env = create(:environment, :with_review_app, :stopped, created_at: 31.days.ago, project: project)
+ new_stopped_review_env = create(:environment, :with_review_app, :stopped, project: project)
+ old_active_review_env = create(:environment, :with_review_app, :available, created_at: 31.days.ago, project: project)
+ old_stopped_other_env = create(:environment, :stopped, created_at: 31.days.ago, project: project)
+ new_stopped_other_env = create(:environment, :stopped, project: project)
+ old_active_other_env = create(:environment, :available, created_at: 31.days.ago, project: project)
+
+ delete api("/projects/#{project.id}/environments/review_apps", current_user), params: { dry_run: false }
+ project.environments.reload
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response["scheduled_entries"].size).to eq(1)
+ expect(json_response["scheduled_entries"].first["id"]).to eq(old_stopped_review_env.id)
+ expect(json_response["unprocessable_entries"].size).to eq(0)
+
+ expect(old_stopped_review_env.reload.auto_delete_at).to eq(1.week.from_now)
+ expect(new_stopped_review_env.reload.auto_delete_at).to be_nil
+ expect(old_active_review_env.reload.auto_delete_at).to be_nil
+ expect(old_stopped_other_env.reload.auto_delete_at).to be_nil
+ expect(new_stopped_other_env.reload.auto_delete_at).to be_nil
+ expect(old_active_other_env.reload.auto_delete_at).to be_nil
+ end
+ end
+
+ context "as a maintainer" do
+ it_behaves_like "delete stopped review environments" do
+ let(:current_user) { user }
+ end
+ end
+
+ context "as a developer" do
+ let(:developer) { create(:user) }
+
+ before do
+ project.add_developer(developer)
+ end
+
+ it_behaves_like "delete stopped review environments" do
+ let(:current_user) { developer }
+ end
+ end
+
+ context "as a reporter" do
+ let(:reporter) { create(:user) }
+
+ before do
+ project.add_reporter(reporter)
+ end
+
+ it "rejects the request" do
+ delete api("/projects/#{project.id}/environments/review_apps", reporter)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context "as a non member" do
+ it "rejects the request" do
+ delete api("/projects/#{project.id}/environments/review_apps", non_member)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
end
diff --git a/spec/requests/api/generic_packages_spec.rb b/spec/requests/api/generic_packages_spec.rb
index a47be1ead9c..16d56b6cfbe 100644
--- a/spec/requests/api/generic_packages_spec.rb
+++ b/spec/requests/api/generic_packages_spec.rb
@@ -444,17 +444,17 @@ RSpec.describe API::GenericPackages do
'PUBLIC' | :guest | true | :user_basic_auth | :success
'PUBLIC' | :developer | true | :invalid_personal_access_token | :unauthorized
'PUBLIC' | :guest | true | :invalid_personal_access_token | :unauthorized
- 'PUBLIC' | :developer | true | :invalid_user_basic_auth | :unauthorized
- 'PUBLIC' | :guest | true | :invalid_user_basic_auth | :unauthorized
+ 'PUBLIC' | :developer | true | :invalid_user_basic_auth | :success
+ 'PUBLIC' | :guest | true | :invalid_user_basic_auth | :success
'PUBLIC' | :developer | false | :personal_access_token | :success
'PUBLIC' | :guest | false | :personal_access_token | :success
'PUBLIC' | :developer | false | :user_basic_auth | :success
'PUBLIC' | :guest | false | :user_basic_auth | :success
'PUBLIC' | :developer | false | :invalid_personal_access_token | :unauthorized
'PUBLIC' | :guest | false | :invalid_personal_access_token | :unauthorized
- 'PUBLIC' | :developer | false | :invalid_user_basic_auth | :unauthorized
- 'PUBLIC' | :guest | false | :invalid_user_basic_auth | :unauthorized
- 'PUBLIC' | :anonymous | false | :none | :unauthorized
+ 'PUBLIC' | :developer | false | :invalid_user_basic_auth | :success
+ 'PUBLIC' | :guest | false | :invalid_user_basic_auth | :success
+ 'PUBLIC' | :anonymous | false | :none | :success
'PRIVATE' | :developer | true | :personal_access_token | :success
'PRIVATE' | :guest | true | :personal_access_token | :forbidden
'PRIVATE' | :developer | true | :user_basic_auth | :success
diff --git a/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb b/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb
index 44f924d8ae5..356e1e11def 100644
--- a/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb
+++ b/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe 'container repository details' do
graphql_query_for(
'containerRepository',
{ id: container_repository_global_id },
- all_graphql_fields_for('ContainerRepositoryDetails')
+ all_graphql_fields_for('ContainerRepositoryDetails', excluded: ['pipeline'])
)
end
diff --git a/spec/requests/api/graphql/group/container_repositories_spec.rb b/spec/requests/api/graphql/group/container_repositories_spec.rb
index 4aa775eba0f..939d7791d92 100644
--- a/spec/requests/api/graphql/group/container_repositories_spec.rb
+++ b/spec/requests/api/graphql/group/container_repositories_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe 'getting container repositories in a group' do
<<~GQL
edges {
node {
- #{all_graphql_fields_for('container_repositories'.classify)}
+ #{all_graphql_fields_for('container_repositories'.classify, max_depth: 1)}
}
}
GQL
diff --git a/spec/requests/api/graphql/group/packages_spec.rb b/spec/requests/api/graphql/group/packages_spec.rb
new file mode 100644
index 00000000000..85775598b2e
--- /dev/null
+++ b/spec/requests/api/graphql/group/packages_spec.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'getting a package list for a group' do
+ include GraphqlHelpers
+
+ let_it_be(:resource) { create(:group, :private) }
+ let_it_be(:group_two) { create(:group, :private) }
+ let_it_be(:project) { create(:project, :repository, group: resource) }
+ let_it_be(:another_project) { create(:project, :repository, group: resource) }
+ let_it_be(:group_two_project) { create(:project, :repository, group: group_two) }
+ let_it_be(:current_user) { create(:user) }
+
+ let_it_be(:package) { create(:package, project: project) }
+ let_it_be(:npm_package) { create(:npm_package, project: group_two_project) }
+ let_it_be(:maven_package) { create(:maven_package, project: project) }
+ let_it_be(:debian_package) { create(:debian_package, project: another_project) }
+ let_it_be(:composer_package) { create(:composer_package, project: another_project) }
+ let_it_be(:composer_metadatum) do
+ create(:composer_metadatum, package: composer_package,
+ target_sha: 'afdeh',
+ composer_json: { name: 'x', type: 'y', license: 'z', version: 1 })
+ end
+
+ let(:package_names) { graphql_data_at(:group, :packages, :nodes, :name) }
+ let(:target_shas) { graphql_data_at(:group, :packages, :nodes, :metadata, :target_sha) }
+ let(:packages) { graphql_data_at(:group, :packages, :nodes) }
+
+ let(:fields) do
+ <<~QUERY
+ nodes {
+ #{all_graphql_fields_for('packages'.classify, excluded: ['project'])}
+ metadata { #{query_graphql_fragment('ComposerMetadata')} }
+ }
+ QUERY
+ end
+
+ let(:query) do
+ graphql_query_for(
+ 'group',
+ { 'fullPath' => resource.full_path },
+ query_graphql_field('packages', {}, fields)
+ )
+ end
+
+ it_behaves_like 'group and project packages query'
+
+ context 'with a batched query' do
+ let(:batch_query) do
+ <<~QUERY
+ {
+ a: group(fullPath: "#{resource.full_path}") { packages { nodes { name } } }
+ b: group(fullPath: "#{group_two.full_path}") { packages { nodes { name } } }
+ }
+ QUERY
+ end
+
+ let(:a_packages_names) { graphql_data_at(:a, :packages, :nodes, :name) }
+
+ before do
+ resource.add_reporter(current_user)
+ group_two.add_reporter(current_user)
+ post_graphql(batch_query, current_user: current_user)
+ end
+
+ it 'returns an error for the second group and data for the first' do
+ expect(a_packages_names).to contain_exactly(
+ package.name,
+ maven_package.name,
+ debian_package.name,
+ composer_package.name
+ )
+ expect_graphql_errors_to_include [/Packages can be requested only for one group at a time/]
+ expect(graphql_data_at(:b, :packages)).to be(nil)
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/issue/issue_spec.rb b/spec/requests/api/graphql/issue/issue_spec.rb
index 09e89f65882..e8b8caf6c2d 100644
--- a/spec/requests/api/graphql/issue/issue_spec.rb
+++ b/spec/requests/api/graphql/issue/issue_spec.rb
@@ -8,10 +8,9 @@ RSpec.describe 'Query.issue(id)' do
let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:current_user) { create(:user) }
+ let_it_be(:issue_params) { { 'id' => issue.to_global_id.to_s } }
let(:issue_data) { graphql_data['issue'] }
-
- let_it_be(:issue_params) { { 'id' => issue.to_global_id.to_s } }
let(:issue_fields) { all_graphql_fields_for('Issue'.classify) }
let(:query) do
@@ -62,7 +61,7 @@ RSpec.describe 'Query.issue(id)' do
)
end
- context 'selecting any single field' do
+ context 'when selecting any single field' do
where(:field) do
scalar_fields_of('Issue').map { |name| [name] }
end
@@ -84,13 +83,13 @@ RSpec.describe 'Query.issue(id)' do
end
end
- context 'selecting multiple fields' do
+ context 'when selecting multiple fields' do
let(:issue_fields) { ['title', 'description', 'updatedBy { username }'] }
it 'returns the Issue with the specified fields' do
post_graphql(query, current_user: current_user)
- expect(issue_data.keys).to eq( %w(title description updatedBy) )
+ expect(issue_data.keys).to eq %w[title description updatedBy]
expect(issue_data['title']).to eq(issue.title)
expect(issue_data['description']).to eq(issue.description)
expect(issue_data['updatedBy']['username']).to eq(issue.author.username)
@@ -110,14 +109,14 @@ RSpec.describe 'Query.issue(id)' do
it 'returns correct attributes' do
post_graphql(query, current_user: current_user)
- expect(issue_data.keys).to eq( %w(moved movedTo) )
+ expect(issue_data.keys).to eq %w[moved movedTo]
expect(issue_data['moved']).to eq(true)
expect(issue_data['movedTo']['title']).to eq(new_issue.title)
end
end
context 'when passed a non-Issue gid' do
- let(:mr) {create(:merge_request)}
+ let(:mr) { create(:merge_request) }
it 'returns an error' do
gid = mr.to_global_id.to_s
diff --git a/spec/requests/api/graphql/mutations/alert_management/alerts/create_alert_issue_spec.rb b/spec/requests/api/graphql/mutations/alert_management/alerts/create_alert_issue_spec.rb
index 6141a172253..f637ca98353 100644
--- a/spec/requests/api/graphql/mutations/alert_management/alerts/create_alert_issue_spec.rb
+++ b/spec/requests/api/graphql/mutations/alert_management/alerts/create_alert_issue_spec.rb
@@ -20,7 +20,9 @@ RSpec.describe 'Create an alert issue from an alert' do
errors
alert {
iid
- issueIid
+ issue {
+ iid
+ }
}
issue {
iid
@@ -46,7 +48,7 @@ RSpec.describe 'Create an alert issue from an alert' do
expect(mutation_response.slice('alert', 'issue')).to eq(
'alert' => {
'iid' => alert.iid.to_s,
- 'issueIid' => new_issue.iid.to_s
+ 'issue' => { 'iid' => new_issue.iid.to_s }
},
'issue' => {
'iid' => new_issue.iid.to_s,
diff --git a/spec/requests/api/graphql/mutations/merge_requests/accept_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/accept_spec.rb
new file mode 100644
index 00000000000..2725b33d528
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/merge_requests/accept_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'accepting a merge request', :request_store do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let!(:merge_request) { create(:merge_request, source_project: project) }
+ let(:input) do
+ {
+ project_path: project.full_path,
+ iid: merge_request.iid.to_s,
+ sha: merge_request.diff_head_sha
+ }
+ end
+
+ let(:mutation) { graphql_mutation(:merge_request_accept, input, 'mergeRequest { state }') }
+ let(:mutation_response) { graphql_mutation_response(:merge_request_accept) }
+
+ context 'when the user is not allowed to accept a merge request' do
+ before do
+ project.add_reporter(current_user)
+ end
+
+ it_behaves_like 'a mutation that returns a top-level access error'
+ end
+
+ context 'when user has permissions to create a merge request' do
+ before do
+ project.add_maintainer(current_user)
+ end
+
+ it 'merges the merge request' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['mergeRequest']).to include(
+ 'state' => 'merged'
+ )
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/notes/create/diff_note_spec.rb b/spec/requests/api/graphql/mutations/notes/create/diff_note_spec.rb
index 7dd897f6466..b5aaf304812 100644
--- a/spec/requests/api/graphql/mutations/notes/create/diff_note_spec.rb
+++ b/spec/requests/api/graphql/mutations/notes/create/diff_note_spec.rb
@@ -9,8 +9,9 @@ RSpec.describe 'Adding a DiffNote' do
let(:noteable) { create(:merge_request, source_project: project, target_project: project) }
let(:project) { create(:project, :repository) }
let(:diff_refs) { noteable.diff_refs }
- let(:mutation) do
- variables = {
+
+ let(:base_variables) do
+ {
noteable_id: GitlabSchema.id_from_object(noteable).to_s,
body: 'Body text',
position: {
@@ -18,16 +19,16 @@ RSpec.describe 'Adding a DiffNote' do
old_path: 'files/ruby/popen.rb',
new_path: 'files/ruby/popen2.rb'
},
- new_line: 14,
base_sha: diff_refs.base_sha,
head_sha: diff_refs.head_sha,
start_sha: diff_refs.start_sha
}
}
-
- graphql_mutation(:create_diff_note, variables)
end
+ let(:variables) { base_variables.deep_merge({ position: { new_line: 14 } }) }
+ let(:mutation) { graphql_mutation(:create_diff_note, variables) }
+
def mutation_response
graphql_mutation_response(:create_diff_note)
end
@@ -41,6 +42,18 @@ RSpec.describe 'Adding a DiffNote' do
it_behaves_like 'a Note mutation that creates a Note'
+ context 'add comment to old line' do
+ let(:variables) { base_variables.deep_merge({ position: { old_line: 14 } }) }
+
+ it_behaves_like 'a Note mutation that creates a Note'
+ end
+
+ context 'add a comment with a position without lines' do
+ let(:variables) { base_variables }
+
+ it_behaves_like 'a Note mutation that does not create a Note'
+ end
+
it_behaves_like 'a Note mutation when there are active record validation errors', model: DiffNote
it_behaves_like 'a Note mutation when there are rate limit validation errors'
diff --git a/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb b/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb
new file mode 100644
index 00000000000..c7a4cb1ebce
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/release_asset_links/create_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Creation of a new release asset link' do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project, :private, :repository) }
+ let_it_be(:release) { create(:release, project: project, tag: 'v13.10') }
+ let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
+
+ let(:current_user) { developer }
+
+ let(:mutation_name) { :release_asset_link_create }
+
+ let(:mutation_arguments) do
+ {
+ projectPath: project.full_path,
+ tagName: release.tag,
+ name: 'awesome-app.dmg',
+ url: 'https://example.com/download/awesome-app.dmg',
+ directAssetPath: '/binaries/awesome-app.dmg',
+ linkType: 'PACKAGE'
+ }
+ end
+
+ let(:mutation) do
+ graphql_mutation(mutation_name, mutation_arguments, <<~FIELDS)
+ link {
+ id
+ name
+ url
+ linkType
+ directAssetUrl
+ external
+ }
+ errors
+ FIELDS
+ end
+
+ let(:create_link) { post_graphql_mutation(mutation, current_user: current_user) }
+ let(:mutation_response) { graphql_mutation_response(mutation_name)&.with_indifferent_access }
+
+ it 'creates and returns a new asset link associated to the provided release', :aggregate_failures do
+ create_link
+
+ expected_response = {
+ id: start_with("gid://gitlab/Releases::Link/"),
+ name: mutation_arguments[:name],
+ url: mutation_arguments[:url],
+ linkType: mutation_arguments[:linkType],
+ directAssetUrl: end_with(mutation_arguments[:directAssetPath]),
+ external: true
+ }.with_indifferent_access
+
+ expect(mutation_response[:link]).to include(expected_response)
+ expect(mutation_response[:errors]).to eq([])
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb b/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb
new file mode 100644
index 00000000000..92b558d4be3
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/release_asset_links/update_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Updating an existing release asset link' do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project, :private, :repository) }
+ let_it_be(:release) { create(:release, project: project) }
+ let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
+
+ let_it_be(:release_link) do
+ create(:release_link,
+ release: release,
+ name: 'link name',
+ url: 'https://example.com/url',
+ filepath: '/permanent/path',
+ link_type: 'package')
+ end
+
+ let(:current_user) { developer }
+
+ let(:mutation_name) { :release_asset_link_update }
+
+ let(:mutation_arguments) do
+ {
+ id: release_link.to_global_id.to_s,
+ name: 'updated name',
+ url: 'https://example.com/updated',
+ directAssetPath: '/updated/path',
+ linkType: 'IMAGE'
+ }
+ end
+
+ let(:mutation) do
+ graphql_mutation(mutation_name, mutation_arguments, <<~FIELDS)
+ link {
+ id
+ name
+ url
+ linkType
+ directAssetUrl
+ external
+ }
+ errors
+ FIELDS
+ end
+
+ let(:update_link) { post_graphql_mutation(mutation, current_user: current_user) }
+ let(:mutation_response) { graphql_mutation_response(mutation_name)&.with_indifferent_access }
+
+ it 'updates and existing release asset link and returns the updated link', :aggregate_failures do
+ update_link
+
+ expected_response = {
+ id: mutation_arguments[:id],
+ name: mutation_arguments[:name],
+ url: mutation_arguments[:url],
+ linkType: mutation_arguments[:linkType],
+ directAssetUrl: end_with(mutation_arguments[:directAssetPath]),
+ external: true
+ }.with_indifferent_access
+
+ expect(mutation_response[:link]).to include(expected_response)
+ expect(mutation_response[:errors]).to eq([])
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/user_callouts/create_spec.rb b/spec/requests/api/graphql/mutations/user_callouts/create_spec.rb
new file mode 100644
index 00000000000..cb67a60ebe4
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/user_callouts/create_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Create a user callout' do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let(:feature_name) { ::UserCallout.feature_names.each_key.first }
+
+ let(:input) do
+ {
+ 'featureName' => feature_name
+ }
+ end
+
+ let(:mutation) { graphql_mutation(:userCalloutCreate, input) }
+ let(:mutation_response) { graphql_mutation_response(:userCalloutCreate) }
+
+ it 'creates user callout' do
+ freeze_time do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['userCallout']['featureName']).to eq(feature_name.upcase)
+ expect(mutation_response['userCallout']['dismissedAt']).to eq(Time.current.iso8601)
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/namespace/projects_spec.rb b/spec/requests/api/graphql/namespace/projects_spec.rb
index 3e68503b7fb..414847c9c93 100644
--- a/spec/requests/api/graphql/namespace/projects_spec.rb
+++ b/spec/requests/api/graphql/namespace/projects_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe 'getting projects' do
projects(includeSubgroups: #{include_subgroups}) {
edges {
node {
- #{all_graphql_fields_for('Project')}
+ #{all_graphql_fields_for('Project', max_depth: 1)}
}
}
}
diff --git a/spec/requests/api/graphql/packages/package_spec.rb b/spec/requests/api/graphql/packages/package_spec.rb
index bb3ceb81f16..654215041cb 100644
--- a/spec/requests/api/graphql/packages/package_spec.rb
+++ b/spec/requests/api/graphql/packages/package_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe 'package details' do
end
let(:depth) { 3 }
- let(:excluded) { %w[metadata apiFuzzingCiConfiguration] }
+ let(:excluded) { %w[metadata apiFuzzingCiConfiguration pipeline] }
let(:query) do
graphql_query_for(:package, { id: package_global_id }, <<~FIELDS)
diff --git a/spec/requests/api/graphql/project/alert_management/alert/issue_spec.rb b/spec/requests/api/graphql/project/alert_management/alert/issue_spec.rb
new file mode 100644
index 00000000000..9724de4fedb
--- /dev/null
+++ b/spec/requests/api/graphql/project/alert_management/alert/issue_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'getting Alert Management Alert Issue' do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:current_user) { create(:user) }
+ let(:payload) { {} }
+ let(:query) { 'avg(metric) > 1.0' }
+
+ let(:fields) do
+ <<~QUERY
+ nodes {
+ iid
+ issue {
+ iid
+ state
+ }
+ }
+ QUERY
+ end
+
+ let(:graphql_query) do
+ graphql_query_for(
+ 'project',
+ { 'fullPath' => project.full_path },
+ query_graphql_field('alertManagementAlerts', {}, fields)
+ )
+ end
+
+ let(:alerts) { graphql_data.dig('project', 'alertManagementAlerts', 'nodes') }
+ let(:first_alert) { alerts.first }
+
+ before do
+ project.add_developer(current_user)
+ end
+
+ context 'with gitlab alert' do
+ before do
+ create(:alert_management_alert, :with_issue, project: project, payload: payload)
+ end
+
+ it 'includes the correct alert issue payload data' do
+ post_graphql(graphql_query, current_user: current_user)
+
+ expect(first_alert).to include('issue' => { "iid" => "1", "state" => "opened" })
+ end
+ end
+
+ describe 'performance' do
+ let(:first_n) { var('Int') }
+ let(:params) { { first: first_n } }
+ let(:limited_query) { with_signature([first_n], query) }
+
+ context 'with gitlab alert' do
+ before do
+ create(:alert_management_alert, :with_issue, project: project, payload: payload)
+ end
+
+ it 'avoids N+1 queries' do
+ base_count = ActiveRecord::QueryRecorder.new do
+ post_graphql(limited_query, current_user: current_user, variables: first_n.with(1))
+ end
+
+ expect { post_graphql(limited_query, current_user: current_user) }.not_to exceed_query_limit(base_count)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/project/alert_management/alerts_spec.rb b/spec/requests/api/graphql/project/alert_management/alerts_spec.rb
index 8deed75a466..fe77d9dc86d 100644
--- a/spec/requests/api/graphql/project/alert_management/alerts_spec.rb
+++ b/spec/requests/api/graphql/project/alert_management/alerts_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe 'getting Alert Management Alerts' do
let_it_be(:payload) { { 'custom' => { 'alert' => 'payload' }, 'runbook' => 'runbook' } }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:current_user) { create(:user) }
- let_it_be(:resolved_alert) { create(:alert_management_alert, :all_fields, :resolved, project: project, issue: nil, severity: :low).present }
+ let_it_be(:resolved_alert) { create(:alert_management_alert, :all_fields, :resolved, project: project, severity: :low).present }
let_it_be(:triggered_alert) { create(:alert_management_alert, :all_fields, project: project, severity: :critical, payload: payload).present }
let_it_be(:other_project_alert) { create(:alert_management_alert, :all_fields).present }
@@ -60,7 +60,6 @@ RSpec.describe 'getting Alert Management Alerts' do
it 'returns the correct properties of the alerts' do
expect(first_alert).to include(
'iid' => triggered_alert.iid.to_s,
- 'issueIid' => triggered_alert.issue_iid.to_s,
'title' => triggered_alert.title,
'description' => triggered_alert.description,
'severity' => triggered_alert.severity.upcase,
@@ -82,7 +81,6 @@ RSpec.describe 'getting Alert Management Alerts' do
expect(second_alert).to include(
'iid' => resolved_alert.iid.to_s,
- 'issueIid' => resolved_alert.issue_iid.to_s,
'status' => 'RESOLVED',
'endedAt' => resolved_alert.ended_at.strftime('%Y-%m-%dT%H:%M:%SZ')
)
diff --git a/spec/requests/api/graphql/project/container_repositories_spec.rb b/spec/requests/api/graphql/project/container_repositories_spec.rb
index 2087d8c2cc3..5ffd48a7bc4 100644
--- a/spec/requests/api/graphql/project/container_repositories_spec.rb
+++ b/spec/requests/api/graphql/project/container_repositories_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe 'getting container repositories in a project' do
<<~GQL
edges {
node {
- #{all_graphql_fields_for('container_repositories'.classify)}
+ #{all_graphql_fields_for('container_repositories'.classify, excluded: ['pipeline'])}
}
}
GQL
diff --git a/spec/requests/api/graphql/project/merge_request/diff_notes_spec.rb b/spec/requests/api/graphql/project/merge_request/diff_notes_spec.rb
index dd16b052e0e..b1ecb32b365 100644
--- a/spec/requests/api/graphql/project/merge_request/diff_notes_spec.rb
+++ b/spec/requests/api/graphql/project/merge_request/diff_notes_spec.rb
@@ -34,7 +34,7 @@ RSpec.describe 'getting notes for a merge request' do
notes {
edges {
node {
- #{all_graphql_fields_for('Note')}
+ #{all_graphql_fields_for('Note', excluded: ['pipeline'])}
}
}
}
diff --git a/spec/requests/api/graphql/project/merge_request_spec.rb b/spec/requests/api/graphql/project/merge_request_spec.rb
index a4e8d0bc35e..e32899c600e 100644
--- a/spec/requests/api/graphql/project/merge_request_spec.rb
+++ b/spec/requests/api/graphql/project/merge_request_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe 'getting merge request information nested in a project' do
let(:current_user) { create(:user) }
let(:merge_request_graphql_data) { graphql_data['project']['mergeRequest'] }
let!(:merge_request) { create(:merge_request, source_project: project) }
- let(:mr_fields) { all_graphql_fields_for('MergeRequest') }
+ let(:mr_fields) { all_graphql_fields_for('MergeRequest', excluded: ['pipeline']) }
let(:query) do
graphql_query_for(
diff --git a/spec/requests/api/graphql/project/merge_requests_spec.rb b/spec/requests/api/graphql/project/merge_requests_spec.rb
index d684be91dc9..d97a0ed9399 100644
--- a/spec/requests/api/graphql/project/merge_requests_spec.rb
+++ b/spec/requests/api/graphql/project/merge_requests_spec.rb
@@ -7,13 +7,27 @@ RSpec.describe 'getting merge request listings nested in a project' do
let_it_be(:project) { create(:project, :repository, :public) }
let_it_be(:current_user) { create(:user) }
-
let_it_be(:label) { create(:label, project: project) }
- let_it_be(:merge_request_a) { create(:labeled_merge_request, :unique_branches, source_project: project, labels: [label]) }
- let_it_be(:merge_request_b) { create(:merge_request, :closed, :unique_branches, source_project: project) }
- let_it_be(:merge_request_c) { create(:labeled_merge_request, :closed, :unique_branches, source_project: project, labels: [label]) }
- let_it_be(:merge_request_d) { create(:merge_request, :locked, :unique_branches, source_project: project) }
- let_it_be(:merge_request_e) { create(:merge_request, :unique_branches, source_project: project) }
+
+ let_it_be(:merge_request_a) do
+ create(:labeled_merge_request, :unique_branches, source_project: project, labels: [label])
+ end
+
+ let_it_be(:merge_request_b) do
+ create(:merge_request, :closed, :unique_branches, source_project: project)
+ end
+
+ let_it_be(:merge_request_c) do
+ create(:labeled_merge_request, :closed, :unique_branches, source_project: project, labels: [label])
+ end
+
+ let_it_be(:merge_request_d) do
+ create(:merge_request, :locked, :unique_branches, source_project: project)
+ end
+
+ let_it_be(:merge_request_e) do
+ create(:merge_request, :unique_branches, source_project: project)
+ end
let(:results) { graphql_data.dig('project', 'mergeRequests', 'nodes') }
@@ -27,32 +41,38 @@ RSpec.describe 'getting merge request listings nested in a project' do
)
end
- let(:query) do
- query_merge_requests(all_graphql_fields_for('MergeRequest', max_depth: 1))
- end
-
it_behaves_like 'a working graphql query' do
+ let(:query) do
+ query_merge_requests(all_graphql_fields_for('MergeRequest', max_depth: 2))
+ end
+
before do
- post_graphql(query, current_user: current_user)
+ # We cannot call the whitelist here, since the transaction does not
+ # begin until we enter the controller.
+ headers = {
+ 'X-GITLAB-QUERY-WHITELIST-ISSUE' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/322979'
+ }
+
+ post_graphql(query, current_user: current_user, headers: headers)
end
end
# The following tests are needed to guarantee that we have correctly annotated
# all the gitaly calls. Selecting combinations of fields may mask this due to
# memoization.
- context 'requesting a single field' do
+ context 'when requesting a single field' do
let_it_be(:fresh_mr) { create(:merge_request, :unique_branches, source_project: project) }
+
let(:search_params) { { iids: [fresh_mr.iid.to_s] } }
+ let(:graphql_data) do
+ GitlabSchema.execute(query, context: { current_user: current_user }).to_h['data']
+ end
before do
project.repository.expire_branches_cache
end
- let(:graphql_data) do
- GitlabSchema.execute(query, context: { current_user: current_user }).to_h['data']
- end
-
- context 'selecting any single scalar field' do
+ context 'when selecting any single scalar field' do
where(:field) do
scalar_fields_of('MergeRequest').map { |name| [name] }
end
@@ -68,7 +88,7 @@ RSpec.describe 'getting merge request listings nested in a project' do
end
end
- context 'selecting any single nested field' do
+ context 'when selecting any single nested field' do
where(:field, :subfield, :is_connection) do
nested_fields_of('MergeRequest').flat_map do |name, field|
type = field_type(field)
@@ -95,7 +115,11 @@ RSpec.describe 'getting merge request listings nested in a project' do
end
end
- shared_examples 'searching with parameters' do
+ shared_examples 'when searching with parameters' do
+ let(:query) do
+ query_merge_requests('iid title')
+ end
+
let(:expected) do
mrs.map { |mr| a_hash_including('iid' => mr.iid.to_s, 'title' => mr.title) }
end
@@ -107,60 +131,60 @@ RSpec.describe 'getting merge request listings nested in a project' do
end
end
- context 'there are no search params' do
+ context 'when there are no search params' do
let(:search_params) { nil }
let(:mrs) { [merge_request_a, merge_request_b, merge_request_c, merge_request_d, merge_request_e] }
- it_behaves_like 'searching with parameters'
+ it_behaves_like 'when searching with parameters'
end
- context 'the search params do not match anything' do
- let(:search_params) { { iids: %w(foo bar baz) } }
+ context 'when the search params do not match anything' do
+ let(:search_params) { { iids: %w[foo bar baz] } }
let(:mrs) { [] }
- it_behaves_like 'searching with parameters'
+ it_behaves_like 'when searching with parameters'
end
- context 'searching by iids' do
+ context 'when searching by iids' do
let(:search_params) { { iids: mrs.map(&:iid).map(&:to_s) } }
let(:mrs) { [merge_request_a, merge_request_c] }
- it_behaves_like 'searching with parameters'
+ it_behaves_like 'when searching with parameters'
end
- context 'searching by state' do
+ context 'when searching by state' do
let(:search_params) { { state: :closed } }
let(:mrs) { [merge_request_b, merge_request_c] }
- it_behaves_like 'searching with parameters'
+ it_behaves_like 'when searching with parameters'
end
- context 'searching by source_branch' do
+ context 'when searching by source_branch' do
let(:search_params) { { source_branches: mrs.map(&:source_branch) } }
let(:mrs) { [merge_request_b, merge_request_c] }
- it_behaves_like 'searching with parameters'
+ it_behaves_like 'when searching with parameters'
end
- context 'searching by target_branch' do
+ context 'when searching by target_branch' do
let(:search_params) { { target_branches: mrs.map(&:target_branch) } }
let(:mrs) { [merge_request_a, merge_request_d] }
- it_behaves_like 'searching with parameters'
+ it_behaves_like 'when searching with parameters'
end
- context 'searching by label' do
+ context 'when searching by label' do
let(:search_params) { { labels: [label.title] } }
let(:mrs) { [merge_request_a, merge_request_c] }
- it_behaves_like 'searching with parameters'
+ it_behaves_like 'when searching with parameters'
end
- context 'searching by combination' do
+ context 'when searching by combination' do
let(:search_params) { { state: :closed, labels: [label.title] } }
let(:mrs) { [merge_request_c] }
- it_behaves_like 'searching with parameters'
+ it_behaves_like 'when searching with parameters'
end
context 'when requesting `approved_by`' do
@@ -203,10 +227,10 @@ RSpec.describe 'getting merge request listings nested in a project' do
it 'exposes `commit_count`' do
execute_query
- expect(results).to match_array([
+ expect(results).to match_array [
{ "iid" => merge_request_a.iid.to_s, "commitCount" => 0 },
{ "iid" => merge_request_with_commits.iid.to_s, "commitCount" => 29 }
- ])
+ ]
end
end
@@ -216,8 +240,8 @@ RSpec.describe 'getting merge request listings nested in a project' do
before do
# make the MRs "merged"
[merge_request_a, merge_request_b, merge_request_c].each do |mr|
- mr.update_column(:state_id, MergeRequest.available_states[:merged])
- mr.metrics.update_column(:merged_at, Time.now)
+ mr.update!(state_id: MergeRequest.available_states[:merged])
+ mr.metrics.update!(merged_at: Time.now)
end
end
@@ -256,13 +280,12 @@ RSpec.describe 'getting merge request listings nested in a project' do
end
it 'returns the reviewers' do
+ nodes = merge_request_a.reviewers.map { |r| { 'username' => r.username } }
+ reviewers = { 'nodes' => match_array(nodes) }
+
execute_query
- expect(results).to include a_hash_including('reviewers' => {
- 'nodes' => match_array(merge_request_a.reviewers.map do |r|
- a_hash_including('username' => r.username)
- end)
- })
+ expect(results).to include a_hash_including('reviewers' => match(reviewers))
end
include_examples 'N+1 query check'
@@ -309,12 +332,14 @@ RSpec.describe 'getting merge request listings nested in a project' do
allow(Gitlab::Database).to receive(:read_only?).and_return(false)
end
+ def query_context
+ { current_user: current_user }
+ end
+
def run_query(number)
# Ensure that we have a fresh request store and batch-context between runs
- result = run_with_clean_state(query,
- context: { current_user: current_user },
- variables: { first: number }
- )
+ vars = { first: number }
+ result = run_with_clean_state(query, context: query_context, variables: vars)
graphql_dig_at(result.to_h, :data, :project, :merge_requests, :nodes)
end
@@ -348,39 +373,49 @@ RSpec.describe 'getting merge request listings nested in a project' do
let(:data_path) { [:project, :mergeRequests] }
def pagination_query(params)
- graphql_query_for(:project, { full_path: project.full_path },
- <<~QUERY
+ graphql_query_for(:project, { full_path: project.full_path }, <<~QUERY)
mergeRequests(#{params}) {
#{page_info} nodes { id }
}
- QUERY
- )
+ QUERY
end
context 'when sorting by merged_at DESC' do
- it_behaves_like 'sorted paginated query' do
- let(:sort_param) { :MERGED_AT_DESC }
- let(:first_param) { 2 }
+ let(:sort_param) { :MERGED_AT_DESC }
+ let(:expected_results) do
+ [
+ merge_request_b,
+ merge_request_d,
+ merge_request_c,
+ merge_request_e,
+ merge_request_a
+ ].map { |mr| global_id_of(mr) }
+ end
- let(:expected_results) do
- [
- merge_request_b,
- merge_request_d,
- merge_request_c,
- merge_request_e,
- merge_request_a
- ].map { |mr| global_id_of(mr) }
- end
+ before do
+ five_days_ago = 5.days.ago
- before do
- five_days_ago = 5.days.ago
+ merge_request_d.metrics.update!(merged_at: five_days_ago)
+
+ # same merged_at, the second order column will decide (merge_request.id)
+ merge_request_c.metrics.update!(merged_at: five_days_ago)
+
+ merge_request_b.metrics.update!(merged_at: 1.day.ago)
+ end
+
+ it_behaves_like 'sorted paginated query' do
+ let(:first_param) { 2 }
+ end
- merge_request_d.metrics.update!(merged_at: five_days_ago)
+ context 'when last parameter is given' do
+ let(:params) { graphql_args(sort: sort_param, last: 2) }
+ let(:page_info) { nil }
- # same merged_at, the second order column will decide (merge_request.id)
- merge_request_c.metrics.update!(merged_at: five_days_ago)
+ it 'takes the last 2 records' do
+ query = pagination_query(params)
+ post_graphql(query, current_user: current_user)
- merge_request_b.metrics.update!(merged_at: 1.day.ago)
+ expect(results.map { |item| item["id"] }).to eq(expected_results.last(2))
end
end
end
@@ -396,75 +431,45 @@ RSpec.describe 'getting merge request listings nested in a project' do
let(:query) do
# Note: __typename meta field is always requested by the FE
- graphql_query_for(:project, { full_path: project.full_path },
- <<~QUERY
+ graphql_query_for(:project, { full_path: project.full_path }, <<~QUERY)
mergeRequests(mergedAfter: "2020-01-01", mergedBefore: "2020-01-05", first: 0, sourceBranches: null, labels: null) {
count
__typename
}
QUERY
- )
end
- shared_examples 'count examples' do
- it 'returns the correct count' do
- post_graphql(query, current_user: current_user)
+ it 'does not query the merge requests table for the count' do
+ query_recorder = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) }
- count = graphql_data.dig('project', 'mergeRequests', 'count')
- expect(count).to eq(1)
- end
+ queries = query_recorder.data.each_value.first[:occurrences]
+ expect(queries).not_to include(match(/SELECT COUNT\(\*\) FROM "merge_requests"/))
+ expect(queries).to include(match(/SELECT COUNT\(\*\) FROM "merge_request_metrics"/))
end
- context 'when "optimized_merge_request_count_with_merged_at_filter" feature flag is enabled' do
- before do
- stub_feature_flags(optimized_merge_request_count_with_merged_at_filter: true)
+ context 'when total_time_to_merge and count is queried' do
+ let(:query) do
+ graphql_query_for(:project, { full_path: project.full_path }, <<~QUERY)
+ mergeRequests(mergedAfter: "2020-01-01", mergedBefore: "2020-01-05", first: 0) {
+ totalTimeToMerge
+ count
+ }
+ QUERY
end
- it 'does not query the merge requests table for the count' do
+ it 'does not query the merge requests table for the total_time_to_merge' do
query_recorder = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) }
queries = query_recorder.data.each_value.first[:occurrences]
- expect(queries).not_to include(match(/SELECT COUNT\(\*\) FROM "merge_requests"/))
- expect(queries).to include(match(/SELECT COUNT\(\*\) FROM "merge_request_metrics"/))
+ expect(queries).to include(match(/SELECT.+SUM.+FROM "merge_request_metrics" WHERE/))
end
+ end
- context 'when total_time_to_merge and count is queried' do
- let(:query) do
- graphql_query_for(:project, { full_path: project.full_path },
- <<~QUERY
- mergeRequests(mergedAfter: "2020-01-01", mergedBefore: "2020-01-05", first: 0) {
- totalTimeToMerge
- count
- }
- QUERY
- )
- end
-
- it 'does not query the merge requests table for the total_time_to_merge' do
- query_recorder = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) }
-
- queries = query_recorder.data.each_value.first[:occurrences]
- expect(queries).to include(match(/SELECT.+SUM.+FROM "merge_request_metrics" WHERE/))
- end
- end
-
- it_behaves_like 'count examples'
-
- context 'when "optimized_merge_request_count_with_merged_at_filter" feature flag is disabled' do
- before do
- stub_feature_flags(optimized_merge_request_count_with_merged_at_filter: false)
- end
-
- it 'queries the merge requests table for the count' do
- query_recorder = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) }
-
- queries = query_recorder.data.each_value.first[:occurrences]
- expect(queries).to include(match(/SELECT COUNT\(\*\) FROM "merge_requests"/))
- expect(queries).not_to include(match(/SELECT COUNT\(\*\) FROM "merge_request_metrics"/))
- end
+ it 'returns the correct count' do
+ post_graphql(query, current_user: current_user)
- it_behaves_like 'count examples'
- end
+ count = graphql_data.dig('project', 'mergeRequests', 'count')
+ expect(count).to eq(1)
end
end
end
diff --git a/spec/requests/api/graphql/project/packages_spec.rb b/spec/requests/api/graphql/project/packages_spec.rb
index b20c96d54c8..3c04e0caf61 100644
--- a/spec/requests/api/graphql/project/packages_spec.rb
+++ b/spec/requests/api/graphql/project/packages_spec.rb
@@ -5,28 +5,28 @@ require 'spec_helper'
RSpec.describe 'getting a package list for a project' do
include GraphqlHelpers
- let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:resource) { create(:project, :repository) }
let_it_be(:current_user) { create(:user) }
- let_it_be(:package) { create(:package, project: project) }
- let_it_be(:maven_package) { create(:maven_package, project: project) }
- let_it_be(:debian_package) { create(:debian_package, project: project) }
- let_it_be(:composer_package) { create(:composer_package, project: project) }
+ let_it_be(:package) { create(:package, project: resource) }
+ let_it_be(:maven_package) { create(:maven_package, project: resource) }
+ let_it_be(:debian_package) { create(:debian_package, project: resource) }
+ let_it_be(:composer_package) { create(:composer_package, project: resource) }
let_it_be(:composer_metadatum) do
create(:composer_metadatum, package: composer_package,
target_sha: 'afdeh',
composer_json: { name: 'x', type: 'y', license: 'z', version: 1 })
end
- let(:package_names) { graphql_data_at(:project, :packages, :edges, :node, :name) }
+ let(:package_names) { graphql_data_at(:project, :packages, :nodes, :name) }
+ let(:target_shas) { graphql_data_at(:project, :packages, :nodes, :metadata, :target_sha) }
+ let(:packages) { graphql_data_at(:project, :packages, :nodes) }
let(:fields) do
<<~QUERY
- edges {
- node {
- #{all_graphql_fields_for('packages'.classify, excluded: ['project'])}
- metadata { #{query_graphql_fragment('ComposerMetadata')} }
- }
+ nodes {
+ #{all_graphql_fields_for('packages'.classify, excluded: ['project'])}
+ metadata { #{query_graphql_fragment('ComposerMetadata')} }
}
QUERY
end
@@ -34,55 +34,10 @@ RSpec.describe 'getting a package list for a project' do
let(:query) do
graphql_query_for(
'project',
- { 'fullPath' => project.full_path },
+ { 'fullPath' => resource.full_path },
query_graphql_field('packages', {}, fields)
)
end
- context 'when user has access to the project' do
- before do
- project.add_reporter(current_user)
- post_graphql(query, current_user: current_user)
- end
-
- it_behaves_like 'a working graphql query'
-
- it 'returns packages successfully' do
- expect(package_names).to contain_exactly(
- package.name,
- maven_package.name,
- debian_package.name,
- composer_package.name
- )
- end
-
- it 'deals with metadata' do
- target_shas = graphql_data_at(:project, :packages, :edges, :node, :metadata, :target_sha)
- expect(target_shas).to contain_exactly(composer_metadatum.target_sha)
- end
- end
-
- context 'when the user does not have access to the project/packages' do
- before do
- post_graphql(query, current_user: current_user)
- end
-
- it_behaves_like 'a working graphql query'
-
- it 'returns nil' do
- expect(graphql_data['project']).to be_nil
- end
- end
-
- context 'when the user is not authenticated' do
- before do
- post_graphql(query)
- end
-
- it_behaves_like 'a working graphql query'
-
- it 'returns nil' do
- expect(graphql_data['project']).to be_nil
- end
- end
+ it_behaves_like 'group and project packages query'
end
diff --git a/spec/requests/api/graphql/project/pipeline_spec.rb b/spec/requests/api/graphql/project/pipeline_spec.rb
index 6179b43629b..cc028ff2ff9 100644
--- a/spec/requests/api/graphql/project/pipeline_spec.rb
+++ b/spec/requests/api/graphql/project/pipeline_spec.rb
@@ -11,10 +11,14 @@ RSpec.describe 'getting pipeline information nested in a project' do
let(:pipeline_graphql_data) { graphql_data['project']['pipeline'] }
let!(:query) do
- graphql_query_for(
- 'project',
- { 'fullPath' => project.full_path },
- query_graphql_field('pipeline', iid: pipeline.iid.to_s)
+ %(
+ query {
+ project(fullPath: "#{project.full_path}") {
+ pipeline(iid: "#{pipeline.iid}") {
+ configSource
+ }
+ }
+ }
)
end
diff --git a/spec/requests/api/graphql/snippets_spec.rb b/spec/requests/api/graphql/snippets_spec.rb
new file mode 100644
index 00000000000..9edd805678a
--- /dev/null
+++ b/spec/requests/api/graphql/snippets_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'snippets' do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:snippets) { create_list(:personal_snippet, 3, :repository, author: current_user) }
+
+ describe 'querying for all fields' do
+ let(:query) do
+ graphql_query_for(:snippets, { ids: [global_id_of(snippets.first)] }, <<~SELECT)
+ nodes { #{all_graphql_fields_for('Snippet')} }
+ SELECT
+ end
+
+ it 'can successfully query for snippets and their blobs' do
+ post_graphql(query, current_user: current_user)
+
+ expect(graphql_data_at(:snippets, :nodes)).to be_one
+ expect(graphql_data_at(:snippets, :nodes, :blobs, :nodes)).to be_present
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/instance_statistics_measurements_spec.rb b/spec/requests/api/graphql/usage_trends_measurements_spec.rb
index eb73dc59253..69a3ed7e09c 100644
--- a/spec/requests/api/graphql/instance_statistics_measurements_spec.rb
+++ b/spec/requests/api/graphql/usage_trends_measurements_spec.rb
@@ -2,22 +2,22 @@
require 'spec_helper'
-RSpec.describe 'InstanceStatisticsMeasurements' do
+RSpec.describe 'UsageTrendsMeasurements' do
include GraphqlHelpers
let(:current_user) { create(:user, :admin) }
- let!(:instance_statistics_measurement_1) { create(:instance_statistics_measurement, :project_count, recorded_at: 20.days.ago, count: 5) }
- let!(:instance_statistics_measurement_2) { create(:instance_statistics_measurement, :project_count, recorded_at: 10.days.ago, count: 10) }
+ let!(:usage_trends_measurement_1) { create(:usage_trends_measurement, :project_count, recorded_at: 20.days.ago, count: 5) }
+ let!(:usage_trends_measurement_2) { create(:usage_trends_measurement, :project_count, recorded_at: 10.days.ago, count: 10) }
let(:arguments) { 'identifier: PROJECTS' }
- let(:query) { graphql_query_for(:instanceStatisticsMeasurements, arguments, 'nodes { count identifier }') }
+ let(:query) { graphql_query_for(:UsageTrendsMeasurements, arguments, 'nodes { count identifier }') }
before do
post_graphql(query, current_user: current_user)
end
it 'returns measurement objects' do
- expect(graphql_data.dig('instanceStatisticsMeasurements', 'nodes')).to eq([
+ expect(graphql_data.dig('usageTrendsMeasurements', 'nodes')).to eq([
{ "count" => 10, 'identifier' => 'PROJECTS' },
{ "count" => 5, 'identifier' => 'PROJECTS' }
])
@@ -27,7 +27,7 @@ RSpec.describe 'InstanceStatisticsMeasurements' do
let(:arguments) { %(identifier: PROJECTS, recordedAfter: "#{15.days.ago.to_date}", recordedBefore: "#{5.days.ago.to_date}") }
it 'returns filtered measurement objects' do
- expect(graphql_data.dig('instanceStatisticsMeasurements', 'nodes')).to eq([
+ expect(graphql_data.dig('usageTrendsMeasurements', 'nodes')).to eq([
{ "count" => 10, 'identifier' => 'PROJECTS' }
])
end
diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb
index 91d10791541..8160a94aef2 100644
--- a/spec/requests/api/helpers_spec.rb
+++ b/spec/requests/api/helpers_spec.rb
@@ -314,14 +314,13 @@ RSpec.describe API::Helpers do
expect(Gitlab::ErrorTracking).to receive(:sentry_dsn).and_return(Gitlab.config.sentry.dsn)
Gitlab::ErrorTracking.configure
- Raven.client.configuration.encoding = 'json'
end
it 'does not report a MethodNotAllowed exception to Sentry' do
exception = Grape::Exceptions::MethodNotAllowed.new({ 'X-GitLab-Test' => '1' })
allow(exception).to receive(:backtrace).and_return(caller)
- expect(Raven).not_to receive(:capture_exception).with(exception)
+ expect(Gitlab::ErrorTracking).not_to receive(:track_exception).with(exception)
handle_api_exception(exception)
end
@@ -330,8 +329,7 @@ RSpec.describe API::Helpers do
exception = RuntimeError.new('test error')
allow(exception).to receive(:backtrace).and_return(caller)
- expect(Raven).to receive(:capture_exception).with(exception, tags:
- a_hash_including(correlation_id: 'new-correlation-id'), extra: {})
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(exception)
Labkit::Correlation::CorrelationId.use_id('new-correlation-id') do
handle_api_exception(exception)
@@ -357,20 +355,6 @@ RSpec.describe API::Helpers do
expect(json_response['message']).to start_with("\nRuntimeError (Runtime Error!):")
end
end
-
- context 'extra information' do
- # Sentry events are an array of the form [auth_header, data, options]
- let(:event_data) { Raven.client.transport.events.first[1] }
-
- it 'sends the params, excluding confidential values' do
- expect(ProjectsFinder).to receive(:new).and_raise('Runtime Error!')
-
- get api('/projects', user), params: { password: 'dont_send_this', other_param: 'send_this' }
-
- expect(event_data).to include('other_param=send_this')
- expect(event_data).to include('password=********')
- end
- end
end
describe '.authenticate_non_get!' do
diff --git a/spec/requests/api/invitations_spec.rb b/spec/requests/api/invitations_spec.rb
index 2ea237469b1..98a7aa63b16 100644
--- a/spec/requests/api/invitations_spec.rb
+++ b/spec/requests/api/invitations_spec.rb
@@ -3,14 +3,14 @@
require 'spec_helper'
RSpec.describe API::Invitations do
- let(:maintainer) { create(:user, username: 'maintainer_user') }
- let(:developer) { create(:user) }
- let(:access_requester) { create(:user) }
- let(:stranger) { create(:user) }
+ let_it_be(:maintainer) { create(:user, username: 'maintainer_user') }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:access_requester) { create(:user) }
+ let_it_be(:stranger) { create(:user) }
let(:email) { 'email1@example.com' }
let(:email2) { 'email2@example.com' }
- let(:project) do
+ let_it_be(:project) do
create(:project, :public, creator_id: maintainer.id, namespace: maintainer.namespace) do |project|
project.add_developer(developer)
project.add_maintainer(maintainer)
@@ -18,7 +18,7 @@ RSpec.describe API::Invitations do
end
end
- let!(:group) do
+ let_it_be(:group, reload: true) do
create(:group, :public) do |group|
group.add_developer(developer)
group.add_owner(maintainer)
@@ -374,4 +374,104 @@ RSpec.describe API::Invitations do
let(:source) { group }
end
end
+
+ shared_examples 'PUT /:source_type/:id/invitations/:email' do |source_type|
+ def update_api(source, user, email)
+ api("/#{source.model_name.plural}/#{source.id}/invitations/#{email}", user)
+ end
+
+ context "with :source_type == #{source_type.pluralize}" do
+ let!(:invite) { invite_member_by_email(source, source_type, developer.email, maintainer) }
+
+ it_behaves_like 'a 404 response when source is private' do
+ let(:route) do
+ put update_api(source, stranger, invite.invite_email), params: { access_level: Member::MAINTAINER }
+ end
+ end
+
+ context 'when authenticated as a non-member or member with insufficient rights' do
+ %i[access_requester stranger].each do |type|
+ context "as a #{type}" do
+ it 'returns 403' do
+ user = public_send(type)
+
+ put update_api(source, user, invite.invite_email), params: { access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+ end
+
+ context 'when authenticated as a maintainer/owner' do
+ context 'updating access level' do
+ it 'updates the invitation' do
+ put update_api(source, maintainer, invite.invite_email), params: { access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['access_level']).to eq(Member::MAINTAINER)
+ expect(invite.reload.access_level).to eq(Member::MAINTAINER)
+ end
+ end
+
+ it 'returns 409 if member does not exist' do
+ put update_api(source, maintainer, non_existing_record_id), params: { access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'returns 400 when access_level is not given and there are no other params' do
+ put update_api(source, maintainer, invite.invite_email)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'returns 400 when access level is not valid' do
+ put update_api(source, maintainer, invite.invite_email), params: { access_level: non_existing_record_access_level }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'updating access expiry date' do
+ subject do
+ put update_api(source, maintainer, invite.invite_email), params: { expires_at: expires_at }
+ end
+
+ context 'when set to a date in the past' do
+ let(:expires_at) { 2.days.ago.to_date }
+
+ it 'does not update the member' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to eq({ 'expires_at' => ['cannot be a date in the past'] })
+ end
+ end
+
+ context 'when set to a date in the future' do
+ let(:expires_at) { 2.days.from_now.to_date }
+
+ it 'updates the member' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['expires_at']).to eq(expires_at.to_s)
+ end
+ end
+ end
+ end
+ end
+
+ describe 'PUT /projects/:id/invitations' do
+ it_behaves_like 'PUT /:source_type/:id/invitations/:email', 'project' do
+ let(:source) { project }
+ end
+ end
+
+ describe 'PUT /groups/:id/invitations' do
+ it_behaves_like 'PUT /:source_type/:id/invitations/:email', 'group' do
+ let(:source) { group }
+ end
+ end
end
diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb
index 1c43ef25f14..fe00b654d3b 100644
--- a/spec/requests/api/jobs_spec.rb
+++ b/spec/requests/api/jobs_spec.rb
@@ -3,6 +3,9 @@
require 'spec_helper'
RSpec.describe API::Jobs do
+ include HttpBasicAuthHelpers
+ include DependencyProxyHelpers
+
using RSpec::Parameterized::TableSyntax
include HttpIOHelpers
@@ -16,20 +19,151 @@ RSpec.describe API::Jobs do
ref: project.default_branch)
end
- let!(:job) do
- create(:ci_build, :success, :tags, pipeline: pipeline,
- artifacts_expire_at: 1.day.since)
- end
-
let(:user) { create(:user) }
let(:api_user) { user }
let(:reporter) { create(:project_member, :reporter, project: project).user }
let(:guest) { create(:project_member, :guest, project: project).user }
+ let(:running_job) do
+ create(:ci_build, :running, project: project,
+ user: user,
+ pipeline: pipeline,
+ artifacts_expire_at: 1.day.since)
+ end
+
+ let!(:job) do
+ create(:ci_build, :success, :tags, pipeline: pipeline,
+ artifacts_expire_at: 1.day.since)
+ end
+
before do
project.add_developer(user)
end
+ shared_examples 'returns common pipeline data' do
+ it 'returns common pipeline data' do
+ expect(json_response['pipeline']).not_to be_empty
+ expect(json_response['pipeline']['id']).to eq jobx.pipeline.id
+ expect(json_response['pipeline']['project_id']).to eq jobx.pipeline.project_id
+ expect(json_response['pipeline']['ref']).to eq jobx.pipeline.ref
+ expect(json_response['pipeline']['sha']).to eq jobx.pipeline.sha
+ expect(json_response['pipeline']['status']).to eq jobx.pipeline.status
+ end
+ end
+
+ shared_examples 'returns common job data' do
+ it 'returns common job data' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['id']).to eq(jobx.id)
+ expect(json_response['status']).to eq(jobx.status)
+ expect(json_response['stage']).to eq(jobx.stage)
+ expect(json_response['name']).to eq(jobx.name)
+ expect(json_response['ref']).to eq(jobx.ref)
+ expect(json_response['tag']).to eq(jobx.tag)
+ expect(json_response['coverage']).to eq(jobx.coverage)
+ expect(json_response['allow_failure']).to eq(jobx.allow_failure)
+ expect(Time.parse(json_response['created_at'])).to be_like_time(jobx.created_at)
+ expect(Time.parse(json_response['started_at'])).to be_like_time(jobx.started_at)
+ expect(Time.parse(json_response['artifacts_expire_at'])).to be_like_time(jobx.artifacts_expire_at)
+ expect(json_response['artifacts_file']).to be_nil
+ expect(json_response['artifacts']).to be_an Array
+ expect(json_response['artifacts']).to be_empty
+ expect(json_response['web_url']).to be_present
+ end
+ end
+
+ shared_examples 'returns unauthorized' do
+ it 'returns unauthorized' do
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ describe 'GET /job' do
+ shared_context 'with auth headers' do
+ let(:headers_with_token) { header }
+ let(:params_with_token) { {} }
+ end
+
+ shared_context 'with auth params' do
+ let(:headers_with_token) { {} }
+ let(:params_with_token) { param }
+ end
+
+ shared_context 'without auth' do
+ let(:headers_with_token) { {} }
+ let(:params_with_token) { {} }
+ end
+
+ before do |example|
+ unless example.metadata[:skip_before_request]
+ get api('/job'), headers: headers_with_token, params: params_with_token
+ end
+ end
+
+ context 'with job token authentication header' do
+ include_context 'with auth headers' do
+ let(:header) { { API::Helpers::Runner::JOB_TOKEN_HEADER => running_job.token } }
+ end
+
+ it_behaves_like 'returns common job data' do
+ let(:jobx) { running_job }
+ end
+
+ it 'returns specific job data' do
+ expect(json_response['finished_at']).to be_nil
+ end
+
+ it_behaves_like 'returns common pipeline data' do
+ let(:jobx) { running_job }
+ end
+ end
+
+ context 'with job token authentication params' do
+ include_context 'with auth params' do
+ let(:param) { { job_token: running_job.token } }
+ end
+
+ it_behaves_like 'returns common job data' do
+ let(:jobx) { running_job }
+ end
+
+ it 'returns specific job data' do
+ expect(json_response['finished_at']).to be_nil
+ end
+
+ it_behaves_like 'returns common pipeline data' do
+ let(:jobx) { running_job }
+ end
+ end
+
+ context 'with non running job' do
+ include_context 'with auth headers' do
+ let(:header) { { API::Helpers::Runner::JOB_TOKEN_HEADER => job.token } }
+ end
+
+ it_behaves_like 'returns unauthorized'
+ end
+
+ context 'with basic auth header' do
+ let(:personal_access_token) { create(:personal_access_token, user: user) }
+ let(:token) { personal_access_token.token}
+
+ include_context 'with auth headers' do
+ let(:header) { { Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER => token } }
+ end
+
+ it 'does not return a job' do
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'without authentication' do
+ include_context 'without auth'
+
+ it_behaves_like 'returns unauthorized'
+ end
+ end
+
describe 'GET /projects/:id/jobs' do
let(:query) { {} }
@@ -150,39 +284,21 @@ RSpec.describe API::Jobs do
end
context 'authorized user' do
+ it_behaves_like 'returns common job data' do
+ let(:jobx) { job }
+ end
+
it 'returns specific job data' do
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['id']).to eq(job.id)
- expect(json_response['status']).to eq(job.status)
- expect(json_response['stage']).to eq(job.stage)
- expect(json_response['name']).to eq(job.name)
- expect(json_response['ref']).to eq(job.ref)
- expect(json_response['tag']).to eq(job.tag)
- expect(json_response['coverage']).to eq(job.coverage)
- expect(json_response['allow_failure']).to eq(job.allow_failure)
- expect(Time.parse(json_response['created_at'])).to be_like_time(job.created_at)
- expect(Time.parse(json_response['started_at'])).to be_like_time(job.started_at)
expect(Time.parse(json_response['finished_at'])).to be_like_time(job.finished_at)
- expect(Time.parse(json_response['artifacts_expire_at'])).to be_like_time(job.artifacts_expire_at)
- expect(json_response['artifacts_file']).to be_nil
- expect(json_response['artifacts']).to be_an Array
- expect(json_response['artifacts']).to be_empty
expect(json_response['duration']).to eq(job.duration)
- expect(json_response['web_url']).to be_present
end
it_behaves_like 'a job with artifacts and trace', result_is_array: false do
let(:api_endpoint) { "/projects/#{project.id}/jobs/#{second_job.id}" }
end
- it 'returns pipeline data' do
- json_job = json_response
-
- expect(json_job['pipeline']).not_to be_empty
- expect(json_job['pipeline']['id']).to eq job.pipeline.id
- expect(json_job['pipeline']['ref']).to eq job.pipeline.ref
- expect(json_job['pipeline']['sha']).to eq job.pipeline.sha
- expect(json_job['pipeline']['status']).to eq job.pipeline.status
+ it_behaves_like 'returns common pipeline data' do
+ let(:jobx) { job }
end
end
@@ -329,6 +445,17 @@ RSpec.describe API::Jobs do
.to include('Content-Type' => 'application/json',
'Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
end
+
+ context 'when artifacts are locked' do
+ it 'allows access to expired artifact' do
+ pipeline.artifacts_locked!
+ job.update!(artifacts_expire_at: Time.now - 7.days)
+
+ get_artifact_file(artifact)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
end
end
diff --git a/spec/requests/api/lint_spec.rb b/spec/requests/api/lint_spec.rb
index b5bf697e9e3..cf8cac773f5 100644
--- a/spec/requests/api/lint_spec.rb
+++ b/spec/requests/api/lint_spec.rb
@@ -406,6 +406,24 @@ RSpec.describe API::Lint do
end
end
+ context 'with an empty repository' do
+ let_it_be(:empty_project) { create(:project_empty_repo) }
+ let_it_be(:yaml_content) do
+ File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
+ end
+
+ before do
+ empty_project.add_developer(api_user)
+ end
+
+ it 'passes validation without errors' do
+ post api("/projects/#{empty_project.id}/ci/lint", api_user), params: { content: yaml_content }
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['valid']).to eq(true)
+ expect(json_response['errors']).to eq([])
+ end
+ end
+
context 'when unauthenticated' do
let_it_be(:api_user) { nil }
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index ad8e21bf4c1..09177dd1710 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -2537,7 +2537,7 @@ RSpec.describe API::MergeRequests do
end
end
- describe "the should_remove_source_branch param" do
+ describe "the should_remove_source_branch param", :sidekiq_inline do
let(:source_repository) { merge_request.source_project.repository }
let(:source_branch) { merge_request.source_branch }
@@ -2552,7 +2552,7 @@ RSpec.describe API::MergeRequests do
end
end
- context "with a merge request that has force_remove_source_branch enabled" do
+ context "with a merge request that has force_remove_source_branch enabled", :sidekiq_inline do
let(:source_repository) { merge_request.source_project.repository }
let(:source_branch) { merge_request.source_branch }
diff --git a/spec/requests/api/npm_instance_packages_spec.rb b/spec/requests/api/npm_instance_packages_spec.rb
index 70c76067a6e..698885ddcf4 100644
--- a/spec/requests/api/npm_instance_packages_spec.rb
+++ b/spec/requests/api/npm_instance_packages_spec.rb
@@ -3,6 +3,11 @@
require 'spec_helper'
RSpec.describe API::NpmInstancePackages do
+ # We need to create a subgroup with the same name as the hosting group.
+ # It has to be created first to exhibit this bug: https://gitlab.com/gitlab-org/gitlab/-/issues/321958
+ let_it_be(:another_namespace) { create(:group, :public) }
+ let_it_be(:similarly_named_group) { create(:group, :public, parent: another_namespace, name: 'test-group') }
+
include_context 'npm api setup'
describe 'GET /api/v4/packages/npm/*package_name' do
diff --git a/spec/requests/api/npm_project_packages_spec.rb b/spec/requests/api/npm_project_packages_spec.rb
index 7ea238c0607..e64b5ddc374 100644
--- a/spec/requests/api/npm_project_packages_spec.rb
+++ b/spec/requests/api/npm_project_packages_spec.rb
@@ -30,7 +30,7 @@ RSpec.describe API::NpmProjectPackages do
end
describe 'GET /api/v4/projects/:id/packages/npm/*package_name/-/*file_name' do
- let_it_be(:package_file) { package.package_files.first }
+ let(:package_file) { package.package_files.first }
let(:headers) { {} }
let(:url) { api("/projects/#{project.id}/packages/npm/#{package.name}/-/#{package_file.file_name}") }
@@ -127,24 +127,6 @@ RSpec.describe API::NpmProjectPackages do
context 'when params are correct' do
context 'invalid package record' do
- context 'unscoped package' do
- let(:package_name) { 'my_unscoped_package' }
- let(:params) { upload_params(package_name: package_name) }
-
- it_behaves_like 'handling invalid record with 400 error'
-
- context 'with empty versions' do
- let(:params) { upload_params(package_name: package_name).merge!(versions: {}) }
-
- it 'throws a 400 error' do
- expect { upload_package_with_token(package_name, params) }
- .not_to change { project.packages.count }
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
- end
- end
-
context 'invalid package name' do
let(:package_name) { "@#{group.path}/my_inv@@lid_package_name" }
let(:params) { upload_params(package_name: package_name) }
@@ -175,52 +157,71 @@ RSpec.describe API::NpmProjectPackages do
end
end
- context 'scoped package' do
- let(:package_name) { "@#{group.path}/my_package_name" }
+ context 'valid package record' do
let(:params) { upload_params(package_name: package_name) }
- context 'with access token' do
- subject { upload_package_with_token(package_name, params) }
+ shared_examples 'handling upload with different authentications' do
+ context 'with access token' do
+ subject { upload_package_with_token(package_name, params) }
+
+ it_behaves_like 'a package tracking event', 'API::NpmPackages', 'push_package'
+
+ it 'creates npm package with file' do
+ expect { subject }
+ .to change { project.packages.count }.by(1)
+ .and change { Packages::PackageFile.count }.by(1)
+ .and change { Packages::Tag.count }.by(1)
- it_behaves_like 'a package tracking event', 'API::NpmPackages', 'push_package'
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
- it 'creates npm package with file' do
- expect { subject }
+ it 'creates npm package with file with job token' do
+ expect { upload_package_with_job_token(package_name, params) }
.to change { project.packages.count }.by(1)
.and change { Packages::PackageFile.count }.by(1)
- .and change { Packages::Tag.count }.by(1)
expect(response).to have_gitlab_http_status(:ok)
end
- end
- it 'creates npm package with file with job token' do
- expect { upload_package_with_job_token(package_name, params) }
- .to change { project.packages.count }.by(1)
- .and change { Packages::PackageFile.count }.by(1)
+ context 'with an authenticated job token' do
+ let!(:job) { create(:ci_build, user: user) }
- expect(response).to have_gitlab_http_status(:ok)
- end
+ before do
+ Grape::Endpoint.before_each do |endpoint|
+ expect(endpoint).to receive(:current_authenticated_job) { job }
+ end
+ end
- context 'with an authenticated job token' do
- let!(:job) { create(:ci_build, user: user) }
+ after do
+ Grape::Endpoint.before_each nil
+ end
- before do
- Grape::Endpoint.before_each do |endpoint|
- expect(endpoint).to receive(:current_authenticated_job) { job }
+ it 'creates the package metadata' do
+ upload_package_with_token(package_name, params)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(project.reload.packages.find(json_response['id']).original_build_info.pipeline).to eq job.pipeline
end
end
+ end
- after do
- Grape::Endpoint.before_each nil
- end
+ context 'with a scoped name' do
+ let(:package_name) { "@#{group.path}/my_package_name" }
- it 'creates the package metadata' do
- upload_package_with_token(package_name, params)
+ it_behaves_like 'handling upload with different authentications'
+ end
- expect(response).to have_gitlab_http_status(:ok)
- expect(project.reload.packages.find(json_response['id']).original_build_info.pipeline).to eq job.pipeline
- end
+ context 'with any scoped name' do
+ let(:package_name) { "@any_scope/my_package_name" }
+
+ it_behaves_like 'handling upload with different authentications'
+ end
+
+ context 'with an unscoped name' do
+ let(:package_name) { "my_unscoped_package_name" }
+
+ it_behaves_like 'handling upload with different authentications'
end
end
diff --git a/spec/requests/api/oauth_tokens_spec.rb b/spec/requests/api/oauth_tokens_spec.rb
index 52c7408545f..edadfbc3d0c 100644
--- a/spec/requests/api/oauth_tokens_spec.rb
+++ b/spec/requests/api/oauth_tokens_spec.rb
@@ -27,13 +27,13 @@ RSpec.describe 'OAuth tokens' do
context 'when user does not have 2FA enabled' do
context 'when no client credentials provided' do
- it 'does not create an access token' do
+ it 'creates an access token' do
user = create(:user)
request_oauth_token(user)
- expect(response).to have_gitlab_http_status(:unauthorized)
- expect(json_response['access_token']).to be_nil
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['access_token']).to be_present
end
end
@@ -51,6 +51,8 @@ RSpec.describe 'OAuth tokens' do
context 'with invalid credentials' do
it 'does not create an access token' do
+ pending 'Enable this example after https://github.com/doorkeeper-gem/doorkeeper/pull/1488 is merged and released'
+
user = create(:user)
request_oauth_token(user, basic_auth_header(client.uid, 'invalid secret'))
diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml
index 181fcafd577..6c9a845b217 100644
--- a/spec/requests/api/project_attributes.yml
+++ b/spec/requests/api/project_attributes.yml
@@ -12,7 +12,6 @@ itself: # project
- import_source
- import_type
- import_url
- - issues_template
- jobs_cache_index
- last_repository_check_at
- last_repository_check_failed
@@ -24,7 +23,6 @@ itself: # project
- merge_requests_author_approval
- merge_requests_disable_committers_approval
- merge_requests_rebase_enabled
- - merge_requests_template
- mirror_last_successful_update_at
- mirror_last_update_at
- mirror_overwrites_diverged_branches
@@ -56,6 +54,7 @@ itself: # project
- can_create_merge_request_in
- compliance_frameworks
- container_expiration_policy
+ - container_registry_image_prefix
- default_branch
- empty_repo
- forks_count
@@ -117,6 +116,7 @@ project_feature:
- project_id
- requirements_access_level
- security_and_compliance_access_level
+ - container_registry_access_level
- updated_at
computed_attributes:
- issues_enabled
@@ -139,6 +139,7 @@ project_setting:
- show_default_award_emojis
- squash_option
- updated_at
+ - cve_id_request_enabled
build_service_desk_setting: # service_desk_setting
unexposed_attributes:
diff --git a/spec/requests/api/project_packages_spec.rb b/spec/requests/api/project_packages_spec.rb
index 1f3887cab8a..97414b3b18a 100644
--- a/spec/requests/api/project_packages_spec.rb
+++ b/spec/requests/api/project_packages_spec.rb
@@ -257,6 +257,10 @@ RSpec.describe API::ProjectPackages do
context 'project is private' do
let(:project) { create(:project, :private) }
+ before do
+ expect(::Packages::Maven::Metadata::SyncWorker).not_to receive(:perform_async)
+ end
+
it 'returns 404 for non authenticated user' do
delete api(package_url)
@@ -301,6 +305,19 @@ RSpec.describe API::ProjectPackages do
expect(response).to have_gitlab_http_status(:no_content)
end
end
+
+ context 'with a maven package' do
+ let_it_be(:package1) { create(:maven_package, project: project) }
+
+ it 'enqueues a sync worker job' do
+ project.add_maintainer(user)
+
+ expect(::Packages::Maven::Metadata::SyncWorker)
+ .to receive(:perform_async).with(user.id, project.id, package1.name)
+
+ delete api(package_url, user)
+ end
+ end
end
end
end
diff --git a/spec/requests/api/project_repository_storage_moves_spec.rb b/spec/requests/api/project_repository_storage_moves_spec.rb
index 5e200312d1f..b40645ba2de 100644
--- a/spec/requests/api/project_repository_storage_moves_spec.rb
+++ b/spec/requests/api/project_repository_storage_moves_spec.rb
@@ -7,6 +7,6 @@ RSpec.describe API::ProjectRepositoryStorageMoves do
let_it_be(:container) { create(:project, :repository).tap { |project| project.track_project_repository } }
let_it_be(:storage_move) { create(:project_repository_storage_move, :scheduled, container: container) }
let(:repository_storage_move_factory) { :project_repository_storage_move }
- let(:bulk_worker_klass) { ProjectScheduleBulkRepositoryShardMovesWorker }
+ let(:bulk_worker_klass) { Projects::ScheduleBulkRepositoryShardMovesWorker }
end
end
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index ad36777184a..d2a33e32b30 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -1478,6 +1478,120 @@ RSpec.describe API::Projects do
end
end
+ describe "GET /projects/:id/groups" do
+ let_it_be(:root_group) { create(:group, :public, name: 'root group') }
+ let_it_be(:project_group) { create(:group, :public, parent: root_group, name: 'project group') }
+ let_it_be(:shared_group_with_dev_access) { create(:group, :private, parent: root_group, name: 'shared group') }
+ let_it_be(:shared_group_with_reporter_access) { create(:group, :private) }
+ let_it_be(:private_project) { create(:project, :private, group: project_group) }
+ let_it_be(:public_project) { create(:project, :public, group: project_group) }
+
+ before_all do
+ create(:project_group_link, :developer, group: shared_group_with_dev_access, project: private_project)
+ create(:project_group_link, :reporter, group: shared_group_with_reporter_access, project: private_project)
+ end
+
+ shared_examples_for 'successful groups response' do
+ it 'returns an array of groups' do
+ request
+
+ aggregate_failures do
+ 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 { |g| g['name'] }).to match_array(expected_groups.map(&:name))
+ end
+ end
+ end
+
+ context 'when unauthenticated' do
+ it 'does not return groups for private projects' do
+ get api("/projects/#{private_project.id}/groups")
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ context 'for public projects' do
+ let(:request) { get api("/projects/#{public_project.id}/groups") }
+
+ it_behaves_like 'successful groups response' do
+ let(:expected_groups) { [root_group, project_group] }
+ end
+ end
+ end
+
+ context 'when authenticated as user' do
+ context 'when user does not have access to the project' do
+ it 'does not return groups' do
+ get api("/projects/#{private_project.id}/groups", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when user has access to the project' do
+ let(:request) { get api("/projects/#{private_project.id}/groups", user), params: params }
+ let(:params) { {} }
+
+ before do
+ private_project.add_developer(user)
+ end
+
+ it_behaves_like 'successful groups response' do
+ let(:expected_groups) { [root_group, project_group] }
+ end
+
+ context 'when search by root group name' do
+ let(:params) { { search: 'root' } }
+
+ it_behaves_like 'successful groups response' do
+ let(:expected_groups) { [root_group] }
+ end
+ end
+
+ context 'with_shared option is on' do
+ let(:params) { { with_shared: true } }
+
+ it_behaves_like 'successful groups response' do
+ let(:expected_groups) { [root_group, project_group, shared_group_with_dev_access, shared_group_with_reporter_access] }
+ end
+
+ context 'when shared_min_access_level is set' do
+ let(:params) { super().merge(shared_min_access_level: Gitlab::Access::DEVELOPER) }
+
+ it_behaves_like 'successful groups response' do
+ let(:expected_groups) { [root_group, project_group, shared_group_with_dev_access] }
+ end
+ end
+
+ context 'when search by shared group name' do
+ let(:params) { super().merge(search: 'shared') }
+
+ it_behaves_like 'successful groups response' do
+ let(:expected_groups) { [shared_group_with_dev_access] }
+ end
+ end
+
+ context 'when skip_groups is set' do
+ let(:params) { super().merge(skip_groups: [shared_group_with_dev_access.id, root_group.id]) }
+
+ it_behaves_like 'successful groups response' do
+ let(:expected_groups) { [shared_group_with_reporter_access, project_group] }
+ end
+ end
+ end
+ end
+ end
+
+ context 'when authenticated as admin' do
+ let(:request) { get api("/projects/#{private_project.id}/groups", admin) }
+
+ it_behaves_like 'successful groups response' do
+ let(:expected_groups) { [root_group, project_group] }
+ end
+ end
+ end
+
describe 'GET /projects/:id' do
context 'when unauthenticated' do
it 'does not return private projects' do
@@ -1540,6 +1654,10 @@ RSpec.describe API::Projects do
end
context 'when authenticated as an admin' do
+ before do
+ stub_container_registry_config(enabled: true, host_port: 'registry.example.org:5000')
+ end
+
let(:project_attributes_file) { 'spec/requests/api/project_attributes.yml' }
let(:project_attributes) { YAML.load_file(project_attributes_file) }
@@ -1563,13 +1681,15 @@ RSpec.describe API::Projects do
mirror
requirements_enabled
security_and_compliance_enabled
+ issues_template
+ merge_requests_template
]
end
keys
end
- it 'returns a project by id' do
+ it 'returns a project by id', :aggregate_failures do
project
project_member
group = create(:group)
@@ -1587,6 +1707,7 @@ RSpec.describe API::Projects do
expect(json_response['ssh_url_to_repo']).to be_present
expect(json_response['http_url_to_repo']).to be_present
expect(json_response['web_url']).to be_present
+ expect(json_response['container_registry_image_prefix']).to eq("registry.example.org:5000/#{project.full_path}")
expect(json_response['owner']).to be_a Hash
expect(json_response['name']).to eq(project.name)
expect(json_response['path']).to be_present
@@ -1644,9 +1765,10 @@ RSpec.describe API::Projects do
before do
project
project_member
+ stub_container_registry_config(enabled: true, host_port: 'registry.example.org:5000')
end
- it 'returns a project by id' do
+ it 'returns a project by id', :aggregate_failures do
group = create(:group)
link = create(:project_group_link, project: project, group: group)
@@ -1662,6 +1784,7 @@ RSpec.describe API::Projects do
expect(json_response['ssh_url_to_repo']).to be_present
expect(json_response['http_url_to_repo']).to be_present
expect(json_response['web_url']).to be_present
+ expect(json_response['container_registry_image_prefix']).to eq("registry.example.org:5000/#{project.full_path}")
expect(json_response['owner']).to be_a Hash
expect(json_response['name']).to eq(project.name)
expect(json_response['path']).to be_present
@@ -2818,7 +2941,7 @@ RSpec.describe API::Projects do
Sidekiq::Testing.fake! do
put(api("/projects/#{new_project.id}", user), params: { repository_storage: unknown_storage, issues_enabled: false })
end
- end.not_to change(ProjectUpdateRepositoryStorageWorker.jobs, :size)
+ end.not_to change(Projects::UpdateRepositoryStorageWorker.jobs, :size)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['issues_enabled']).to eq(false)
@@ -2845,7 +2968,7 @@ RSpec.describe API::Projects do
Sidekiq::Testing.fake! do
put(api("/projects/#{new_project.id}", admin), params: { repository_storage: 'test_second_storage' })
end
- end.to change(ProjectUpdateRepositoryStorageWorker.jobs, :size).by(1)
+ end.to change(Projects::UpdateRepositoryStorageWorker.jobs, :size).by(1)
expect(response).to have_gitlab_http_status(:ok)
end
diff --git a/spec/requests/api/protected_branches_spec.rb b/spec/requests/api/protected_branches_spec.rb
index 8bcd493eb1f..6b1aa576167 100644
--- a/spec/requests/api/protected_branches_spec.rb
+++ b/spec/requests/api/protected_branches_spec.rb
@@ -68,6 +68,7 @@ RSpec.describe API::ProtectedBranches do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['name']).to eq(branch_name)
+ expect(json_response['allow_force_push']).to eq(false)
expect(json_response['push_access_levels'][0]['access_level']).to eq(::Gitlab::Access::MAINTAINER)
expect(json_response['merge_access_levels'][0]['access_level']).to eq(::Gitlab::Access::MAINTAINER)
end
@@ -132,6 +133,7 @@ RSpec.describe API::ProtectedBranches do
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq(branch_name)
+ expect(json_response['allow_force_push']).to eq(false)
expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER)
expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER)
end
@@ -141,6 +143,7 @@ RSpec.describe API::ProtectedBranches do
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq(branch_name)
+ expect(json_response['allow_force_push']).to eq(false)
expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::DEVELOPER)
expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER)
end
@@ -150,6 +153,7 @@ RSpec.describe API::ProtectedBranches do
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq(branch_name)
+ expect(json_response['allow_force_push']).to eq(false)
expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER)
expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::DEVELOPER)
end
@@ -159,6 +163,7 @@ RSpec.describe API::ProtectedBranches do
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq(branch_name)
+ expect(json_response['allow_force_push']).to eq(false)
expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::DEVELOPER)
expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::DEVELOPER)
end
@@ -168,6 +173,7 @@ RSpec.describe API::ProtectedBranches do
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq(branch_name)
+ expect(json_response['allow_force_push']).to eq(false)
expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::NO_ACCESS)
expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER)
end
@@ -177,6 +183,7 @@ RSpec.describe API::ProtectedBranches do
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq(branch_name)
+ expect(json_response['allow_force_push']).to eq(false)
expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER)
expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::NO_ACCESS)
end
@@ -186,10 +193,21 @@ RSpec.describe API::ProtectedBranches do
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq(branch_name)
+ expect(json_response['allow_force_push']).to eq(false)
expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::NO_ACCESS)
expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::NO_ACCESS)
end
+ it 'protects a single branch and allows force pushes' do
+ post post_endpoint, params: { name: branch_name, allow_force_push: true }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['name']).to eq(branch_name)
+ expect(json_response['allow_force_push']).to eq(true)
+ expect(json_response['push_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER)
+ expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER)
+ end
+
it 'returns a 409 error if the same branch is protected twice' do
post post_endpoint, params: { name: protected_name }
diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb
index ace73e49c7c..31f0d7cec2a 100644
--- a/spec/requests/api/repositories_spec.rb
+++ b/spec/requests/api/repositories_spec.rb
@@ -650,6 +650,40 @@ RSpec.describe API::Repositories do
expect(response).to have_gitlab_http_status(:ok)
end
+ it 'supports leaving out the from and to attribute' do
+ spy = instance_spy(Repositories::ChangelogService)
+
+ allow(Repositories::ChangelogService)
+ .to receive(:new)
+ .with(
+ project,
+ user,
+ version: '1.0.0',
+ date: DateTime.new(2020, 1, 1),
+ branch: 'kittens',
+ trailer: 'Foo',
+ file: 'FOO.md',
+ message: 'Commit message'
+ )
+ .and_return(spy)
+
+ expect(spy).to receive(:execute)
+
+ post(
+ api("/projects/#{project.id}/repository/changelog", user),
+ params: {
+ version: '1.0.0',
+ date: '2020-01-01',
+ branch: 'kittens',
+ trailer: 'Foo',
+ file: 'FOO.md',
+ message: 'Commit message'
+ }
+ )
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
it 'produces an error when generating the changelog fails' do
spy = instance_spy(Repositories::ChangelogService)
diff --git a/spec/requests/api/resource_access_tokens_spec.rb b/spec/requests/api/resource_access_tokens_spec.rb
index 9fd7eb2177d..79549bfc5e0 100644
--- a/spec/requests/api/resource_access_tokens_spec.rb
+++ b/spec/requests/api/resource_access_tokens_spec.rb
@@ -30,6 +30,18 @@ RSpec.describe API::ResourceAccessTokens do
expect(token_ids).to match_array(access_tokens.pluck(:id))
end
+ it "exposes the correct token information", :aggregate_failures do
+ get_tokens
+
+ token = access_tokens.last
+ api_get_token = json_response.last
+
+ expect(api_get_token["name"]).to eq(token.name)
+ expect(api_get_token["scopes"]).to eq(token.scopes)
+ expect(api_get_token["expires_at"]).to eq(token.expires_at.to_date.iso8601)
+ expect(api_get_token).not_to have_key('token')
+ end
+
context "when using a project access token to GET other project access tokens" do
let_it_be(:token) { access_tokens.first }
@@ -182,13 +194,13 @@ RSpec.describe API::ResourceAccessTokens do
end
describe "POST projects/:id/access_tokens" do
- let_it_be(:params) { { name: "test", scopes: ["api"], expires_at: Date.today + 1.month } }
+ let(:params) { { name: "test", scopes: ["api"], expires_at: expires_at } }
+ let(:expires_at) { 1.month.from_now }
subject(:create_token) { post api("/projects/#{project_id}/access_tokens", user), params: params }
context "when the user has maintainer permissions" do
let_it_be(:project_id) { project.id }
- let_it_be(:expires_at) { 1.month.from_now }
before do
project.add_maintainer(user)
@@ -203,11 +215,12 @@ RSpec.describe API::ResourceAccessTokens do
expect(json_response["name"]).to eq("test")
expect(json_response["scopes"]).to eq(["api"])
expect(json_response["expires_at"]).to eq(expires_at.to_date.iso8601)
+ expect(json_response["token"]).to be_present
end
end
context "when 'expires_at' is not set" do
- let_it_be(:params) { { name: "test", scopes: ["api"] } }
+ let(:expires_at) { nil }
it "creates a project access token with the params", :aggregate_failures do
create_token
diff --git a/spec/requests/api/rubygem_packages_spec.rb b/spec/requests/api/rubygem_packages_spec.rb
index 5dd68bf9b10..d6ad8186063 100644
--- a/spec/requests/api/rubygem_packages_spec.rb
+++ b/spec/requests/api/rubygem_packages_spec.rb
@@ -3,9 +3,11 @@
require 'spec_helper'
RSpec.describe API::RubygemPackages do
+ include PackagesManagerApiSpecHelpers
+ include WorkhorseHelpers
using RSpec::Parameterized::TableSyntax
- let_it_be(:project) { create(:project) }
+ let_it_be_with_reload(:project) { create(:project) }
let_it_be(:personal_access_token) { create(:personal_access_token) }
let_it_be(:user) { personal_access_token.user }
let_it_be(:job) { create(:ci_build, :running, user: user) }
@@ -13,6 +15,14 @@ RSpec.describe API::RubygemPackages do
let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
let_it_be(:headers) { {} }
+ let(:tokens) do
+ {
+ personal_access_token: personal_access_token.token,
+ deploy_token: deploy_token.token,
+ job_token: job.token
+ }
+ end
+
shared_examples 'when feature flag is disabled' do
let(:headers) do
{ 'HTTP_AUTHORIZATION' => personal_access_token.token }
@@ -34,7 +44,7 @@ RSpec.describe API::RubygemPackages do
end
shared_examples 'without authentication' do
- it_behaves_like 'returning response status', :unauthorized
+ it_behaves_like 'returning response status', :not_found
end
shared_examples 'with authentication' do
@@ -42,14 +52,6 @@ RSpec.describe API::RubygemPackages do
{ 'HTTP_AUTHORIZATION' => token }
end
- let(:tokens) do
- {
- personal_access_token: personal_access_token.token,
- deploy_token: deploy_token.token,
- job_token: job.token
- }
- end
-
where(:user_role, :token_type, :valid_token, :status) do
:guest | :personal_access_token | true | :not_found
:guest | :personal_access_token | false | :unauthorized
@@ -106,34 +108,290 @@ RSpec.describe API::RubygemPackages do
end
describe 'GET /api/v4/projects/:project_id/packages/rubygems/gems/:file_name' do
- let(:url) { api("/projects/#{project.id}/packages/rubygems/gems/my_gem-1.0.0.gem") }
+ let_it_be(:package_name) { 'package' }
+ let_it_be(:version) { '0.0.1' }
+ let_it_be(:package) { create(:rubygems_package, project: project, name: package_name, version: version) }
+ let_it_be(:file_name) { "#{package_name}-#{version}.gem" }
+
+ let(:url) { api("/projects/#{project.id}/packages/rubygems/gems/#{file_name}") }
subject { get(url, headers: headers) }
- it_behaves_like 'an unimplemented route'
+ context 'with valid project' do
+ where(:visibility, :user_role, :member, :token_type, :valid_token, :shared_examples_name, :expected_status) do
+ :public | :developer | true | :personal_access_token | true | 'Rubygems gem download' | :success
+ :public | :guest | true | :personal_access_token | true | 'Rubygems gem download' | :success
+ :public | :developer | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :guest | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :developer | false | :personal_access_token | true | 'Rubygems gem download' | :success
+ :public | :guest | false | :personal_access_token | true | 'Rubygems gem download' | :success
+ :public | :developer | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :guest | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :anonymous | false | :personal_access_token | true | 'Rubygems gem download' | :success
+ :private | :developer | true | :personal_access_token | true | 'Rubygems gem download' | :success
+ :private | :guest | true | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden
+ :private | :developer | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :guest | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found
+ :private | :guest | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found
+ :private | :developer | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :guest | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :anonymous | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found
+ :public | :developer | true | :job_token | true | 'Rubygems gem download' | :success
+ :public | :guest | true | :job_token | true | 'Rubygems gem download' | :success
+ :public | :developer | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :guest | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :developer | false | :job_token | true | 'Rubygems gem download' | :success
+ :public | :guest | false | :job_token | true | 'Rubygems gem download' | :success
+ :public | :developer | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :guest | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | true | :job_token | true | 'Rubygems gem download' | :success
+ :private | :guest | true | :job_token | true | 'rejects rubygems packages access' | :forbidden
+ :private | :developer | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :guest | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | false | :job_token | true | 'rejects rubygems packages access' | :not_found
+ :private | :guest | false | :job_token | true | 'rejects rubygems packages access' | :not_found
+ :private | :developer | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :guest | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :developer | true | :deploy_token | true | 'Rubygems gem download' | :success
+ :public | :developer | true | :deploy_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | true | :deploy_token | true | 'Rubygems gem download' | :success
+ :private | :developer | true | :deploy_token | false | 'rejects rubygems packages access' | :unauthorized
+ end
+
+ with_them do
+ let(:token) { valid_token ? tokens[token_type] : 'invalid-token123' }
+ let(:headers) { user_role == :anonymous ? {} : { 'HTTP_AUTHORIZATION' => token } }
+
+ before do
+ project.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value(visibility.to_s))
+ end
+
+ it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member]
+ end
+ end
end
describe 'POST /api/v4/projects/:project_id/packages/rubygems/api/v1/gems/authorize' do
+ include_context 'workhorse headers'
+
let(:url) { api("/projects/#{project.id}/packages/rubygems/api/v1/gems/authorize") }
+ let(:headers) { {} }
subject { post(url, headers: headers) }
- it_behaves_like 'an unimplemented route'
+ context 'with valid project' do
+ where(:visibility, :user_role, :member, :token_type, :valid_token, :shared_examples_name, :expected_status) do
+ :public | :developer | true | :personal_access_token | true | 'process rubygems workhorse authorization' | :success
+ :public | :guest | true | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden
+ :public | :developer | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :guest | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :developer | false | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden
+ :public | :guest | false | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden
+ :public | :developer | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :guest | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :anonymous | false | :personal_access_token | true | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | true | :personal_access_token | true | 'process rubygems workhorse authorization' | :success
+ :private | :guest | true | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden
+ :private | :developer | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :guest | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found
+ :private | :guest | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found
+ :private | :developer | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :guest | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :anonymous | false | :personal_access_token | true | 'rejects rubygems packages access' | :unauthorized
+ :public | :developer | true | :job_token | true | 'process rubygems workhorse authorization' | :success
+ :public | :guest | true | :job_token | true | 'rejects rubygems packages access' | :forbidden
+ :public | :developer | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :guest | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :developer | false | :job_token | true | 'rejects rubygems packages access' | :forbidden
+ :public | :guest | false | :job_token | true | 'rejects rubygems packages access' | :forbidden
+ :public | :developer | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :guest | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | true | :job_token | true | 'process rubygems workhorse authorization' | :success
+ :private | :guest | true | :job_token | true | 'rejects rubygems packages access' | :forbidden
+ :private | :developer | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :guest | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | false | :job_token | true | 'rejects rubygems packages access' | :not_found
+ :private | :guest | false | :job_token | true | 'rejects rubygems packages access' | :not_found
+ :private | :developer | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :guest | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :developer | true | :deploy_token | true | 'process rubygems workhorse authorization' | :success
+ :public | :developer | true | :deploy_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | true | :deploy_token | true | 'process rubygems workhorse authorization' | :success
+ :private | :developer | true | :deploy_token | false | 'rejects rubygems packages access' | :unauthorized
+ end
+
+ with_them do
+ let(:token) { valid_token ? tokens[token_type] : 'invalid-token123' }
+ let(:user_headers) { user_role == :anonymous ? {} : { 'HTTP_AUTHORIZATION' => token } }
+ let(:headers) { user_headers.merge(workhorse_headers) }
+
+ before do
+ project.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value(visibility.to_s))
+ end
+
+ it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member]
+ end
+ end
end
describe 'POST /api/v4/projects/:project_id/packages/rubygems/api/v1/gems' do
- let(:url) { api("/projects/#{project.id}/packages/rubygems/api/v1/gems") }
+ include_context 'workhorse headers'
+
+ let(:url) { "/projects/#{project.id}/packages/rubygems/api/v1/gems" }
+
+ let_it_be(:file_name) { 'package.gem' }
+ let(:headers) { {} }
+ let(:params) { { file: temp_file(file_name) } }
+ let(:file_key) { :file }
+ let(:send_rewritten_field) { true }
+
+ subject do
+ workhorse_finalize(
+ api(url),
+ method: :post,
+ file_key: file_key,
+ params: params,
+ headers: headers,
+ send_rewritten_field: send_rewritten_field
+ )
+ end
- subject { post(url, headers: headers) }
+ context 'with valid project' do
+ where(:visibility, :user_role, :member, :token_type, :valid_token, :shared_examples_name, :expected_status) do
+ :public | :developer | true | :personal_access_token | true | 'process rubygems upload' | :created
+ :public | :guest | true | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden
+ :public | :developer | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :guest | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :developer | false | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden
+ :public | :guest | false | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden
+ :public | :developer | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :guest | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :anonymous | false | :personal_access_token | true | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | true | :personal_access_token | true | 'process rubygems upload' | :created
+ :private | :guest | true | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden
+ :private | :developer | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :guest | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found
+ :private | :guest | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found
+ :private | :developer | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :guest | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :anonymous | false | :personal_access_token | true | 'rejects rubygems packages access' | :unauthorized
+ :public | :developer | true | :job_token | true | 'process rubygems upload' | :created
+ :public | :guest | true | :job_token | true | 'rejects rubygems packages access' | :forbidden
+ :public | :developer | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :guest | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :developer | false | :job_token | true | 'rejects rubygems packages access' | :forbidden
+ :public | :guest | false | :job_token | true | 'rejects rubygems packages access' | :forbidden
+ :public | :developer | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :guest | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | true | :job_token | true | 'process rubygems upload' | :created
+ :private | :guest | true | :job_token | true | 'rejects rubygems packages access' | :forbidden
+ :private | :developer | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :guest | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | false | :job_token | true | 'rejects rubygems packages access' | :not_found
+ :private | :guest | false | :job_token | true | 'rejects rubygems packages access' | :not_found
+ :private | :developer | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :guest | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :developer | true | :deploy_token | true | 'process rubygems upload' | :created
+ :public | :developer | true | :deploy_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | true | :deploy_token | true | 'process rubygems upload' | :created
+ :private | :developer | true | :deploy_token | false | 'rejects rubygems packages access' | :unauthorized
+ end
- it_behaves_like 'an unimplemented route'
+ with_them do
+ let(:token) { valid_token ? tokens[token_type] : 'invalid-token123' }
+ let(:user_headers) { user_role == :anonymous ? {} : { 'HTTP_AUTHORIZATION' => token } }
+ let(:headers) { user_headers.merge(workhorse_headers) }
+
+ before do
+ project.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value(visibility.to_s))
+ end
+
+ it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member]
+ end
+
+ context 'failed package file save' do
+ let(:user_headers) { { 'HTTP_AUTHORIZATION' => personal_access_token.token } }
+ let(:headers) { user_headers.merge(workhorse_headers) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ it 'does not create package record', :aggregate_failures do
+ allow(Packages::CreatePackageFileService).to receive(:new).and_raise(StandardError)
+
+ expect { subject }
+ .to change { project.packages.count }.by(0)
+ .and change { Packages::PackageFile.count }.by(0)
+ expect(response).to have_gitlab_http_status(:error)
+ end
+ end
+ end
end
describe 'GET /api/v4/projects/:project_id/packages/rubygems/api/v1/dependencies' do
+ let_it_be(:package) { create(:rubygems_package, project: project) }
+
let(:url) { api("/projects/#{project.id}/packages/rubygems/api/v1/dependencies") }
- subject { get(url, headers: headers) }
+ subject { get(url, headers: headers, params: params) }
+
+ context 'with valid project' do
+ where(:visibility, :user_role, :member, :token_type, :valid_token, :shared_examples_name, :expected_status) do
+ :public | :developer | true | :personal_access_token | true | 'dependency endpoint success' | :success
+ :public | :guest | true | :personal_access_token | true | 'dependency endpoint success' | :success
+ :public | :developer | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :guest | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :developer | false | :personal_access_token | true | 'dependency endpoint success' | :success
+ :public | :guest | false | :personal_access_token | true | 'dependency endpoint success' | :success
+ :public | :developer | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :guest | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :anonymous | false | :personal_access_token | true | 'dependency endpoint success' | :success
+ :private | :developer | true | :personal_access_token | true | 'dependency endpoint success' | :success
+ :private | :guest | true | :personal_access_token | true | 'rejects rubygems packages access' | :forbidden
+ :private | :developer | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :guest | true | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found
+ :private | :guest | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found
+ :private | :developer | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :guest | false | :personal_access_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :anonymous | false | :personal_access_token | true | 'rejects rubygems packages access' | :not_found
+ :public | :developer | true | :job_token | true | 'dependency endpoint success' | :success
+ :public | :guest | true | :job_token | true | 'dependency endpoint success' | :success
+ :public | :developer | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :guest | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :developer | false | :job_token | true | 'dependency endpoint success' | :success
+ :public | :guest | false | :job_token | true | 'dependency endpoint success' | :success
+ :public | :developer | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :guest | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | true | :job_token | true | 'dependency endpoint success' | :success
+ :private | :guest | true | :job_token | true | 'rejects rubygems packages access' | :forbidden
+ :private | :developer | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :guest | true | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | false | :job_token | true | 'rejects rubygems packages access' | :not_found
+ :private | :guest | false | :job_token | true | 'rejects rubygems packages access' | :not_found
+ :private | :developer | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :guest | false | :job_token | false | 'rejects rubygems packages access' | :unauthorized
+ :public | :developer | true | :deploy_token | true | 'dependency endpoint success' | :success
+ :public | :developer | true | :deploy_token | false | 'rejects rubygems packages access' | :unauthorized
+ :private | :developer | true | :deploy_token | true | 'dependency endpoint success' | :success
+ :private | :developer | true | :deploy_token | false | 'rejects rubygems packages access' | :unauthorized
+ end
- it_behaves_like 'an unimplemented route'
+ with_them do
+ let(:token) { valid_token ? tokens[token_type] : 'invalid-token123' }
+ let(:headers) { user_role == :anonymous ? {} : { 'HTTP_AUTHORIZATION' => token } }
+ let(:params) { {} }
+
+ before do
+ project.update!(visibility: visibility.to_s)
+ end
+
+ it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member]
+ end
+ end
end
end
diff --git a/spec/requests/api/snippet_repository_storage_moves_spec.rb b/spec/requests/api/snippet_repository_storage_moves_spec.rb
index edb92569823..40d01500ac1 100644
--- a/spec/requests/api/snippet_repository_storage_moves_spec.rb
+++ b/spec/requests/api/snippet_repository_storage_moves_spec.rb
@@ -7,6 +7,6 @@ RSpec.describe API::SnippetRepositoryStorageMoves do
let_it_be(:container) { create(:snippet, :repository).tap { |snippet| snippet.create_repository } }
let_it_be(:storage_move) { create(:snippet_repository_storage_move, :scheduled, container: container) }
let(:repository_storage_move_factory) { :snippet_repository_storage_move }
- let(:bulk_worker_klass) { SnippetScheduleBulkRepositoryShardMovesWorker }
+ let(:bulk_worker_klass) { Snippets::ScheduleBulkRepositoryShardMovesWorker }
end
end
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index d70a8bd692d..2a7689eaddf 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -320,6 +320,18 @@ RSpec.describe API::Users do
expect(json_response).to all(include('state' => /(blocked|ldap_blocked)/))
end
+ it "returns an array of external users" do
+ create(:user)
+ external_user = create(:user, external: true)
+
+ get api("/users?external=true", user)
+
+ expect(response).to match_response_schema('public_api/v4/user/basics')
+ expect(response).to include_pagination_headers
+ expect(json_response.size).to eq(1)
+ expect(json_response[0]['id']).to eq(external_user.id)
+ end
+
it "returns one user" do
get api("/users?username=#{omniauth_user.username}", user)
@@ -940,6 +952,18 @@ RSpec.describe API::Users do
expect(new_user.private_profile?).to eq(true)
end
+ it "creates user with view_diffs_file_by_file" do
+ post api('/users', admin), params: attributes_for(:user, view_diffs_file_by_file: true)
+
+ expect(response).to have_gitlab_http_status(:created)
+
+ user_id = json_response['id']
+ new_user = User.find(user_id)
+
+ expect(new_user).not_to eq(nil)
+ expect(new_user.user_preference.view_diffs_file_by_file?).to eq(true)
+ end
+
it "does not create user with invalid email" do
post api('/users', admin),
params: {
@@ -1254,6 +1278,13 @@ RSpec.describe API::Users do
expect(user.reload.private_profile).to eq(true)
end
+ it "updates viewing diffs file by file" do
+ put api("/users/#{user.id}", admin), params: { view_diffs_file_by_file: true }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(user.reload.user_preference.view_diffs_file_by_file?).to eq(true)
+ end
+
it "updates private profile to false when nil is given" do
user.update!(private_profile: true)
@@ -3044,18 +3075,6 @@ RSpec.describe API::Users do
expect(response).to have_gitlab_http_status(:bad_request)
end
-
- context 'when the clear_status_with_quick_options feature flag is disabled' do
- before do
- stub_feature_flags(clear_status_with_quick_options: false)
- end
-
- it 'does not persist clear_status_at' do
- put api('/user/status', user), params: { emoji: 'smirk', message: 'hello world', clear_status_after: '3_hours' }
-
- expect(user.status.reload.clear_status_at).to be_nil
- end
- end
end
end
diff --git a/spec/requests/api/v3/github_spec.rb b/spec/requests/api/v3/github_spec.rb
index e7d9ba99743..197c6cbb0eb 100644
--- a/spec/requests/api/v3/github_spec.rb
+++ b/spec/requests/api/v3/github_spec.rb
@@ -149,6 +149,8 @@ RSpec.describe API::V3::Github do
end
describe 'GET events' do
+ include ProjectForksHelper
+
let(:group) { create(:group) }
let(:project) { create(:project, :empty_repo, path: 'project.with.dot', group: group) }
let(:events_path) { "/repos/#{group.path}/#{project.path}/events" }
@@ -174,6 +176,17 @@ RSpec.describe API::V3::Github do
end
end
+ it 'avoids N+1 queries' do
+ create(:merge_request, source_project: project)
+ source_project = fork_project(project, nil, repository: true)
+
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { jira_get v3_api(events_path, user) }.count
+
+ create_list(:merge_request, 2, :unique_branches, source_project: source_project, target_project: project)
+
+ expect { jira_get v3_api(events_path, user) }.not_to exceed_all_query_limit(control_count)
+ end
+
context 'if there are more merge requests' do
let!(:merge_request) { create(:merge_request, id: 10000, source_project: project, target_project: project, author: user) }
let!(:merge_request2) { create(:merge_request, id: 10001, source_project: project, source_branch: generate(:branch), target_project: project, author: user) }
diff --git a/spec/requests/api/wikis_spec.rb b/spec/requests/api/wikis_spec.rb
index f271f8aa853..d35aab40ca9 100644
--- a/spec/requests/api/wikis_spec.rb
+++ b/spec/requests/api/wikis_spec.rb
@@ -14,6 +14,7 @@ require 'spec_helper'
RSpec.describe API::Wikis do
include WorkhorseHelpers
+ include AfterNextHelpers
let(:user) { create(:user) }
let(:group) { create(:group).tap { |g| g.add_owner(user) } }
@@ -578,6 +579,20 @@ RSpec.describe API::Wikis do
include_examples 'wiki API 404 Wiki Page Not Found'
end
end
+
+ context 'when there is an error deleting the page' do
+ it 'returns 422' do
+ project.add_maintainer(user)
+
+ allow_next(WikiPages::DestroyService, current_user: user, container: project)
+ .to receive(:execute).and_return(ServiceResponse.error(message: 'foo'))
+
+ delete(api(url, user))
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ expect(json_response['message']).to eq 'foo'
+ end
+ end
end
context 'when wiki belongs to a group project' do
diff --git a/spec/requests/ide_controller_spec.rb b/spec/requests/ide_controller_spec.rb
index 805c1f1d82b..4f127e07b6b 100644
--- a/spec/requests/ide_controller_spec.rb
+++ b/spec/requests/ide_controller_spec.rb
@@ -3,7 +3,11 @@
require 'spec_helper'
RSpec.describe IdeController do
- let(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:creator) { project.creator }
+ let_it_be(:other_user) { create(:user) }
+
+ let(:user) { creator }
before do
sign_in(user)
@@ -14,4 +18,172 @@ RSpec.describe IdeController do
get ide_url
end
+
+ describe '#index', :aggregate_failures do
+ subject { get route }
+
+ shared_examples 'user cannot push code' do
+ include ProjectForksHelper
+
+ let(:user) { other_user }
+
+ context 'when user does not have fork' do
+ it 'does not instantiate forked_project instance var and return 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:project)).to eq project
+ expect(assigns(:forked_project)).to be_nil
+ end
+ end
+
+ context 'when user has have fork' do
+ let!(:fork) { fork_project(project, user, repository: true) }
+
+ it 'instantiates forked_project instance var and return 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:project)).to eq project
+ expect(assigns(:forked_project)).to eq fork
+ end
+ end
+ end
+
+ context '/-/ide' do
+ let(:route) { '/-/ide' }
+
+ it 'does not instantiate any instance var and return 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:project)).to be_nil
+ expect(assigns(:branch)).to be_nil
+ expect(assigns(:path)).to be_nil
+ expect(assigns(:merge_request)).to be_nil
+ expect(assigns(:forked_project)).to be_nil
+ end
+ end
+
+ context '/-/ide/project' do
+ let(:route) { '/-/ide/project' }
+
+ it 'does not instantiate any instance var and return 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:project)).to be_nil
+ expect(assigns(:branch)).to be_nil
+ expect(assigns(:path)).to be_nil
+ expect(assigns(:merge_request)).to be_nil
+ expect(assigns(:forked_project)).to be_nil
+ end
+ end
+
+ context '/-/ide/project/:project' do
+ let(:route) { "/-/ide/project/#{project.full_path}" }
+
+ it 'instantiates project instance var and return 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:project)).to eq project
+ expect(assigns(:branch)).to be_nil
+ expect(assigns(:path)).to be_nil
+ expect(assigns(:merge_request)).to be_nil
+ expect(assigns(:forked_project)).to be_nil
+ end
+
+ it_behaves_like 'user cannot push code'
+
+ %w(edit blob tree).each do |action|
+ context "/-/ide/project/:project/#{action}" do
+ let(:route) { "/-/ide/project/#{project.full_path}/#{action}" }
+
+ it 'instantiates project instance var and return 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:project)).to eq project
+ expect(assigns(:branch)).to be_nil
+ expect(assigns(:path)).to be_nil
+ expect(assigns(:merge_request)).to be_nil
+ expect(assigns(:forked_project)).to be_nil
+ end
+
+ it_behaves_like 'user cannot push code'
+
+ context "/-/ide/project/:project/#{action}/:branch" do
+ let(:route) { "/-/ide/project/#{project.full_path}/#{action}/master" }
+
+ it 'instantiates project and branch instance vars and return 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:project)).to eq project
+ expect(assigns(:branch)).to eq 'master'
+ expect(assigns(:path)).to be_nil
+ expect(assigns(:merge_request)).to be_nil
+ expect(assigns(:forked_project)).to be_nil
+ end
+
+ it_behaves_like 'user cannot push code'
+
+ context "/-/ide/project/:project/#{action}/:branch/-" do
+ let(:route) { "/-/ide/project/#{project.full_path}/#{action}/branch/slash/-" }
+
+ it 'instantiates project and branch instance vars and return 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:project)).to eq project
+ expect(assigns(:branch)).to eq 'branch/slash'
+ expect(assigns(:path)).to be_nil
+ expect(assigns(:merge_request)).to be_nil
+ expect(assigns(:forked_project)).to be_nil
+ end
+
+ it_behaves_like 'user cannot push code'
+
+ context "/-/ide/project/:project/#{action}/:branch/-/:path" do
+ let(:route) { "/-/ide/project/#{project.full_path}/#{action}/master/-/foo/.bar" }
+
+ it 'instantiates project, branch, and path instance vars and return 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:project)).to eq project
+ expect(assigns(:branch)).to eq 'master'
+ expect(assigns(:path)).to eq 'foo/.bar'
+ expect(assigns(:merge_request)).to be_nil
+ expect(assigns(:forked_project)).to be_nil
+ end
+
+ it_behaves_like 'user cannot push code'
+ end
+ end
+ end
+ end
+ end
+
+ context '/-/ide/project/:project/merge_requests/:merge_request_id' do
+ let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
+
+ let(:route) { "/-/ide/project/#{project.full_path}/merge_requests/#{merge_request.id}" }
+
+ it 'instantiates project and merge_request instance vars and return 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:project)).to eq project
+ expect(assigns(:branch)).to be_nil
+ expect(assigns(:path)).to be_nil
+ expect(assigns(:merge_request)).to eq merge_request.id.to_s
+ expect(assigns(:forked_project)).to be_nil
+ end
+
+ it_behaves_like 'user cannot push code'
+ end
+ end
+ end
end
diff --git a/spec/requests/projects/merge_requests/content_spec.rb b/spec/requests/projects/merge_requests/content_spec.rb
new file mode 100644
index 00000000000..7e5ec6f64c4
--- /dev/null
+++ b/spec/requests/projects/merge_requests/content_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'merge request content spec' do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:merge_request) { create(:merge_request, :with_head_pipeline, target_project: project, source_project: project) }
+ let_it_be(:ci_build) { create(:ci_build, :artifacts, pipeline: merge_request.head_pipeline) }
+
+ before do
+ sign_in(user)
+ project.add_maintainer(user)
+ end
+
+ shared_examples 'cached widget request' do
+ it 'avoids N+1 queries when multiple job artifacts are present' do
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ get cached_widget_project_json_merge_request_path(project, merge_request, format: :json)
+ end
+
+ create_list(:ci_build, 10, :artifacts, pipeline: merge_request.head_pipeline)
+
+ expect do
+ get cached_widget_project_json_merge_request_path(project, merge_request, format: :json)
+ end.not_to exceed_query_limit(control)
+ end
+ end
+
+ describe 'GET cached_widget' do
+ it_behaves_like 'cached widget request'
+
+ context 'with non_public_artifacts disabled' do
+ before do
+ stub_feature_flags(non_public_artifacts: false)
+ end
+
+ it_behaves_like 'cached widget request'
+ end
+ end
+end
diff --git a/spec/requests/projects/noteable_notes_spec.rb b/spec/requests/projects/noteable_notes_spec.rb
index 5ae2aadaa84..2bf1ffb2edc 100644
--- a/spec/requests/projects/noteable_notes_spec.rb
+++ b/spec/requests/projects/noteable_notes_spec.rb
@@ -18,7 +18,9 @@ RSpec.describe 'Project noteable notes' do
login_as(user)
end
- it 'does not set a Gitlab::EtagCaching ETag' do
+ it 'does not set a Gitlab::EtagCaching ETag if there is a note' do
+ create(:note_on_merge_request, noteable: merge_request, project: merge_request.project)
+
get notes_path
expect(response).to have_gitlab_http_status(:ok)
@@ -27,5 +29,12 @@ RSpec.describe 'Project noteable notes' do
# interfere with notes pagination
expect(response_etag).not_to eq(stored_etag)
end
+
+ it 'sets a Gitlab::EtagCaching ETag if there is no note' do
+ get notes_path
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response_etag).to eq(stored_etag)
+ end
end
end
diff --git a/spec/rubocop/code_reuse_helpers_spec.rb b/spec/rubocop/code_reuse_helpers_spec.rb
index 4c3dd8f8167..9337df368e3 100644
--- a/spec/rubocop/code_reuse_helpers_spec.rb
+++ b/spec/rubocop/code_reuse_helpers_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require 'parser/current'
require_relative '../../rubocop/code_reuse_helpers'
diff --git a/spec/rubocop/cop/active_record_association_reload_spec.rb b/spec/rubocop/cop/active_record_association_reload_spec.rb
index f28c4e60f3c..1c0518815ee 100644
--- a/spec/rubocop/cop/active_record_association_reload_spec.rb
+++ b/spec/rubocop/cop/active_record_association_reload_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../rubocop/cop/active_record_association_reload'
RSpec.describe RuboCop::Cop::ActiveRecordAssociationReload do
diff --git a/spec/rubocop/cop/api/base_spec.rb b/spec/rubocop/cop/api/base_spec.rb
index ec646b9991b..547d3f53a08 100644
--- a/spec/rubocop/cop/api/base_spec.rb
+++ b/spec/rubocop/cop/api/base_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/api/base'
RSpec.describe RuboCop::Cop::API::Base do
diff --git a/spec/rubocop/cop/api/grape_array_missing_coerce_spec.rb b/spec/rubocop/cop/api/grape_array_missing_coerce_spec.rb
index b50866b54b3..01f1fc71f9a 100644
--- a/spec/rubocop/cop/api/grape_array_missing_coerce_spec.rb
+++ b/spec/rubocop/cop/api/grape_array_missing_coerce_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/api/grape_array_missing_coerce'
RSpec.describe RuboCop::Cop::API::GrapeArrayMissingCoerce do
diff --git a/spec/rubocop/cop/avoid_becomes_spec.rb b/spec/rubocop/cop/avoid_becomes_spec.rb
index 401c694f373..3ab1544b00d 100644
--- a/spec/rubocop/cop/avoid_becomes_spec.rb
+++ b/spec/rubocop/cop/avoid_becomes_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../rubocop/cop/avoid_becomes'
RSpec.describe RuboCop::Cop::AvoidBecomes do
diff --git a/spec/rubocop/cop/avoid_break_from_strong_memoize_spec.rb b/spec/rubocop/cop/avoid_break_from_strong_memoize_spec.rb
index ac59d36db3f..cc851045c3c 100644
--- a/spec/rubocop/cop/avoid_break_from_strong_memoize_spec.rb
+++ b/spec/rubocop/cop/avoid_break_from_strong_memoize_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../rubocop/cop/avoid_break_from_strong_memoize'
RSpec.describe RuboCop::Cop::AvoidBreakFromStrongMemoize do
diff --git a/spec/rubocop/cop/avoid_keyword_arguments_in_sidekiq_workers_spec.rb b/spec/rubocop/cop/avoid_keyword_arguments_in_sidekiq_workers_spec.rb
index 460a0b13458..90ee5772b66 100644
--- a/spec/rubocop/cop/avoid_keyword_arguments_in_sidekiq_workers_spec.rb
+++ b/spec/rubocop/cop/avoid_keyword_arguments_in_sidekiq_workers_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../rubocop/cop/avoid_keyword_arguments_in_sidekiq_workers'
RSpec.describe RuboCop::Cop::AvoidKeywordArgumentsInSidekiqWorkers do
diff --git a/spec/rubocop/cop/avoid_return_from_blocks_spec.rb b/spec/rubocop/cop/avoid_return_from_blocks_spec.rb
index 71311b9df7f..86098f1afcc 100644
--- a/spec/rubocop/cop/avoid_return_from_blocks_spec.rb
+++ b/spec/rubocop/cop/avoid_return_from_blocks_spec.rb
@@ -1,12 +1,9 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../rubocop/cop/avoid_return_from_blocks'
RSpec.describe RuboCop::Cop::AvoidReturnFromBlocks do
- include CopHelper
-
subject(:cop) { described_class.new }
it 'flags violation for return inside a block' do
@@ -19,20 +16,16 @@ RSpec.describe RuboCop::Cop::AvoidReturnFromBlocks do
RUBY
end
- it "doesn't call add_offense twice for nested blocks" do
- source = <<~RUBY
+ it "doesn't create more than one offense for nested blocks" do
+ expect_offense(<<~RUBY)
call do
call do
something
return if something_else
+ ^^^^^^ Do not return from a block, use next or break instead.
end
end
RUBY
- expect_any_instance_of(described_class) do |instance|
- expect(instance).to receive(:add_offense).once
- end
-
- inspect_source(source)
end
it 'flags violation for return inside included > def > block' do
diff --git a/spec/rubocop/cop/avoid_route_redirect_leading_slash_spec.rb b/spec/rubocop/cop/avoid_route_redirect_leading_slash_spec.rb
index 9e13a5278e3..61d6f45b5ba 100644
--- a/spec/rubocop/cop/avoid_route_redirect_leading_slash_spec.rb
+++ b/spec/rubocop/cop/avoid_route_redirect_leading_slash_spec.rb
@@ -1,23 +1,24 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../rubocop/cop/avoid_route_redirect_leading_slash'
RSpec.describe RuboCop::Cop::AvoidRouteRedirectLeadingSlash do
- include CopHelper
-
subject(:cop) { described_class.new }
before do
allow(cop).to receive(:in_routes?).and_return(true)
end
- it 'registers an offense when redirect has a leading slash' do
+ it 'registers an offense when redirect has a leading slash and corrects', :aggregate_failures do
expect_offense(<<~PATTERN)
root to: redirect("/-/route")
^^^^^^^^^^^^^^^^^^^^ Do not use a leading "/" in route redirects
PATTERN
+
+ expect_correction(<<~PATTERN)
+ root to: redirect("-/route")
+ PATTERN
end
it 'does not register an offense when redirect does not have a leading slash' do
@@ -25,8 +26,4 @@ RSpec.describe RuboCop::Cop::AvoidRouteRedirectLeadingSlash do
root to: redirect("-/route")
PATTERN
end
-
- it 'autocorrect `/-/route` to `-/route`' do
- expect(autocorrect_source('redirect("/-/route")')).to eq('redirect("-/route")')
- end
end
diff --git a/spec/rubocop/cop/ban_catch_throw_spec.rb b/spec/rubocop/cop/ban_catch_throw_spec.rb
index b3c4ad8688c..f255d27e7c7 100644
--- a/spec/rubocop/cop/ban_catch_throw_spec.rb
+++ b/spec/rubocop/cop/ban_catch_throw_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../rubocop/cop/ban_catch_throw'
diff --git a/spec/rubocop/cop/code_reuse/finder_spec.rb b/spec/rubocop/cop/code_reuse/finder_spec.rb
index 484a1549a89..36f44ca79da 100644
--- a/spec/rubocop/cop/code_reuse/finder_spec.rb
+++ b/spec/rubocop/cop/code_reuse/finder_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/code_reuse/finder'
RSpec.describe RuboCop::Cop::CodeReuse::Finder do
diff --git a/spec/rubocop/cop/code_reuse/presenter_spec.rb b/spec/rubocop/cop/code_reuse/presenter_spec.rb
index 4639854588e..070a7ed760c 100644
--- a/spec/rubocop/cop/code_reuse/presenter_spec.rb
+++ b/spec/rubocop/cop/code_reuse/presenter_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/code_reuse/presenter'
RSpec.describe RuboCop::Cop::CodeReuse::Presenter do
diff --git a/spec/rubocop/cop/code_reuse/serializer_spec.rb b/spec/rubocop/cop/code_reuse/serializer_spec.rb
index 84db2e62b41..d5577caa2b4 100644
--- a/spec/rubocop/cop/code_reuse/serializer_spec.rb
+++ b/spec/rubocop/cop/code_reuse/serializer_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/code_reuse/serializer'
RSpec.describe RuboCop::Cop::CodeReuse::Serializer do
diff --git a/spec/rubocop/cop/code_reuse/service_class_spec.rb b/spec/rubocop/cop/code_reuse/service_class_spec.rb
index b6d94dd749f..353225b2c42 100644
--- a/spec/rubocop/cop/code_reuse/service_class_spec.rb
+++ b/spec/rubocop/cop/code_reuse/service_class_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/code_reuse/service_class'
RSpec.describe RuboCop::Cop::CodeReuse::ServiceClass do
diff --git a/spec/rubocop/cop/code_reuse/worker_spec.rb b/spec/rubocop/cop/code_reuse/worker_spec.rb
index 42c9303a93b..8155791a3e3 100644
--- a/spec/rubocop/cop/code_reuse/worker_spec.rb
+++ b/spec/rubocop/cop/code_reuse/worker_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/code_reuse/worker'
RSpec.describe RuboCop::Cop::CodeReuse::Worker do
diff --git a/spec/rubocop/cop/default_scope_spec.rb b/spec/rubocop/cop/default_scope_spec.rb
index 506843e030e..4fac0d465e0 100644
--- a/spec/rubocop/cop/default_scope_spec.rb
+++ b/spec/rubocop/cop/default_scope_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../rubocop/cop/default_scope'
RSpec.describe RuboCop::Cop::DefaultScope do
diff --git a/spec/rubocop/cop/destroy_all_spec.rb b/spec/rubocop/cop/destroy_all_spec.rb
index f6850a00238..468b10c3816 100644
--- a/spec/rubocop/cop/destroy_all_spec.rb
+++ b/spec/rubocop/cop/destroy_all_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../rubocop/cop/destroy_all'
RSpec.describe RuboCop::Cop::DestroyAll do
diff --git a/spec/rubocop/cop/filename_length_spec.rb b/spec/rubocop/cop/filename_length_spec.rb
index 2411c8dbc7b..ee128cb2781 100644
--- a/spec/rubocop/cop/filename_length_spec.rb
+++ b/spec/rubocop/cop/filename_length_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require 'rubocop/rspec/support'
require_relative '../../../rubocop/cop/filename_length'
diff --git a/spec/rubocop/cop/gitlab/avoid_uploaded_file_from_params_spec.rb b/spec/rubocop/cop/gitlab/avoid_uploaded_file_from_params_spec.rb
index f96e25c59e7..6d69eb5456f 100644
--- a/spec/rubocop/cop/gitlab/avoid_uploaded_file_from_params_spec.rb
+++ b/spec/rubocop/cop/gitlab/avoid_uploaded_file_from_params_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/gitlab/avoid_uploaded_file_from_params'
RSpec.describe RuboCop::Cop::Gitlab::AvoidUploadedFileFromParams do
diff --git a/spec/rubocop/cop/gitlab/bulk_insert_spec.rb b/spec/rubocop/cop/gitlab/bulk_insert_spec.rb
index c280ab8fa8b..7c60518f890 100644
--- a/spec/rubocop/cop/gitlab/bulk_insert_spec.rb
+++ b/spec/rubocop/cop/gitlab/bulk_insert_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/gitlab/bulk_insert'
RSpec.describe RuboCop::Cop::Gitlab::BulkInsert do
diff --git a/spec/rubocop/cop/gitlab/change_timezone_spec.rb b/spec/rubocop/cop/gitlab/change_timezone_spec.rb
index 9cb822ec4f2..f3c07e44cc7 100644
--- a/spec/rubocop/cop/gitlab/change_timezone_spec.rb
+++ b/spec/rubocop/cop/gitlab/change_timezone_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/gitlab/change_timzone'
RSpec.describe RuboCop::Cop::Gitlab::ChangeTimezone do
diff --git a/spec/rubocop/cop/gitlab/const_get_inherit_false_spec.rb b/spec/rubocop/cop/gitlab/const_get_inherit_false_spec.rb
index 19e5fe946be..1d99ec93e25 100644
--- a/spec/rubocop/cop/gitlab/const_get_inherit_false_spec.rb
+++ b/spec/rubocop/cop/gitlab/const_get_inherit_false_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/gitlab/const_get_inherit_false'
RSpec.describe RuboCop::Cop::Gitlab::ConstGetInheritFalse do
diff --git a/spec/rubocop/cop/gitlab/duplicate_spec_location_spec.rb b/spec/rubocop/cop/gitlab/duplicate_spec_location_spec.rb
index a207155f432..3b3d5b01a30 100644
--- a/spec/rubocop/cop/gitlab/duplicate_spec_location_spec.rb
+++ b/spec/rubocop/cop/gitlab/duplicate_spec_location_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/gitlab/duplicate_spec_location'
diff --git a/spec/rubocop/cop/gitlab/except_spec.rb b/spec/rubocop/cop/gitlab/except_spec.rb
index 7a122e3cf53..04cfe261cf2 100644
--- a/spec/rubocop/cop/gitlab/except_spec.rb
+++ b/spec/rubocop/cop/gitlab/except_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/gitlab/except'
RSpec.describe RuboCop::Cop::Gitlab::Except do
diff --git a/spec/rubocop/cop/gitlab/finder_with_find_by_spec.rb b/spec/rubocop/cop/gitlab/finder_with_find_by_spec.rb
index 03d7fc5e8b1..d2cd06d77c5 100644
--- a/spec/rubocop/cop/gitlab/finder_with_find_by_spec.rb
+++ b/spec/rubocop/cop/gitlab/finder_with_find_by_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/gitlab/finder_with_find_by'
diff --git a/spec/rubocop/cop/gitlab/httparty_spec.rb b/spec/rubocop/cop/gitlab/httparty_spec.rb
index fcd18b0eb9b..98b1aa36586 100644
--- a/spec/rubocop/cop/gitlab/httparty_spec.rb
+++ b/spec/rubocop/cop/gitlab/httparty_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/gitlab/httparty'
RSpec.describe RuboCop::Cop::Gitlab::HTTParty do # rubocop:disable RSpec/FilePath
diff --git a/spec/rubocop/cop/gitlab/intersect_spec.rb b/spec/rubocop/cop/gitlab/intersect_spec.rb
index 6f0367591cd..f3cb1412f35 100644
--- a/spec/rubocop/cop/gitlab/intersect_spec.rb
+++ b/spec/rubocop/cop/gitlab/intersect_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/gitlab/intersect'
RSpec.describe RuboCop::Cop::Gitlab::Intersect do
diff --git a/spec/rubocop/cop/gitlab/json_spec.rb b/spec/rubocop/cop/gitlab/json_spec.rb
index 29c3b96cc1a..66b2c675e80 100644
--- a/spec/rubocop/cop/gitlab/json_spec.rb
+++ b/spec/rubocop/cop/gitlab/json_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/gitlab/json'
RSpec.describe RuboCop::Cop::Gitlab::Json do
diff --git a/spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb b/spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb
index 08634d5753a..d46dec3b2e3 100644
--- a/spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb
+++ b/spec/rubocop/cop/gitlab/module_with_instance_variables_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/gitlab/module_with_instance_variables'
RSpec.describe RuboCop::Cop::Gitlab::ModuleWithInstanceVariables do
diff --git a/spec/rubocop/cop/gitlab/namespaced_class_spec.rb b/spec/rubocop/cop/gitlab/namespaced_class_spec.rb
index d1f61aa5afb..824a1b8cef5 100644
--- a/spec/rubocop/cop/gitlab/namespaced_class_spec.rb
+++ b/spec/rubocop/cop/gitlab/namespaced_class_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require 'rubocop/rspec/support'
require_relative '../../../../rubocop/cop/gitlab/namespaced_class'
diff --git a/spec/rubocop/cop/gitlab/policy_rule_boolean_spec.rb b/spec/rubocop/cop/gitlab/policy_rule_boolean_spec.rb
index 6dbbcdd8324..f73fc71b601 100644
--- a/spec/rubocop/cop/gitlab/policy_rule_boolean_spec.rb
+++ b/spec/rubocop/cop/gitlab/policy_rule_boolean_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/gitlab/policy_rule_boolean'
RSpec.describe RuboCop::Cop::Gitlab::PolicyRuleBoolean do
diff --git a/spec/rubocop/cop/gitlab/predicate_memoization_spec.rb b/spec/rubocop/cop/gitlab/predicate_memoization_spec.rb
index 071ddcf8b7d..903c02ba194 100644
--- a/spec/rubocop/cop/gitlab/predicate_memoization_spec.rb
+++ b/spec/rubocop/cop/gitlab/predicate_memoization_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/gitlab/predicate_memoization'
RSpec.describe RuboCop::Cop::Gitlab::PredicateMemoization do
diff --git a/spec/rubocop/cop/gitlab/rails_logger_spec.rb b/spec/rubocop/cop/gitlab/rails_logger_spec.rb
index 7258b047191..24f49bf3044 100644
--- a/spec/rubocop/cop/gitlab/rails_logger_spec.rb
+++ b/spec/rubocop/cop/gitlab/rails_logger_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/gitlab/rails_logger'
RSpec.describe RuboCop::Cop::Gitlab::RailsLogger do
diff --git a/spec/rubocop/cop/gitlab/union_spec.rb b/spec/rubocop/cop/gitlab/union_spec.rb
index 04a3db8e7dd..ce84c75338d 100644
--- a/spec/rubocop/cop/gitlab/union_spec.rb
+++ b/spec/rubocop/cop/gitlab/union_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/gitlab/union'
RSpec.describe RuboCop::Cop::Gitlab::Union do
diff --git a/spec/rubocop/cop/graphql/authorize_types_spec.rb b/spec/rubocop/cop/graphql/authorize_types_spec.rb
index 9242b865b20..6c521789e34 100644
--- a/spec/rubocop/cop/graphql/authorize_types_spec.rb
+++ b/spec/rubocop/cop/graphql/authorize_types_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/graphql/authorize_types'
@@ -63,4 +62,34 @@ RSpec.describe RuboCop::Cop::Graphql::AuthorizeTypes do
end
TYPE
end
+
+ it 'does not add an offense for subtypes of BaseUnion' do
+ expect_no_offenses(<<~TYPE)
+ module Types
+ class AType < BaseUnion
+ possible_types Types::Foo, Types::Bar
+ end
+ end
+ TYPE
+ end
+
+ it 'does not add an offense for subtypes of BaseInputObject' do
+ expect_no_offenses(<<~TYPE)
+ module Types
+ class AType < BaseInputObject
+ argument :a_thing
+ end
+ end
+ TYPE
+ end
+
+ it 'does not add an offense for InputTypes' do
+ expect_no_offenses(<<~TYPE)
+ module Types
+ class AInputType < SomeObjectType
+ argument :a_thing
+ end
+ end
+ TYPE
+ end
end
diff --git a/spec/rubocop/cop/graphql/descriptions_spec.rb b/spec/rubocop/cop/graphql/descriptions_spec.rb
index 9ad40fad83d..af660aee165 100644
--- a/spec/rubocop/cop/graphql/descriptions_spec.rb
+++ b/spec/rubocop/cop/graphql/descriptions_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/graphql/descriptions'
RSpec.describe RuboCop::Cop::Graphql::Descriptions do
@@ -91,6 +90,50 @@ RSpec.describe RuboCop::Cop::Graphql::Descriptions do
end
end
+ context 'enum values' do
+ it 'adds an offense when there is no description' do
+ expect_offense(<<~TYPE)
+ module Types
+ class FakeEnum < BaseEnum
+ value 'FOO', value: 'foo'
+ ^^^^^^^^^^^^^^^^^^^^^^^^^ Please add a `description` property.
+ end
+ end
+ TYPE
+ end
+
+ it 'adds an offense when description does not end in a period' do
+ expect_offense(<<~TYPE)
+ module Types
+ class FakeEnum < BaseEnum
+ value 'FOO', value: 'foo', description: 'bar'
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `description` strings must end with a `.`.
+ end
+ end
+ TYPE
+ end
+
+ it 'does not add an offense when description is correct (defined using `description:`)' do
+ expect_no_offenses(<<~TYPE.strip)
+ module Types
+ class FakeEnum < BaseEnum
+ value 'FOO', value: 'foo', description: 'bar.'
+ end
+ end
+ TYPE
+ end
+
+ it 'does not add an offense when description is correct (defined as a second argument)' do
+ expect_no_offenses(<<~TYPE.strip)
+ module Types
+ class FakeEnum < BaseEnum
+ value 'FOO', 'bar.', value: 'foo'
+ end
+ end
+ TYPE
+ end
+ end
+
describe 'autocorrecting descriptions without periods' do
it 'can autocorrect' do
expect_offense(<<~TYPE)
diff --git a/spec/rubocop/cop/graphql/gid_expected_type_spec.rb b/spec/rubocop/cop/graphql/gid_expected_type_spec.rb
index d9a129244d6..47a6ce24d53 100644
--- a/spec/rubocop/cop/graphql/gid_expected_type_spec.rb
+++ b/spec/rubocop/cop/graphql/gid_expected_type_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/graphql/gid_expected_type'
diff --git a/spec/rubocop/cop/graphql/id_type_spec.rb b/spec/rubocop/cop/graphql/id_type_spec.rb
index 93c01cd7f06..a566488b118 100644
--- a/spec/rubocop/cop/graphql/id_type_spec.rb
+++ b/spec/rubocop/cop/graphql/id_type_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/graphql/id_type'
diff --git a/spec/rubocop/cop/graphql/json_type_spec.rb b/spec/rubocop/cop/graphql/json_type_spec.rb
index 91838c1708e..50437953c1d 100644
--- a/spec/rubocop/cop/graphql/json_type_spec.rb
+++ b/spec/rubocop/cop/graphql/json_type_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/graphql/json_type'
RSpec.describe RuboCop::Cop::Graphql::JSONType do
diff --git a/spec/rubocop/cop/graphql/resolver_type_spec.rb b/spec/rubocop/cop/graphql/resolver_type_spec.rb
index 11c0ad284a9..06bf90a8a07 100644
--- a/spec/rubocop/cop/graphql/resolver_type_spec.rb
+++ b/spec/rubocop/cop/graphql/resolver_type_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/graphql/resolver_type'
diff --git a/spec/rubocop/cop/group_public_or_visible_to_user_spec.rb b/spec/rubocop/cop/group_public_or_visible_to_user_spec.rb
index b3ec426dc07..2348552f9e4 100644
--- a/spec/rubocop/cop/group_public_or_visible_to_user_spec.rb
+++ b/spec/rubocop/cop/group_public_or_visible_to_user_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../rubocop/cop/group_public_or_visible_to_user'
RSpec.describe RuboCop::Cop::GroupPublicOrVisibleToUser do
diff --git a/spec/rubocop/cop/ignored_columns_spec.rb b/spec/rubocop/cop/ignored_columns_spec.rb
index 38b4ac0bc1a..1c72fedbf31 100644
--- a/spec/rubocop/cop/ignored_columns_spec.rb
+++ b/spec/rubocop/cop/ignored_columns_spec.rb
@@ -1,22 +1,17 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
-require 'rubocop/rspec/support'
require_relative '../../../rubocop/cop/ignored_columns'
RSpec.describe RuboCop::Cop::IgnoredColumns do
- include CopHelper
-
subject(:cop) { described_class.new }
- it 'flags the use of destroy_all with a local variable receiver' do
- inspect_source(<<~RUBY)
+ it 'flags direct use of ignored_columns instead of the IgnoredColumns concern' do
+ expect_offense(<<~RUBY)
class Foo < ApplicationRecord
self.ignored_columns += %i[id]
+ ^^^^^^^^^^^^^^^^^^^^ Use `IgnoredColumns` concern instead of adding to `self.ignored_columns`.
end
RUBY
-
- expect(cop.offenses.size).to eq(1)
end
end
diff --git a/spec/rubocop/cop/include_sidekiq_worker_spec.rb b/spec/rubocop/cop/include_sidekiq_worker_spec.rb
index bdd622d4894..8c706925ab9 100644
--- a/spec/rubocop/cop/include_sidekiq_worker_spec.rb
+++ b/spec/rubocop/cop/include_sidekiq_worker_spec.rb
@@ -2,7 +2,6 @@
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../rubocop/cop/include_sidekiq_worker'
RSpec.describe RuboCop::Cop::IncludeSidekiqWorker do
diff --git a/spec/rubocop/cop/inject_enterprise_edition_module_spec.rb b/spec/rubocop/cop/inject_enterprise_edition_module_spec.rb
index 2d293fd0a05..8bfa57031d7 100644
--- a/spec/rubocop/cop/inject_enterprise_edition_module_spec.rb
+++ b/spec/rubocop/cop/inject_enterprise_edition_module_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../rubocop/cop/inject_enterprise_edition_module'
RSpec.describe RuboCop::Cop::InjectEnterpriseEditionModule do
diff --git a/spec/rubocop/cop/lint/last_keyword_argument_spec.rb b/spec/rubocop/cop/lint/last_keyword_argument_spec.rb
index aac59f0db4c..b1b4c88e0f6 100644
--- a/spec/rubocop/cop/lint/last_keyword_argument_spec.rb
+++ b/spec/rubocop/cop/lint/last_keyword_argument_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/lint/last_keyword_argument'
RSpec.describe RuboCop::Cop::Lint::LastKeywordArgument do
diff --git a/spec/rubocop/cop/migration/add_column_with_default_spec.rb b/spec/rubocop/cop/migration/add_column_with_default_spec.rb
index cf476ae55d6..3f47613280f 100644
--- a/spec/rubocop/cop/migration/add_column_with_default_spec.rb
+++ b/spec/rubocop/cop/migration/add_column_with_default_spec.rb
@@ -1,15 +1,12 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/add_column_with_default'
RSpec.describe RuboCop::Cop::Migration::AddColumnWithDefault do
- include CopHelper
-
let(:cop) { described_class.new }
- context 'outside of a migration' do
+ context 'when outside of a migration' do
it 'does not register any offenses' do
expect_no_offenses(<<~RUBY)
def up
@@ -19,18 +16,16 @@ RSpec.describe RuboCop::Cop::Migration::AddColumnWithDefault do
end
end
- context 'in a migration' do
+ context 'when in a migration' do
before do
allow(cop).to receive(:in_migration?).and_return(true)
end
- let(:offense) { '`add_column_with_default` is deprecated, use `add_column` instead' }
-
it 'registers an offense' do
expect_offense(<<~RUBY)
def up
add_column_with_default(:merge_request_diff_files, :artifacts, :boolean, default: true, allow_null: false)
- ^^^^^^^^^^^^^^^^^^^^^^^ #{offense}
+ ^^^^^^^^^^^^^^^^^^^^^^^ `add_column_with_default` is deprecated, use `add_column` instead
end
RUBY
end
diff --git a/spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb b/spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb
index 92863c45b1a..b78ec971245 100644
--- a/spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb
+++ b/spec/rubocop/cop/migration/add_columns_to_wide_tables_spec.rb
@@ -1,15 +1,12 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/add_columns_to_wide_tables'
RSpec.describe RuboCop::Cop::Migration::AddColumnsToWideTables do
- include CopHelper
-
let(:cop) { described_class.new }
- context 'outside of a migration' do
+ context 'when outside of a migration' do
it 'does not register any offenses' do
expect_no_offenses(<<~RUBY)
def up
@@ -19,14 +16,14 @@ RSpec.describe RuboCop::Cop::Migration::AddColumnsToWideTables do
end
end
- context 'in a migration' do
+ context 'when in a migration' do
before do
allow(cop).to receive(:in_migration?).and_return(true)
end
context 'with wide tables' do
it 'registers an offense when adding a column to a wide table' do
- offense = '`projects` is a wide table with several columns, addig more should be avoided unless absolutely necessary. Consider storing the column in a different table or creating a new one.'
+ offense = '`projects` is a wide table with several columns, [...]'
expect_offense(<<~RUBY)
def up
@@ -37,7 +34,7 @@ RSpec.describe RuboCop::Cop::Migration::AddColumnsToWideTables do
end
it 'registers an offense when adding a column with default to a wide table' do
- offense = '`users` is a wide table with several columns, addig more should be avoided unless absolutely necessary. Consider storing the column in a different table or creating a new one.'
+ offense = '`users` is a wide table with several columns, [...]'
expect_offense(<<~RUBY)
def up
@@ -48,7 +45,7 @@ RSpec.describe RuboCop::Cop::Migration::AddColumnsToWideTables do
end
it 'registers an offense when adding a reference' do
- offense = '`ci_builds` is a wide table with several columns, addig more should be avoided unless absolutely necessary. Consider storing the column in a different table or creating a new one.'
+ offense = '`ci_builds` is a wide table with several columns, [...]'
expect_offense(<<~RUBY)
def up
@@ -59,7 +56,7 @@ RSpec.describe RuboCop::Cop::Migration::AddColumnsToWideTables do
end
it 'registers an offense when adding timestamps' do
- offense = '`projects` is a wide table with several columns, addig more should be avoided unless absolutely necessary. Consider storing the column in a different table or creating a new one.'
+ offense = '`projects` is a wide table with several columns, [...]'
expect_offense(<<~RUBY)
def up
diff --git a/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb b/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb
index 25350ad1ecb..572c0d414b3 100644
--- a/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb
+++ b/spec/rubocop/cop/migration/add_concurrent_foreign_key_spec.rb
@@ -1,50 +1,41 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/add_concurrent_foreign_key'
RSpec.describe RuboCop::Cop::Migration::AddConcurrentForeignKey do
- include CopHelper
-
let(:cop) { described_class.new }
- context 'outside of a migration' do
+ context 'when outside of a migration' do
it 'does not register any offenses' do
- inspect_source('def up; add_foreign_key(:projects, :users, column: :user_id); end')
-
- expect(cop.offenses).to be_empty
+ expect_no_offenses('def up; add_foreign_key(:projects, :users, column: :user_id); end')
end
end
- context 'in a migration' do
+ context 'when in a migration' do
before do
allow(cop).to receive(:in_migration?).and_return(true)
end
it 'registers an offense when using add_foreign_key' do
- inspect_source('def up; add_foreign_key(:projects, :users, column: :user_id); end')
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([1])
- end
+ expect_offense(<<~RUBY)
+ def up
+ add_foreign_key(:projects, :users, column: :user_id)
+ ^^^^^^^^^^^^^^^ `add_foreign_key` requires downtime, use `add_concurrent_foreign_key` instead
+ end
+ RUBY
end
it 'does not register an offense when a `NOT VALID` foreign key is added' do
- inspect_source('def up; add_foreign_key(:projects, :users, column: :user_id, validate: false); end')
-
- expect(cop.offenses).to be_empty
+ expect_no_offenses('def up; add_foreign_key(:projects, :users, column: :user_id, validate: false); end')
end
it 'does not register an offense when `add_foreign_key` is within `with_lock_retries`' do
- inspect_source <<~RUBY
+ expect_no_offenses(<<~RUBY)
with_lock_retries do
add_foreign_key :key, :projects, column: :project_id, on_delete: :cascade
end
RUBY
-
- expect(cop.offenses).to be_empty
end
end
end
diff --git a/spec/rubocop/cop/migration/add_concurrent_index_spec.rb b/spec/rubocop/cop/migration/add_concurrent_index_spec.rb
index 351283a230a..52b3a5769ff 100644
--- a/spec/rubocop/cop/migration/add_concurrent_index_spec.rb
+++ b/spec/rubocop/cop/migration/add_concurrent_index_spec.rb
@@ -1,40 +1,33 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/add_concurrent_index'
RSpec.describe RuboCop::Cop::Migration::AddConcurrentIndex do
- include CopHelper
-
subject(:cop) { described_class.new }
- context 'in migration' do
+ context 'when in migration' do
before do
allow(cop).to receive(:in_migration?).and_return(true)
end
it 'registers an offense when add_concurrent_index is used inside a change method' do
- inspect_source('def change; add_concurrent_index :table, :column; end')
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([1])
- end
+ expect_offense(<<~RUBY)
+ def change
+ ^^^^^^ `add_concurrent_index` is not reversible[...]
+ add_concurrent_index :table, :column
+ end
+ RUBY
end
it 'registers no offense when add_concurrent_index is used inside an up method' do
- inspect_source('def up; add_concurrent_index :table, :column; end')
-
- expect(cop.offenses.size).to eq(0)
+ expect_no_offenses('def up; add_concurrent_index :table, :column; end')
end
end
- context 'outside of migration' do
+ context 'when outside of migration' do
it 'registers no offense' do
- inspect_source('def change; add_concurrent_index :table, :column; end')
-
- expect(cop.offenses.size).to eq(0)
+ expect_no_offenses('def change; add_concurrent_index :table, :column; end')
end
end
end
diff --git a/spec/rubocop/cop/migration/add_index_spec.rb b/spec/rubocop/cop/migration/add_index_spec.rb
index 1d083e9f2d2..088bfe434f4 100644
--- a/spec/rubocop/cop/migration/add_index_spec.rb
+++ b/spec/rubocop/cop/migration/add_index_spec.rb
@@ -1,12 +1,9 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/add_index'
RSpec.describe RuboCop::Cop::Migration::AddIndex do
- include CopHelper
-
subject(:cop) { described_class.new }
context 'in migration' do
diff --git a/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb b/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb
index 149fb0a48eb..f4695ff8d2d 100644
--- a/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb
+++ b/spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb
@@ -1,15 +1,14 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/add_limit_to_text_columns'
RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do
- include CopHelper
-
subject(:cop) { described_class.new }
- context 'in migration' do
+ context 'when in migration' do
+ let(:msg) { 'Text columns should always have a limit set (255 is suggested)[...]' }
+
before do
allow(cop).to receive(:in_migration?).and_return(true)
end
@@ -25,31 +24,29 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do
create_table :test_text_limits, id: false do |t|
t.integer :test_id, null: false
t.text :name
- ^^^^ #{described_class::MSG}
+ ^^^^ #{msg}
end
create_table_with_constraints :test_text_limits_create do |t|
t.integer :test_id, null: false
t.text :title
t.text :description
- ^^^^ #{described_class::MSG}
+ ^^^^ #{msg}
t.text_limit :title, 100
end
add_column :test_text_limits, :email, :text
- ^^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^^ #{msg}
add_column_with_default :test_text_limits, :role, :text, default: 'default'
- ^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^^^^^^^^^^^^^^^ #{msg}
change_column_type_concurrently :test_text_limits, :test_id, :text
- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg}
end
end
RUBY
-
- expect(cop.offenses.map(&:cop_name)).to all(eq('Migration/AddLimitToTextColumns'))
end
end
@@ -111,7 +108,7 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do
end
# Make sure that the cop is properly checking for an `add_text_limit`
- # over the same {table, attribute} as the one that triggered the offence
+ # over the same {table, attribute} as the one that triggered the offense
context 'when the limit is defined for a same name attribute but different table' do
it 'registers an offense' do
expect_offense(<<~RUBY)
@@ -123,17 +120,17 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do
create_table :test_text_limits, id: false do |t|
t.integer :test_id, null: false
t.text :name
- ^^^^ #{described_class::MSG}
+ ^^^^ #{msg}
end
add_column :test_text_limits, :email, :text
- ^^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^^ #{msg}
add_column_with_default :test_text_limits, :role, :text, default: 'default'
- ^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^^^^^^^^^^^^^^^ #{msg}
change_column_type_concurrently :test_text_limits, :test_id, :text
- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{msg}
add_text_limit :wrong_table, :name, 255
add_text_limit :wrong_table, :email, 255
@@ -142,8 +139,6 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do
end
end
RUBY
-
- expect(cop.offenses.map(&:cop_name)).to all(eq('Migration/AddLimitToTextColumns'))
end
end
@@ -176,18 +171,18 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do
DOWNTIME = false
def up
- drop_table :no_offence_on_down
+ drop_table :no_offense_on_down
end
def down
- create_table :no_offence_on_down, id: false do |t|
+ create_table :no_offense_on_down, id: false do |t|
t.integer :test_id, null: false
t.text :name
end
- add_column :no_offence_on_down, :email, :text
+ add_column :no_offense_on_down, :email, :text
- add_column_with_default :no_offence_on_down, :role, :text, default: 'default'
+ add_column_with_default :no_offense_on_down, :role, :text, default: 'default'
end
end
RUBY
@@ -195,7 +190,7 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do
end
end
- context 'outside of migration' do
+ context 'when outside of migration' do
it 'registers no offense' do
expect_no_offenses(<<~RUBY)
class TestTextLimits < ActiveRecord::Migration[6.0]
diff --git a/spec/rubocop/cop/migration/add_reference_spec.rb b/spec/rubocop/cop/migration/add_reference_spec.rb
index 6e229d3eefc..9445780e9ed 100644
--- a/spec/rubocop/cop/migration/add_reference_spec.rb
+++ b/spec/rubocop/cop/migration/add_reference_spec.rb
@@ -1,15 +1,12 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/add_reference'
RSpec.describe RuboCop::Cop::Migration::AddReference do
- include CopHelper
-
let(:cop) { described_class.new }
- context 'outside of a migration' do
+ context 'when outside of a migration' do
it 'does not register any offenses' do
expect_no_offenses(<<~RUBY)
def up
@@ -19,12 +16,12 @@ RSpec.describe RuboCop::Cop::Migration::AddReference do
end
end
- context 'in a migration' do
+ context 'when in a migration' do
before do
allow(cop).to receive(:in_migration?).and_return(true)
end
- let(:offense) { '`add_reference` requires downtime for existing tables, use `add_concurrent_foreign_key` instead. When used for new tables, `index: true` or `index: { options... } is required.`' }
+ let(:offense) { '`add_reference` requires downtime for existing tables, use `add_concurrent_foreign_key`[...]' }
context 'when the table existed before' do
it 'registers an offense when using add_reference' do
diff --git a/spec/rubocop/cop/migration/add_timestamps_spec.rb b/spec/rubocop/cop/migration/add_timestamps_spec.rb
index 83570711ab9..ef5a856722f 100644
--- a/spec/rubocop/cop/migration/add_timestamps_spec.rb
+++ b/spec/rubocop/cop/migration/add_timestamps_spec.rb
@@ -1,12 +1,9 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/add_timestamps'
RSpec.describe RuboCop::Cop::Migration::AddTimestamps do
- include CopHelper
-
subject(:cop) { described_class.new }
let(:migration_with_add_timestamps) do
@@ -47,44 +44,39 @@ RSpec.describe RuboCop::Cop::Migration::AddTimestamps do
)
end
- context 'in migration' do
+ context 'when in migration' do
before do
allow(cop).to receive(:in_migration?).and_return(true)
end
it 'registers an offense when the "add_timestamps" method is used' do
- inspect_source(migration_with_add_timestamps)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([7])
- end
+ expect_offense(<<~RUBY)
+ class Users < ActiveRecord::Migration[4.2]
+ DOWNTIME = false
+
+ def change
+ add_column(:users, :username, :text)
+ add_timestamps(:users)
+ ^^^^^^^^^^^^^^ Do not use `add_timestamps`, use `add_timestamps_with_timezone` instead
+ end
+ end
+ RUBY
end
it 'does not register an offense when the "add_timestamps" method is not used' do
- inspect_source(migration_without_add_timestamps)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(0)
- end
+ expect_no_offenses(migration_without_add_timestamps)
end
it 'does not register an offense when the "add_timestamps_with_timezone" method is used' do
- inspect_source(migration_with_add_timestamps_with_timezone)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(0)
- end
+ expect_no_offenses(migration_with_add_timestamps_with_timezone)
end
end
- context 'outside of migration' do
- it 'registers no offense' do
- inspect_source(migration_with_add_timestamps)
- inspect_source(migration_without_add_timestamps)
- inspect_source(migration_with_add_timestamps_with_timezone)
-
- expect(cop.offenses.size).to eq(0)
+ context 'when outside of migration' do
+ it 'registers no offense', :aggregate_failures do
+ expect_no_offenses(migration_with_add_timestamps)
+ expect_no_offenses(migration_without_add_timestamps)
+ expect_no_offenses(migration_with_add_timestamps_with_timezone)
end
end
end
diff --git a/spec/rubocop/cop/migration/complex_indexes_require_name_spec.rb b/spec/rubocop/cop/migration/complex_indexes_require_name_spec.rb
index 38ccf546b7c..15e947a1e53 100644
--- a/spec/rubocop/cop/migration/complex_indexes_require_name_spec.rb
+++ b/spec/rubocop/cop/migration/complex_indexes_require_name_spec.rb
@@ -1,15 +1,14 @@
# frozen_string_literal: true
#
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/complex_indexes_require_name'
RSpec.describe RuboCop::Cop::Migration::ComplexIndexesRequireName do
- include CopHelper
-
subject(:cop) { described_class.new }
- context 'in migration' do
+ context 'when in migration' do
+ let(:msg) { 'indexes added with custom options must be explicitly named' }
+
before do
allow(cop).to receive(:in_migration?).and_return(true)
end
@@ -29,9 +28,9 @@ RSpec.describe RuboCop::Cop::Migration::ComplexIndexesRequireName do
t.index :column1, unique: true
t.index :column2, where: 'column1 = 0'
- ^^^^^ #{described_class::MSG}
+ ^^^^^ #{msg}
t.index :column3, using: :gin
- ^^^^^ #{described_class::MSG}
+ ^^^^^ #{msg}
end
end
@@ -40,8 +39,6 @@ RSpec.describe RuboCop::Cop::Migration::ComplexIndexesRequireName do
end
end
RUBY
-
- expect(cop.offenses.map(&:cop_name)).to all(eq("Migration/#{described_class.name.demodulize}"))
end
end
@@ -85,20 +82,18 @@ RSpec.describe RuboCop::Cop::Migration::ComplexIndexesRequireName do
add_index :test_indexes, :column1
add_index :test_indexes, :column2, where: "column2 = 'value'", order: { column4: :desc }
- ^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^ #{msg}
end
def down
add_index :test_indexes, :column4, 'unique' => true, where: 'column4 IS NOT NULL'
- ^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^ #{msg}
add_concurrent_index :test_indexes, :column6, using: :gin, opclass: :gin_trgm_ops
- ^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^^^^^^^^^^^^ #{msg}
end
end
RUBY
-
- expect(cop.offenses.map(&:cop_name)).to all(eq("Migration/#{described_class.name.demodulize}"))
end
end
@@ -132,7 +127,7 @@ RSpec.describe RuboCop::Cop::Migration::ComplexIndexesRequireName do
end
end
- context 'outside migration' do
+ context 'when outside migration' do
before do
allow(cop).to receive(:in_migration?).and_return(false)
end
diff --git a/spec/rubocop/cop/migration/create_table_with_foreign_keys_spec.rb b/spec/rubocop/cop/migration/create_table_with_foreign_keys_spec.rb
index 2159bad1490..7bcaf36b014 100644
--- a/spec/rubocop/cop/migration/create_table_with_foreign_keys_spec.rb
+++ b/spec/rubocop/cop/migration/create_table_with_foreign_keys_spec.rb
@@ -1,12 +1,9 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/create_table_with_foreign_keys'
RSpec.describe RuboCop::Cop::Migration::CreateTableWithForeignKeys do
- include CopHelper
-
let(:cop) { described_class.new }
context 'outside of a migration' do
@@ -22,7 +19,7 @@ RSpec.describe RuboCop::Cop::Migration::CreateTableWithForeignKeys do
end
end
- context 'in a migration' do
+ context 'when in a migration' do
before do
allow(cop).to receive(:in_migration?).and_return(true)
end
diff --git a/spec/rubocop/cop/migration/datetime_spec.rb b/spec/rubocop/cop/migration/datetime_spec.rb
index a3cccae21e0..3854ddfe99c 100644
--- a/spec/rubocop/cop/migration/datetime_spec.rb
+++ b/spec/rubocop/cop/migration/datetime_spec.rb
@@ -1,44 +1,11 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/datetime'
RSpec.describe RuboCop::Cop::Migration::Datetime do
- include CopHelper
-
subject(:cop) { described_class.new }
- let(:create_table_migration_with_datetime) do
- %q(
- class Users < ActiveRecord::Migration[6.0]
- DOWNTIME = false
-
- def change
- create_table :users do |t|
- t.string :username, null: false
- t.datetime :last_sign_in
- end
- end
- end
- )
- end
-
- let(:create_table_migration_with_timestamp) do
- %q(
- class Users < ActiveRecord::Migration[6.0]
- DOWNTIME = false
-
- def change
- create_table :users do |t|
- t.string :username, null: false
- t.timestamp :last_sign_in
- end
- end
- end
- )
- end
-
let(:create_table_migration_without_datetime) do
%q(
class Users < ActiveRecord::Migration[6.0]
@@ -120,92 +87,94 @@ RSpec.describe RuboCop::Cop::Migration::Datetime do
)
end
- context 'in migration' do
+ context 'when in migration' do
before do
allow(cop).to receive(:in_migration?).and_return(true)
end
it 'registers an offense when the ":datetime" data type is used on create_table' do
- inspect_source(create_table_migration_with_datetime)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([8])
- expect(cop.offenses.first.message).to include('`datetime`')
- end
+ expect_offense(<<~RUBY)
+ class Users < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def change
+ create_table :users do |t|
+ t.string :username, null: false
+ t.datetime :last_sign_in
+ ^^^^^^^^ Do not use the `datetime` data type[...]
+ end
+ end
+ end
+ RUBY
end
it 'registers an offense when the ":timestamp" data type is used on create_table' do
- inspect_source(create_table_migration_with_timestamp)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([8])
- expect(cop.offenses.first.message).to include('timestamp')
- end
+ expect_offense(<<~RUBY)
+ class Users < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def change
+ create_table :users do |t|
+ t.string :username, null: false
+ t.timestamp :last_sign_in
+ ^^^^^^^^^ Do not use the `timestamp` data type[...]
+ end
+ end
+ end
+ RUBY
end
it 'does not register an offense when the ":datetime" data type is not used on create_table' do
- inspect_source(create_table_migration_without_datetime)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(0)
- end
+ expect_no_offenses(create_table_migration_without_datetime)
end
it 'does not register an offense when the ":datetime_with_timezone" data type is used on create_table' do
- inspect_source(create_table_migration_with_datetime_with_timezone)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(0)
- end
+ expect_no_offenses(create_table_migration_with_datetime_with_timezone)
end
it 'registers an offense when the ":datetime" data type is used on add_column' do
- inspect_source(add_column_migration_with_datetime)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([7])
- expect(cop.offenses.first.message).to include('`datetime`')
- end
+ expect_offense(<<~RUBY)
+ class Users < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def change
+ add_column(:users, :username, :text)
+ add_column(:users, :last_sign_in, :datetime)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Do not use the `datetime` data type[...]
+ end
+ end
+ RUBY
end
it 'registers an offense when the ":timestamp" data type is used on add_column' do
- inspect_source(add_column_migration_with_timestamp)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([7])
- expect(cop.offenses.first.message).to include('timestamp')
- end
+ expect_offense(<<~RUBY)
+ class Users < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def change
+ add_column(:users, :username, :text)
+ add_column(:users, :last_sign_in, :timestamp)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Do not use the `timestamp` data type[...]
+ end
+ end
+ RUBY
end
it 'does not register an offense when the ":datetime" data type is not used on add_column' do
- inspect_source(add_column_migration_without_datetime)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(0)
- end
+ expect_no_offenses(add_column_migration_without_datetime)
end
it 'does not register an offense when the ":datetime_with_timezone" data type is used on add_column' do
- inspect_source(add_column_migration_with_datetime_with_timezone)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(0)
- end
+ expect_no_offenses(add_column_migration_with_datetime_with_timezone)
end
end
- context 'outside of migration' do
- it 'registers no offense' do
- inspect_source(add_column_migration_with_datetime)
- inspect_source(add_column_migration_with_timestamp)
- inspect_source(add_column_migration_without_datetime)
- inspect_source(add_column_migration_with_datetime_with_timezone)
-
- expect(cop.offenses.size).to eq(0)
+ context 'when outside of migration' do
+ it 'registers no offense', :aggregate_failures do
+ expect_no_offenses(add_column_migration_with_datetime)
+ expect_no_offenses(add_column_migration_with_timestamp)
+ expect_no_offenses(add_column_migration_without_datetime)
+ expect_no_offenses(add_column_migration_with_datetime_with_timezone)
end
end
end
diff --git a/spec/rubocop/cop/migration/drop_table_spec.rb b/spec/rubocop/cop/migration/drop_table_spec.rb
index d783cb56203..f1bd710f5e6 100644
--- a/spec/rubocop/cop/migration/drop_table_spec.rb
+++ b/spec/rubocop/cop/migration/drop_table_spec.rb
@@ -1,15 +1,16 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/drop_table'
RSpec.describe RuboCop::Cop::Migration::DropTable do
- include CopHelper
-
subject(:cop) { described_class.new }
context 'when in deployment migration' do
+ let(:msg) do
+ '`drop_table` in deployment migrations requires downtime. Drop tables in post-deployment migrations instead.'
+ end
+
before do
allow(cop).to receive(:in_deployment_migration?).and_return(true)
end
@@ -30,7 +31,7 @@ RSpec.describe RuboCop::Cop::Migration::DropTable do
expect_offense(<<~PATTERN)
def up
drop_table :table
- ^^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^^ #{msg}
end
PATTERN
end
@@ -41,7 +42,7 @@ RSpec.describe RuboCop::Cop::Migration::DropTable do
expect_offense(<<~PATTERN)
def change
drop_table :table
- ^^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^^ #{msg}
end
PATTERN
end
@@ -63,7 +64,7 @@ RSpec.describe RuboCop::Cop::Migration::DropTable do
expect_offense(<<~PATTERN)
def up
execute "DROP TABLE table"
- ^^^^^^^ #{described_class::MSG}
+ ^^^^^^^ #{msg}
end
PATTERN
end
@@ -74,7 +75,7 @@ RSpec.describe RuboCop::Cop::Migration::DropTable do
expect_offense(<<~PATTERN)
def change
execute "DROP TABLE table"
- ^^^^^^^ #{described_class::MSG}
+ ^^^^^^^ #{msg}
end
PATTERN
end
diff --git a/spec/rubocop/cop/migration/hash_index_spec.rb b/spec/rubocop/cop/migration/hash_index_spec.rb
index 15f68eb990f..6da27af39b6 100644
--- a/spec/rubocop/cop/migration/hash_index_spec.rb
+++ b/spec/rubocop/cop/migration/hash_index_spec.rb
@@ -1,52 +1,47 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/hash_index'
RSpec.describe RuboCop::Cop::Migration::HashIndex do
- include CopHelper
-
subject(:cop) { described_class.new }
- context 'in migration' do
+ context 'when in migration' do
before do
allow(cop).to receive(:in_migration?).and_return(true)
end
it 'registers an offense when creating a hash index' do
- inspect_source('def change; add_index :table, :column, using: :hash; end')
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([1])
- end
+ expect_offense(<<~RUBY)
+ def change
+ add_index :table, :column, using: :hash
+ ^^^^^^^^^^^^ hash indexes should be avoided at all costs[...]
+ end
+ RUBY
end
it 'registers an offense when creating a concurrent hash index' do
- inspect_source('def change; add_concurrent_index :table, :column, using: :hash; end')
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([1])
- end
+ expect_offense(<<~RUBY)
+ def change
+ add_concurrent_index :table, :column, using: :hash
+ ^^^^^^^^^^^^ hash indexes should be avoided at all costs[...]
+ end
+ RUBY
end
it 'registers an offense when creating a hash index using t.index' do
- inspect_source('def change; t.index :table, :column, using: :hash; end')
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([1])
- end
+ expect_offense(<<~RUBY)
+ def change
+ t.index :table, :column, using: :hash
+ ^^^^^^^^^^^^ hash indexes should be avoided at all costs[...]
+ end
+ RUBY
end
end
- context 'outside of migration' do
+ context 'when outside of migration' do
it 'registers no offense' do
- inspect_source('def change; index :table, :column, using: :hash; end')
-
- expect(cop.offenses.size).to eq(0)
+ expect_no_offenses('def change; index :table, :column, using: :hash; end')
end
end
end
diff --git a/spec/rubocop/cop/migration/prevent_strings_spec.rb b/spec/rubocop/cop/migration/prevent_strings_spec.rb
index 560a485017a..a9b62f23a77 100644
--- a/spec/rubocop/cop/migration/prevent_strings_spec.rb
+++ b/spec/rubocop/cop/migration/prevent_strings_spec.rb
@@ -1,49 +1,44 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/prevent_strings'
RSpec.describe RuboCop::Cop::Migration::PreventStrings do
- include CopHelper
-
subject(:cop) { described_class.new }
- context 'in migration' do
+ context 'when in migration' do
before do
allow(cop).to receive(:in_migration?).and_return(true)
end
context 'when the string data type is used' do
it 'registers an offense' do
- expect_offense(<<~RUBY)
+ expect_offense(<<~RUBY, msg: "Do not use the `string` data type, use `text` instead.[...]")
class Users < ActiveRecord::Migration[6.0]
DOWNTIME = false
def up
create_table :users do |t|
t.string :username, null: false
- ^^^^^^ #{described_class::MSG}
+ ^^^^^^ %{msg}
t.timestamps_with_timezone null: true
t.string :password
- ^^^^^^ #{described_class::MSG}
+ ^^^^^^ %{msg}
end
add_column(:users, :bio, :string)
- ^^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^^ %{msg}
add_column_with_default(:users, :url, :string, default: '/-/user', allow_null: false, limit: 255)
- ^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^^^^^^^^^^^^^^^ %{msg}
change_column_type_concurrently :users, :commit_id, :string
- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ %{msg}
end
end
RUBY
-
- expect(cop.offenses.map(&:cop_name)).to all(eq('Migration/PreventStrings'))
end
end
@@ -109,7 +104,7 @@ RSpec.describe RuboCop::Cop::Migration::PreventStrings do
end
end
- context 'on down' do
+ context 'when using down method' do
it 'registers no offense' do
expect_no_offenses(<<~RUBY)
class Users < ActiveRecord::Migration[6.0]
@@ -138,7 +133,7 @@ RSpec.describe RuboCop::Cop::Migration::PreventStrings do
end
end
- context 'outside of migration' do
+ context 'when outside of migration' do
it 'registers no offense' do
expect_no_offenses(<<~RUBY)
class Users < ActiveRecord::Migration[6.0]
diff --git a/spec/rubocop/cop/migration/refer_to_index_by_name_spec.rb b/spec/rubocop/cop/migration/refer_to_index_by_name_spec.rb
index a25328a56a8..b3e66492d83 100644
--- a/spec/rubocop/cop/migration/refer_to_index_by_name_spec.rb
+++ b/spec/rubocop/cop/migration/refer_to_index_by_name_spec.rb
@@ -1,22 +1,19 @@
# frozen_string_literal: true
#
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/refer_to_index_by_name'
RSpec.describe RuboCop::Cop::Migration::ReferToIndexByName do
- include CopHelper
-
subject(:cop) { described_class.new }
- context 'in migration' do
+ context 'when in migration' do
before do
allow(cop).to receive(:in_migration?).and_return(true)
end
context 'when existing indexes are referred to without an explicit name' do
it 'registers an offense' do
- expect_offense(<<~RUBY)
+ expect_offense(<<~RUBY, msg: 'migration methods that refer to existing indexes must do so by name')
class TestReferToIndexByName < ActiveRecord::Migration[6.0]
DOWNTIME = false
@@ -30,22 +27,22 @@ RSpec.describe RuboCop::Cop::Migration::ReferToIndexByName do
end
if index_exists? :test_indexes, :column2
- ^^^^^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^^^^^ %{msg}
remove_index :test_indexes, :column2
- ^^^^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^^^^ %{msg}
end
remove_index :test_indexes, column: column3
- ^^^^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^^^^ %{msg}
remove_index :test_indexes, name: 'index_name_4'
end
def down
if index_exists? :test_indexes, :column4, using: :gin, opclass: :gin_trgm_ops
- ^^^^^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^^^^^ %{msg}
remove_concurrent_index :test_indexes, :column4, using: :gin, opclass: :gin_trgm_ops
- ^^^^^^^^^^^^^^^^^^^^^^^ #{described_class::MSG}
+ ^^^^^^^^^^^^^^^^^^^^^^^ %{msg}
end
if index_exists? :test_indexes, :column3, unique: true, name: 'index_name_3', where: 'column3 = 10'
@@ -54,13 +51,11 @@ RSpec.describe RuboCop::Cop::Migration::ReferToIndexByName do
end
end
RUBY
-
- expect(cop.offenses.map(&:cop_name)).to all(eq("Migration/#{described_class.name.demodulize}"))
end
end
end
- context 'outside migration' do
+ context 'when outside migration' do
before do
allow(cop).to receive(:in_migration?).and_return(false)
end
diff --git a/spec/rubocop/cop/migration/remove_column_spec.rb b/spec/rubocop/cop/migration/remove_column_spec.rb
index 4768093b10d..f72a5b048d5 100644
--- a/spec/rubocop/cop/migration/remove_column_spec.rb
+++ b/spec/rubocop/cop/migration/remove_column_spec.rb
@@ -1,67 +1,58 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/remove_column'
RSpec.describe RuboCop::Cop::Migration::RemoveColumn do
- include CopHelper
-
subject(:cop) { described_class.new }
def source(meth = 'change')
"def #{meth}; remove_column :table, :column; end"
end
- context 'in a regular migration' do
+ context 'when in a regular migration' do
before do
allow(cop).to receive(:in_migration?).and_return(true)
allow(cop).to receive(:in_post_deployment_migration?).and_return(false)
end
it 'registers an offense when remove_column is used in the change method' do
- inspect_source(source('change'))
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([1])
- end
+ expect_offense(<<~RUBY)
+ def change
+ remove_column :table, :column
+ ^^^^^^^^^^^^^ `remove_column` must only be used in post-deployment migrations
+ end
+ RUBY
end
it 'registers an offense when remove_column is used in the up method' do
- inspect_source(source('up'))
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([1])
- end
+ expect_offense(<<~RUBY)
+ def up
+ remove_column :table, :column
+ ^^^^^^^^^^^^^ `remove_column` must only be used in post-deployment migrations
+ end
+ RUBY
end
it 'registers no offense when remove_column is used in the down method' do
- inspect_source(source('down'))
-
- expect(cop.offenses.size).to eq(0)
+ expect_no_offenses(source('down'))
end
end
- context 'in a post-deployment migration' do
+ context 'when in a post-deployment migration' do
before do
allow(cop).to receive(:in_migration?).and_return(true)
allow(cop).to receive(:in_post_deployment_migration?).and_return(true)
end
it 'registers no offense' do
- inspect_source(source)
-
- expect(cop.offenses.size).to eq(0)
+ expect_no_offenses(source)
end
end
- context 'outside of a migration' do
+ context 'when outside of a migration' do
it 'registers no offense' do
- inspect_source(source)
-
- expect(cop.offenses.size).to eq(0)
+ expect_no_offenses(source)
end
end
end
diff --git a/spec/rubocop/cop/migration/remove_concurrent_index_spec.rb b/spec/rubocop/cop/migration/remove_concurrent_index_spec.rb
index 8da368d588c..10ca0353b0f 100644
--- a/spec/rubocop/cop/migration/remove_concurrent_index_spec.rb
+++ b/spec/rubocop/cop/migration/remove_concurrent_index_spec.rb
@@ -1,12 +1,9 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/remove_concurrent_index'
RSpec.describe RuboCop::Cop::Migration::RemoveConcurrentIndex do
- include CopHelper
-
subject(:cop) { described_class.new }
context 'in migration' do
@@ -15,26 +12,22 @@ RSpec.describe RuboCop::Cop::Migration::RemoveConcurrentIndex do
end
it 'registers an offense when remove_concurrent_index is used inside a change method' do
- inspect_source('def change; remove_concurrent_index :table, :column; end')
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([1])
- end
+ expect_offense(<<~RUBY)
+ def change
+ ^^^^^^ `remove_concurrent_index` is not reversible [...]
+ remove_concurrent_index :table, :column
+ end
+ RUBY
end
it 'registers no offense when remove_concurrent_index is used inside an up method' do
- inspect_source('def up; remove_concurrent_index :table, :column; end')
-
- expect(cop.offenses.size).to eq(0)
+ expect_no_offenses('def up; remove_concurrent_index :table, :column; end')
end
end
context 'outside of migration' do
it 'registers no offense' do
- inspect_source('def change; remove_concurrent_index :table, :column; end')
-
- expect(cop.offenses.size).to eq(0)
+ expect_no_offenses('def change; remove_concurrent_index :table, :column; end')
end
end
end
diff --git a/spec/rubocop/cop/migration/remove_index_spec.rb b/spec/rubocop/cop/migration/remove_index_spec.rb
index 274c907ac41..5d1ffef2589 100644
--- a/spec/rubocop/cop/migration/remove_index_spec.rb
+++ b/spec/rubocop/cop/migration/remove_index_spec.rb
@@ -1,34 +1,29 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/remove_index'
RSpec.describe RuboCop::Cop::Migration::RemoveIndex do
- include CopHelper
-
subject(:cop) { described_class.new }
- context 'in migration' do
+ context 'when in migration' do
before do
allow(cop).to receive(:in_migration?).and_return(true)
end
it 'registers an offense when remove_index is used' do
- inspect_source('def change; remove_index :table, :column; end')
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([1])
- end
+ expect_offense(<<~RUBY)
+ def change
+ remove_index :table, :column
+ ^^^^^^^^^^^^ `remove_index` requires downtime, use `remove_concurrent_index` instead
+ end
+ RUBY
end
end
- context 'outside of migration' do
+ context 'when outside of migration' do
it 'registers no offense' do
- inspect_source('def change; remove_index :table, :column; end')
-
- expect(cop.offenses.size).to eq(0)
+ expect_no_offenses('def change; remove_index :table, :column; end')
end
end
end
diff --git a/spec/rubocop/cop/migration/safer_boolean_column_spec.rb b/spec/rubocop/cop/migration/safer_boolean_column_spec.rb
index aa7bb58ab45..cf9bdbeef91 100644
--- a/spec/rubocop/cop/migration/safer_boolean_column_spec.rb
+++ b/spec/rubocop/cop/migration/safer_boolean_column_spec.rb
@@ -1,12 +1,9 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/safer_boolean_column'
RSpec.describe RuboCop::Cop::Migration::SaferBooleanColumn do
- include CopHelper
-
subject(:cop) { described_class.new }
context 'in migration' do
@@ -31,11 +28,10 @@ RSpec.describe RuboCop::Cop::Migration::SaferBooleanColumn do
sources_and_offense.each do |source, offense|
context "given the source \"#{source}\"" do
it "registers the offense matching \"#{offense}\"" do
- inspect_source(source)
-
- aggregate_failures do
- expect(cop.offenses.first.message).to match(offense)
- end
+ expect_offense(<<~RUBY, node: source, msg: offense)
+ %{node}
+ ^{node} Boolean columns on the `#{table}` table %{msg}.[...]
+ RUBY
end
end
end
@@ -48,11 +44,7 @@ RSpec.describe RuboCop::Cop::Migration::SaferBooleanColumn do
inoffensive_sources.each do |source|
context "given the source \"#{source}\"" do
it "registers no offense" do
- inspect_source(source)
-
- aggregate_failures do
- expect(cop.offenses).to be_empty
- end
+ expect_no_offenses(source)
end
end
end
@@ -60,25 +52,19 @@ RSpec.describe RuboCop::Cop::Migration::SaferBooleanColumn do
end
it 'registers no offense for tables not listed in SMALL_TABLES' do
- inspect_source("add_column :large_table, :column, :boolean")
-
- expect(cop.offenses).to be_empty
+ expect_no_offenses("add_column :large_table, :column, :boolean")
end
it 'registers no offense for non-boolean columns' do
table = described_class::SMALL_TABLES.sample
- inspect_source("add_column :#{table}, :column, :string")
-
- expect(cop.offenses).to be_empty
+ expect_no_offenses("add_column :#{table}, :column, :string")
end
end
context 'outside of migration' do
it 'registers no offense' do
table = described_class::SMALL_TABLES.sample
- inspect_source("add_column :#{table}, :column, :boolean")
-
- expect(cop.offenses).to be_empty
+ expect_no_offenses("add_column :#{table}, :column, :boolean")
end
end
end
diff --git a/spec/rubocop/cop/migration/schedule_async_spec.rb b/spec/rubocop/cop/migration/schedule_async_spec.rb
index a7246dfa73a..b89acb6db41 100644
--- a/spec/rubocop/cop/migration/schedule_async_spec.rb
+++ b/spec/rubocop/cop/migration/schedule_async_spec.rb
@@ -2,14 +2,9 @@
require 'fast_spec_helper'
-require 'rubocop'
-require 'rubocop/rspec/support'
-
require_relative '../../../../rubocop/cop/migration/schedule_async'
RSpec.describe RuboCop::Cop::Migration::ScheduleAsync do
- include CopHelper
-
let(:cop) { described_class.new }
let(:source) do
<<~SOURCE
@@ -21,9 +16,7 @@ RSpec.describe RuboCop::Cop::Migration::ScheduleAsync do
shared_examples 'a disabled cop' do
it 'does not register any offenses' do
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
+ expect_no_offenses(source)
end
end
@@ -50,101 +43,73 @@ RSpec.describe RuboCop::Cop::Migration::ScheduleAsync do
end
context 'BackgroundMigrationWorker.perform_async' do
- it 'adds an offence when calling `BackgroundMigrationWorker.peform_async`' do
- inspect_source(source)
-
- expect(cop.offenses.size).to eq(1)
- end
-
- it 'autocorrects to the right version' do
- correct_source = <<~CORRECT
- def up
- migrate_async(ClazzName, "Bar", "Baz")
- end
- CORRECT
+ it 'adds an offense when calling `BackgroundMigrationWorker.peform_async` and corrects', :aggregate_failures do
+ expect_offense(<<~RUBY)
+ def up
+ BackgroundMigrationWorker.perform_async(ClazzName, "Bar", "Baz")
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't call [...]
+ end
+ RUBY
- expect(autocorrect_source(source)).to eq(correct_source)
+ expect_correction(<<~RUBY)
+ def up
+ migrate_async(ClazzName, "Bar", "Baz")
+ end
+ RUBY
end
end
context 'BackgroundMigrationWorker.perform_in' do
- let(:source) do
- <<~SOURCE
+ it 'adds an offense and corrects', :aggregate_failures do
+ expect_offense(<<~RUBY)
def up
BackgroundMigrationWorker
+ ^^^^^^^^^^^^^^^^^^^^^^^^^ Don't call [...]
.perform_in(delay, ClazzName, "Bar", "Baz")
end
- SOURCE
- end
-
- it 'adds an offence' do
- inspect_source(source)
+ RUBY
- expect(cop.offenses.size).to eq(1)
- end
-
- it 'autocorrects to the right version' do
- correct_source = <<~CORRECT
+ expect_correction(<<~RUBY)
def up
migrate_in(delay, ClazzName, "Bar", "Baz")
end
- CORRECT
-
- expect(autocorrect_source(source)).to eq(correct_source)
+ RUBY
end
end
context 'BackgroundMigrationWorker.bulk_perform_async' do
- let(:source) do
- <<~SOURCE
+ it 'adds an offense and corrects', :aggregate_failures do
+ expect_offense(<<~RUBY)
def up
BackgroundMigrationWorker
+ ^^^^^^^^^^^^^^^^^^^^^^^^^ Don't call [...]
.bulk_perform_async(jobs)
end
- SOURCE
- end
-
- it 'adds an offence' do
- inspect_source(source)
-
- expect(cop.offenses.size).to eq(1)
- end
+ RUBY
- it 'autocorrects to the right version' do
- correct_source = <<~CORRECT
+ expect_correction(<<~RUBY)
def up
bulk_migrate_async(jobs)
end
- CORRECT
-
- expect(autocorrect_source(source)).to eq(correct_source)
+ RUBY
end
end
context 'BackgroundMigrationWorker.bulk_perform_in' do
- let(:source) do
- <<~SOURCE
+ it 'adds an offense and corrects', :aggregate_failures do
+ expect_offense(<<~RUBY)
def up
BackgroundMigrationWorker
+ ^^^^^^^^^^^^^^^^^^^^^^^^^ Don't call [...]
.bulk_perform_in(5.minutes, jobs)
end
- SOURCE
- end
-
- it 'adds an offence' do
- inspect_source(source)
+ RUBY
- expect(cop.offenses.size).to eq(1)
- end
-
- it 'autocorrects to the right version' do
- correct_source = <<~CORRECT
+ expect_correction(<<~RUBY)
def up
bulk_migrate_in(5.minutes, jobs)
end
- CORRECT
-
- expect(autocorrect_source(source)).to eq(correct_source)
+ RUBY
end
end
end
diff --git a/spec/rubocop/cop/migration/timestamps_spec.rb b/spec/rubocop/cop/migration/timestamps_spec.rb
index 2f4154907d2..91bb5c1b05b 100644
--- a/spec/rubocop/cop/migration/timestamps_spec.rb
+++ b/spec/rubocop/cop/migration/timestamps_spec.rb
@@ -1,12 +1,9 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/timestamps'
RSpec.describe RuboCop::Cop::Migration::Timestamps do
- include CopHelper
-
subject(:cop) { described_class.new }
let(:migration_with_timestamps) do
@@ -62,38 +59,36 @@ RSpec.describe RuboCop::Cop::Migration::Timestamps do
end
it 'registers an offense when the "timestamps" method is used' do
- inspect_source(migration_with_timestamps)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([8])
- end
+ expect_offense(<<~RUBY)
+ class Users < ActiveRecord::Migration[4.2]
+ DOWNTIME = false
+
+ def change
+ create_table :users do |t|
+ t.string :username, null: false
+ t.timestamps null: true
+ ^^^^^^^^^^ Do not use `timestamps`, use `timestamps_with_timezone` instead
+ t.string :password
+ end
+ end
+ end
+ RUBY
end
it 'does not register an offense when the "timestamps" method is not used' do
- inspect_source(migration_without_timestamps)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(0)
- end
+ expect_no_offenses(migration_without_timestamps)
end
it 'does not register an offense when the "timestamps_with_timezone" method is used' do
- inspect_source(migration_with_timestamps_with_timezone)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(0)
- end
+ expect_no_offenses(migration_with_timestamps_with_timezone)
end
end
context 'outside of migration' do
- it 'registers no offense' do
- inspect_source(migration_with_timestamps)
- inspect_source(migration_without_timestamps)
- inspect_source(migration_with_timestamps_with_timezone)
-
- expect(cop.offenses.size).to eq(0)
+ it 'registers no offense', :aggregate_failures do
+ expect_no_offenses(migration_with_timestamps)
+ expect_no_offenses(migration_without_timestamps)
+ expect_no_offenses(migration_with_timestamps_with_timezone)
end
end
end
diff --git a/spec/rubocop/cop/migration/update_column_in_batches_spec.rb b/spec/rubocop/cop/migration/update_column_in_batches_spec.rb
index 8049cba12d0..a12ae94c22b 100644
--- a/spec/rubocop/cop/migration/update_column_in_batches_spec.rb
+++ b/spec/rubocop/cop/migration/update_column_in_batches_spec.rb
@@ -2,9 +2,6 @@
require 'fast_spec_helper'
-require 'rubocop'
-require 'rubocop/rspec/support'
-
require_relative '../../../../rubocop/cop/migration/update_column_in_batches'
RSpec.describe RuboCop::Cop::Migration::UpdateColumnInBatches do
@@ -31,9 +28,7 @@ RSpec.describe RuboCop::Cop::Migration::UpdateColumnInBatches do
context 'outside of a migration' do
it 'does not register any offenses' do
- inspect_source(migration_code)
-
- expect(cop.offenses).to be_empty
+ expect_no_offenses(migration_code)
end
end
@@ -53,14 +48,14 @@ RSpec.describe RuboCop::Cop::Migration::UpdateColumnInBatches do
let(:relative_spec_filepath) { Pathname.new(spec_filepath).relative_path_from(tmp_rails_root) }
it 'registers an offense when using update_column_in_batches' do
- inspect_source(migration_code, @migration_file)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([2])
- expect(cop.offenses.first.message)
- .to include("`#{relative_spec_filepath}`")
- end
+ expect_offense(<<~RUBY, @migration_file)
+ def up
+ update_column_in_batches(:projects, :name, "foo") do |table, query|
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Migration running `update_column_in_batches` [...]
+ query.where(table[:name].eq(nil))
+ end
+ end
+ RUBY
end
end
@@ -76,20 +71,18 @@ RSpec.describe RuboCop::Cop::Migration::UpdateColumnInBatches do
end
it 'does not register any offenses' do
- inspect_source(migration_code, @migration_file)
-
- expect(cop.offenses).to be_empty
+ expect_no_offenses(migration_code)
end
end
- context 'in a migration' do
+ context 'when in migration' do
let(:migration_filepath) { File.join(tmp_rails_root, 'db', 'migrate', '20121220064453_my_super_migration.rb') }
it_behaves_like 'a migration file with no spec file'
it_behaves_like 'a migration file with a spec file'
end
- context 'in a post migration' do
+ context 'when in a post migration' do
let(:migration_filepath) { File.join(tmp_rails_root, 'db', 'post_migrate', '20121220064453_my_super_migration.rb') }
it_behaves_like 'a migration file with no spec file'
@@ -99,14 +92,14 @@ RSpec.describe RuboCop::Cop::Migration::UpdateColumnInBatches do
context 'EE migrations' do
let(:spec_filepath) { File.join(tmp_rails_root, 'ee', 'spec', 'migrations', 'my_super_migration_spec.rb') }
- context 'in a migration' do
+ context 'when in a migration' do
let(:migration_filepath) { File.join(tmp_rails_root, 'ee', 'db', 'migrate', '20121220064453_my_super_migration.rb') }
it_behaves_like 'a migration file with no spec file'
it_behaves_like 'a migration file with a spec file'
end
- context 'in a post migration' do
+ context 'when in a post migration' do
let(:migration_filepath) { File.join(tmp_rails_root, 'ee', 'db', 'post_migrate', '20121220064453_my_super_migration.rb') }
it_behaves_like 'a migration file with no spec file'
diff --git a/spec/rubocop/cop/migration/with_lock_retries_disallowed_method_spec.rb b/spec/rubocop/cop/migration/with_lock_retries_disallowed_method_spec.rb
index 814d87ea24b..298ca273256 100644
--- a/spec/rubocop/cop/migration/with_lock_retries_disallowed_method_spec.rb
+++ b/spec/rubocop/cop/migration/with_lock_retries_disallowed_method_spec.rb
@@ -1,64 +1,58 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/with_lock_retries_disallowed_method'
RSpec.describe RuboCop::Cop::Migration::WithLockRetriesDisallowedMethod do
- include CopHelper
-
subject(:cop) { described_class.new }
- context 'in migration' do
+ context 'when in migration' do
before do
allow(cop).to receive(:in_migration?).and_return(true)
end
it 'registers an offense when `with_lock_retries` block has disallowed method' do
- inspect_source('def change; with_lock_retries { disallowed_method }; end')
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([1])
- end
+ expect_offense(<<~RUBY)
+ def change
+ with_lock_retries { disallowed_method }
+ ^^^^^^^^^^^^^^^^^ The method is not allowed [...]
+ end
+ RUBY
end
it 'registers an offense when `with_lock_retries` block has disallowed methods' do
- source = <<~HEREDOC
- def change
- with_lock_retries do
- disallowed_method
+ expect_offense(<<~RUBY)
+ def change
+ with_lock_retries do
+ disallowed_method
+ ^^^^^^^^^^^^^^^^^ The method is not allowed [...]
- create_table do |t|
- t.text :text
- end
+ create_table do |t|
+ t.text :text
+ end
- other_disallowed_method
+ other_disallowed_method
+ ^^^^^^^^^^^^^^^^^^^^^^^ The method is not allowed [...]
- add_column :users, :name
+ add_column :users, :name
+ end
end
- end
- HEREDOC
-
- inspect_source(source)
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(2)
- expect(cop.offenses.map(&:line)).to eq([3, 9])
- end
+ RUBY
end
it 'registers no offense when `with_lock_retries` has only allowed method' do
- inspect_source('def up; with_lock_retries { add_foreign_key :foo, :bar }; end')
-
- expect(cop.offenses.size).to eq(0)
+ expect_no_offenses(<<~RUBY)
+ def up
+ with_lock_retries { add_foreign_key :foo, :bar }
+ end
+ RUBY
end
describe 'for `add_foreign_key`' do
it 'registers an offense when more than two FKs are added' do
message = described_class::MSG_ONLY_ONE_FK_ALLOWED
- expect_offense <<~RUBY
+ expect_offense(<<~RUBY)
with_lock_retries do
add_foreign_key :imports, :projects, column: :project_id, on_delete: :cascade
^^^^^^^^^^^^^^^ #{message}
@@ -71,11 +65,13 @@ RSpec.describe RuboCop::Cop::Migration::WithLockRetriesDisallowedMethod do
end
end
- context 'outside of migration' do
+ context 'when outside of migration' do
it 'registers no offense' do
- inspect_source('def change; with_lock_retries { disallowed_method }; end')
-
- expect(cop.offenses.size).to eq(0)
+ expect_no_offenses(<<~RUBY)
+ def change
+ with_lock_retries { disallowed_method }
+ end
+ RUBY
end
end
end
diff --git a/spec/rubocop/cop/migration/with_lock_retries_with_change_spec.rb b/spec/rubocop/cop/migration/with_lock_retries_with_change_spec.rb
index f0be14c8ee9..f2e84a8697c 100644
--- a/spec/rubocop/cop/migration/with_lock_retries_with_change_spec.rb
+++ b/spec/rubocop/cop/migration/with_lock_retries_with_change_spec.rb
@@ -1,40 +1,41 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/migration/with_lock_retries_with_change'
RSpec.describe RuboCop::Cop::Migration::WithLockRetriesWithChange do
- include CopHelper
-
subject(:cop) { described_class.new }
- context 'in migration' do
+ context 'when in migration' do
before do
allow(cop).to receive(:in_migration?).and_return(true)
end
it 'registers an offense when `with_lock_retries` is used inside a `change` method' do
- inspect_source('def change; with_lock_retries {}; end')
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([1])
- end
+ expect_offense(<<~RUBY)
+ def change
+ ^^^^^^ `with_lock_retries` cannot be used within `change` [...]
+ with_lock_retries {}
+ end
+ RUBY
end
it 'registers no offense when `with_lock_retries` is used inside an `up` method' do
- inspect_source('def up; with_lock_retries {}; end')
-
- expect(cop.offenses.size).to eq(0)
+ expect_no_offenses(<<~RUBY)
+ def up
+ with_lock_retries {}
+ end
+ RUBY
end
end
- context 'outside of migration' do
+ context 'when outside of migration' do
it 'registers no offense' do
- inspect_source('def change; with_lock_retries {}; end')
-
- expect(cop.offenses.size).to eq(0)
+ expect_no_offenses(<<~RUBY)
+ def change
+ with_lock_retries {}
+ end
+ RUBY
end
end
end
diff --git a/spec/rubocop/cop/performance/ar_count_each_spec.rb b/spec/rubocop/cop/performance/ar_count_each_spec.rb
index 402e3e93147..fa7a1aba426 100644
--- a/spec/rubocop/cop/performance/ar_count_each_spec.rb
+++ b/spec/rubocop/cop/performance/ar_count_each_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/performance/ar_count_each.rb'
RSpec.describe RuboCop::Cop::Performance::ARCountEach do
diff --git a/spec/rubocop/cop/performance/ar_exists_and_present_blank_spec.rb b/spec/rubocop/cop/performance/ar_exists_and_present_blank_spec.rb
index 8497ff0e909..127c858a549 100644
--- a/spec/rubocop/cop/performance/ar_exists_and_present_blank_spec.rb
+++ b/spec/rubocop/cop/performance/ar_exists_and_present_blank_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/performance/ar_exists_and_present_blank.rb'
RSpec.describe RuboCop::Cop::Performance::ARExistsAndPresentBlank do
diff --git a/spec/rubocop/cop/performance/readlines_each_spec.rb b/spec/rubocop/cop/performance/readlines_each_spec.rb
index 5a30107722a..0a8b168ce5d 100644
--- a/spec/rubocop/cop/performance/readlines_each_spec.rb
+++ b/spec/rubocop/cop/performance/readlines_each_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/performance/readlines_each'
RSpec.describe RuboCop::Cop::Performance::ReadlinesEach do
diff --git a/spec/rubocop/cop/prefer_class_methods_over_module_spec.rb b/spec/rubocop/cop/prefer_class_methods_over_module_spec.rb
index dc665f9dd25..1261ca7891c 100644
--- a/spec/rubocop/cop/prefer_class_methods_over_module_spec.rb
+++ b/spec/rubocop/cop/prefer_class_methods_over_module_spec.rb
@@ -1,16 +1,12 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
-require 'rubocop/rspec/support'
require_relative '../../../rubocop/cop/prefer_class_methods_over_module'
RSpec.describe RuboCop::Cop::PreferClassMethodsOverModule do
- include CopHelper
-
subject(:cop) { described_class.new }
- it 'flags violation when using module ClassMethods' do
+ it 'flags violation when using module ClassMethods and corrects', :aggregate_failures do
expect_offense(<<~RUBY)
module Foo
extend ActiveSupport::Concern
@@ -22,6 +18,17 @@ RSpec.describe RuboCop::Cop::PreferClassMethodsOverModule do
end
end
RUBY
+
+ expect_correction(<<~RUBY)
+ module Foo
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def a_class_method
+ end
+ end
+ end
+ RUBY
end
it "doesn't flag violation when using class_methods" do
@@ -69,30 +76,4 @@ RSpec.describe RuboCop::Cop::PreferClassMethodsOverModule do
end
RUBY
end
-
- it 'autocorrects ClassMethods into class_methods' do
- source = <<~RUBY
- module Foo
- extend ActiveSupport::Concern
-
- module ClassMethods
- def a_class_method
- end
- end
- end
- RUBY
- autocorrected = autocorrect_source(source)
-
- expected_source = <<~RUBY
- module Foo
- extend ActiveSupport::Concern
-
- class_methods do
- def a_class_method
- end
- end
- end
- RUBY
- expect(autocorrected).to eq(expected_source)
- end
end
diff --git a/spec/rubocop/cop/project_path_helper_spec.rb b/spec/rubocop/cop/project_path_helper_spec.rb
index 16782802a27..b3c920f9d25 100644
--- a/spec/rubocop/cop/project_path_helper_spec.rb
+++ b/spec/rubocop/cop/project_path_helper_spec.rb
@@ -2,7 +2,6 @@
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../rubocop/cop/project_path_helper'
RSpec.describe RuboCop::Cop::ProjectPathHelper do
diff --git a/spec/rubocop/cop/put_group_routes_under_scope_spec.rb b/spec/rubocop/cop/put_group_routes_under_scope_spec.rb
index 46b50d7690b..366fc4b5657 100644
--- a/spec/rubocop/cop/put_group_routes_under_scope_spec.rb
+++ b/spec/rubocop/cop/put_group_routes_under_scope_spec.rb
@@ -1,12 +1,9 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../rubocop/cop/put_group_routes_under_scope'
RSpec.describe RuboCop::Cop::PutGroupRoutesUnderScope do
- include CopHelper
-
subject(:cop) { described_class.new }
%w[resource resources get post put patch delete].each do |route_method|
@@ -15,12 +12,12 @@ RSpec.describe RuboCop::Cop::PutGroupRoutesUnderScope do
marker = '^' * offense.size
expect_offense(<<~PATTERN)
- scope(path: 'groups/*group_id/-', module: :groups) do
- resource :issues
- end
+ scope(path: 'groups/*group_id/-', module: :groups) do
+ resource :issues
+ end
- #{offense}
- #{marker} Put new group routes under /-/ scope
+ #{offense}
+ #{marker} Put new group routes under /-/ scope
PATTERN
end
end
diff --git a/spec/rubocop/cop/put_project_routes_under_scope_spec.rb b/spec/rubocop/cop/put_project_routes_under_scope_spec.rb
index eb783d22129..9d226db09ef 100644
--- a/spec/rubocop/cop/put_project_routes_under_scope_spec.rb
+++ b/spec/rubocop/cop/put_project_routes_under_scope_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../rubocop/cop/put_project_routes_under_scope'
RSpec.describe RuboCop::Cop::PutProjectRoutesUnderScope do
diff --git a/spec/rubocop/cop/qa/ambiguous_page_object_name_spec.rb b/spec/rubocop/cop/qa/ambiguous_page_object_name_spec.rb
index 9332ab4186e..9335b8d01ee 100644
--- a/spec/rubocop/cop/qa/ambiguous_page_object_name_spec.rb
+++ b/spec/rubocop/cop/qa/ambiguous_page_object_name_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/qa/ambiguous_page_object_name'
diff --git a/spec/rubocop/cop/qa/element_with_pattern_spec.rb b/spec/rubocop/cop/qa/element_with_pattern_spec.rb
index 28c351ccf1e..d3e79525c62 100644
--- a/spec/rubocop/cop/qa/element_with_pattern_spec.rb
+++ b/spec/rubocop/cop/qa/element_with_pattern_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/qa/element_with_pattern'
diff --git a/spec/rubocop/cop/rspec/be_success_matcher_spec.rb b/spec/rubocop/cop/rspec/be_success_matcher_spec.rb
index 050f0396fac..678e62048b8 100644
--- a/spec/rubocop/cop/rspec/be_success_matcher_spec.rb
+++ b/spec/rubocop/cop/rspec/be_success_matcher_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/rspec/be_success_matcher'
RSpec.describe RuboCop::Cop::RSpec::BeSuccessMatcher do
diff --git a/spec/rubocop/cop/rspec/env_assignment_spec.rb b/spec/rubocop/cop/rspec/env_assignment_spec.rb
index cc132d1532a..da6bb2fa2fb 100644
--- a/spec/rubocop/cop/rspec/env_assignment_spec.rb
+++ b/spec/rubocop/cop/rspec/env_assignment_spec.rb
@@ -2,7 +2,6 @@
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/rspec/env_assignment'
RSpec.describe RuboCop::Cop::RSpec::EnvAssignment do
diff --git a/spec/rubocop/cop/rspec/expect_gitlab_tracking_spec.rb b/spec/rubocop/cop/rspec/expect_gitlab_tracking_spec.rb
index d1ce8d01e0b..e36feecdd66 100644
--- a/spec/rubocop/cop/rspec/expect_gitlab_tracking_spec.rb
+++ b/spec/rubocop/cop/rspec/expect_gitlab_tracking_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/rspec/expect_gitlab_tracking'
RSpec.describe RuboCop::Cop::RSpec::ExpectGitlabTracking do
diff --git a/spec/rubocop/cop/rspec/factories_in_migration_specs_spec.rb b/spec/rubocop/cop/rspec/factories_in_migration_specs_spec.rb
index 8beec53375e..74c1521fa0e 100644
--- a/spec/rubocop/cop/rspec/factories_in_migration_specs_spec.rb
+++ b/spec/rubocop/cop/rspec/factories_in_migration_specs_spec.rb
@@ -2,7 +2,6 @@
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/rspec/factories_in_migration_specs'
RSpec.describe RuboCop::Cop::RSpec::FactoriesInMigrationSpecs do
diff --git a/spec/rubocop/cop/rspec/factory_bot/inline_association_spec.rb b/spec/rubocop/cop/rspec/factory_bot/inline_association_spec.rb
index 0e6af71ea3e..194e2436ff2 100644
--- a/spec/rubocop/cop/rspec/factory_bot/inline_association_spec.rb
+++ b/spec/rubocop/cop/rspec/factory_bot/inline_association_spec.rb
@@ -2,7 +2,6 @@
require 'fast_spec_helper'
require 'rspec-parameterized'
-require 'rubocop'
require_relative '../../../../../rubocop/cop/rspec/factory_bot/inline_association'
diff --git a/spec/rubocop/cop/rspec/have_gitlab_http_status_spec.rb b/spec/rubocop/cop/rspec/have_gitlab_http_status_spec.rb
index c2d97c8992a..9bdbe145f4c 100644
--- a/spec/rubocop/cop/rspec/have_gitlab_http_status_spec.rb
+++ b/spec/rubocop/cop/rspec/have_gitlab_http_status_spec.rb
@@ -3,7 +3,6 @@
require 'fast_spec_helper'
require 'rspec-parameterized'
-require 'rubocop'
require_relative '../../../../rubocop/cop/rspec/have_gitlab_http_status'
RSpec.describe RuboCop::Cop::RSpec::HaveGitlabHttpStatus do
diff --git a/spec/rubocop/cop/rspec/modify_sidekiq_middleware_spec.rb b/spec/rubocop/cop/rspec/modify_sidekiq_middleware_spec.rb
index ffabbae90dc..7a2b7c92bd1 100644
--- a/spec/rubocop/cop/rspec/modify_sidekiq_middleware_spec.rb
+++ b/spec/rubocop/cop/rspec/modify_sidekiq_middleware_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/rspec/modify_sidekiq_middleware'
RSpec.describe RuboCop::Cop::RSpec::ModifySidekiqMiddleware do
diff --git a/spec/rubocop/cop/rspec/timecop_freeze_spec.rb b/spec/rubocop/cop/rspec/timecop_freeze_spec.rb
index 939623f8299..b8d16d58d9e 100644
--- a/spec/rubocop/cop/rspec/timecop_freeze_spec.rb
+++ b/spec/rubocop/cop/rspec/timecop_freeze_spec.rb
@@ -2,7 +2,6 @@
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/rspec/timecop_freeze'
RSpec.describe RuboCop::Cop::RSpec::TimecopFreeze do
diff --git a/spec/rubocop/cop/rspec/timecop_travel_spec.rb b/spec/rubocop/cop/rspec/timecop_travel_spec.rb
index 476e45e69a6..16e09fb8c45 100644
--- a/spec/rubocop/cop/rspec/timecop_travel_spec.rb
+++ b/spec/rubocop/cop/rspec/timecop_travel_spec.rb
@@ -2,7 +2,6 @@
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/rspec/timecop_travel'
RSpec.describe RuboCop::Cop::RSpec::TimecopTravel do
diff --git a/spec/rubocop/cop/rspec/top_level_describe_path_spec.rb b/spec/rubocop/cop/rspec/top_level_describe_path_spec.rb
index 23531cd0201..78e6bec51d4 100644
--- a/spec/rubocop/cop/rspec/top_level_describe_path_spec.rb
+++ b/spec/rubocop/cop/rspec/top_level_describe_path_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/rspec/top_level_describe_path'
RSpec.describe RuboCop::Cop::RSpec::TopLevelDescribePath do
diff --git a/spec/rubocop/cop/ruby_interpolation_in_translation_spec.rb b/spec/rubocop/cop/ruby_interpolation_in_translation_spec.rb
index cacf0a1b67d..c21999be917 100644
--- a/spec/rubocop/cop/ruby_interpolation_in_translation_spec.rb
+++ b/spec/rubocop/cop/ruby_interpolation_in_translation_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../rubocop/cop/ruby_interpolation_in_translation'
diff --git a/spec/rubocop/cop/safe_params_spec.rb b/spec/rubocop/cop/safe_params_spec.rb
index 62f8e542d86..9a064b93b16 100644
--- a/spec/rubocop/cop/safe_params_spec.rb
+++ b/spec/rubocop/cop/safe_params_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../rubocop/cop/safe_params'
RSpec.describe RuboCop::Cop::SafeParams do
diff --git a/spec/rubocop/cop/scalability/bulk_perform_with_context_spec.rb b/spec/rubocop/cop/scalability/bulk_perform_with_context_spec.rb
index a19ddf9dbe6..01afaf3acb6 100644
--- a/spec/rubocop/cop/scalability/bulk_perform_with_context_spec.rb
+++ b/spec/rubocop/cop/scalability/bulk_perform_with_context_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/scalability/bulk_perform_with_context'
RSpec.describe RuboCop::Cop::Scalability::BulkPerformWithContext do
diff --git a/spec/rubocop/cop/scalability/cron_worker_context_spec.rb b/spec/rubocop/cop/scalability/cron_worker_context_spec.rb
index 11b2b82d2f5..28db12fd075 100644
--- a/spec/rubocop/cop/scalability/cron_worker_context_spec.rb
+++ b/spec/rubocop/cop/scalability/cron_worker_context_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/scalability/cron_worker_context'
RSpec.describe RuboCop::Cop::Scalability::CronWorkerContext do
diff --git a/spec/rubocop/cop/scalability/file_uploads_spec.rb b/spec/rubocop/cop/scalability/file_uploads_spec.rb
index bda5c056b03..ca25b0246f0 100644
--- a/spec/rubocop/cop/scalability/file_uploads_spec.rb
+++ b/spec/rubocop/cop/scalability/file_uploads_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/scalability/file_uploads'
RSpec.describe RuboCop::Cop::Scalability::FileUploads do
diff --git a/spec/rubocop/cop/scalability/idempotent_worker_spec.rb b/spec/rubocop/cop/scalability/idempotent_worker_spec.rb
index 729f2613697..53c0c06f6c9 100644
--- a/spec/rubocop/cop/scalability/idempotent_worker_spec.rb
+++ b/spec/rubocop/cop/scalability/idempotent_worker_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/scalability/idempotent_worker'
RSpec.describe RuboCop::Cop::Scalability::IdempotentWorker do
diff --git a/spec/rubocop/cop/sidekiq_options_queue_spec.rb b/spec/rubocop/cop/sidekiq_options_queue_spec.rb
index 306cbcf62b5..346a8d82475 100644
--- a/spec/rubocop/cop/sidekiq_options_queue_spec.rb
+++ b/spec/rubocop/cop/sidekiq_options_queue_spec.rb
@@ -2,29 +2,19 @@
require 'fast_spec_helper'
-require 'rubocop'
-require 'rubocop/rspec/support'
-
require_relative '../../../rubocop/cop/sidekiq_options_queue'
RSpec.describe RuboCop::Cop::SidekiqOptionsQueue do
- include CopHelper
-
subject(:cop) { described_class.new }
it 'registers an offense when `sidekiq_options` is used with the `queue` option' do
- inspect_source('sidekiq_options queue: "some_queue"')
-
- aggregate_failures do
- expect(cop.offenses.size).to eq(1)
- expect(cop.offenses.map(&:line)).to eq([1])
- expect(cop.highlights).to eq(['queue: "some_queue"'])
- end
+ expect_offense(<<~CODE)
+ sidekiq_options queue: "some_queue"
+ ^^^^^^^^^^^^^^^^^^^ Do not manually set a queue; `ApplicationWorker` sets one automatically.
+ CODE
end
it 'does not register an offense when `sidekiq_options` is used with another option' do
- inspect_source('sidekiq_options retry: false')
-
- expect(cop.offenses).to be_empty
+ expect_no_offenses('sidekiq_options retry: false')
end
end
diff --git a/spec/rubocop/cop/static_translation_definition_spec.rb b/spec/rubocop/cop/static_translation_definition_spec.rb
index 8656b07a6e4..b2b04cbcbde 100644
--- a/spec/rubocop/cop/static_translation_definition_spec.rb
+++ b/spec/rubocop/cop/static_translation_definition_spec.rb
@@ -2,7 +2,6 @@
require 'fast_spec_helper'
-require 'rubocop'
require 'rspec-parameterized'
require_relative '../../../rubocop/cop/static_translation_definition'
diff --git a/spec/rubocop/cop/usage_data/distinct_count_by_large_foreign_key_spec.rb b/spec/rubocop/cop/usage_data/distinct_count_by_large_foreign_key_spec.rb
index b6711effe9e..f377dfe36d8 100644
--- a/spec/rubocop/cop/usage_data/distinct_count_by_large_foreign_key_spec.rb
+++ b/spec/rubocop/cop/usage_data/distinct_count_by_large_foreign_key_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/usage_data/distinct_count_by_large_foreign_key'
diff --git a/spec/rubocop/cop/usage_data/large_table_spec.rb b/spec/rubocop/cop/usage_data/large_table_spec.rb
index 26bd4e61625..a6b22fd7f0d 100644
--- a/spec/rubocop/cop/usage_data/large_table_spec.rb
+++ b/spec/rubocop/cop/usage_data/large_table_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require_relative '../../../../rubocop/cop/usage_data/large_table'
diff --git a/spec/rubocop/migration_helpers_spec.rb b/spec/rubocop/migration_helpers_spec.rb
index f0be21c9d70..997d4071c29 100644
--- a/spec/rubocop/migration_helpers_spec.rb
+++ b/spec/rubocop/migration_helpers_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require 'rspec-parameterized'
require_relative '../../rubocop/migration_helpers'
diff --git a/spec/rubocop/qa_helpers_spec.rb b/spec/rubocop/qa_helpers_spec.rb
index cf6d2f1a845..4b5566609e3 100644
--- a/spec/rubocop/qa_helpers_spec.rb
+++ b/spec/rubocop/qa_helpers_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require 'rubocop'
require 'parser/current'
require_relative '../../rubocop/qa_helpers'
diff --git a/spec/serializers/base_discussion_entity_spec.rb b/spec/serializers/base_discussion_entity_spec.rb
index 5f483da4113..334e71d23f4 100644
--- a/spec/serializers/base_discussion_entity_spec.rb
+++ b/spec/serializers/base_discussion_entity_spec.rb
@@ -66,4 +66,13 @@ RSpec.describe BaseDiscussionEntity do
)
end
end
+
+ context 'when issues are disabled in a project' do
+ let(:project) { create(:project, :issues_disabled) }
+ let(:note) { create(:discussion_note_on_merge_request, project: project) }
+
+ it 'does not show a new issues path' do
+ expect(entity.as_json[:resolve_with_issue_path]).to be_nil
+ end
+ end
end
diff --git a/spec/serializers/merge_request_user_entity_spec.rb b/spec/serializers/merge_request_user_entity_spec.rb
index dcd4ef6acfb..697fa3001e3 100644
--- a/spec/serializers/merge_request_user_entity_spec.rb
+++ b/spec/serializers/merge_request_user_entity_spec.rb
@@ -3,19 +3,22 @@
require 'spec_helper'
RSpec.describe MergeRequestUserEntity do
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
- let(:request) { EntityRequest.new(project: project, current_user: user) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:merge_request) { create(:merge_request) }
+ let(:request) { EntityRequest.new(project: merge_request.target_project, current_user: user) }
let(:entity) do
- described_class.new(user, request: request)
+ described_class.new(user, request: request, merge_request: merge_request)
end
- context 'as json' do
+ describe '#as_json' do
subject { entity.as_json }
it 'exposes needed attributes' do
- expect(subject).to include(:id, :name, :username, :state, :avatar_url, :web_url, :can_merge)
+ is_expected.to include(
+ :id, :name, :username, :state, :avatar_url, :web_url,
+ :can_merge, :can_update_merge_request, :reviewed, :approved
+ )
end
context 'when `status` is not preloaded' do
@@ -24,6 +27,22 @@ RSpec.describe MergeRequestUserEntity do
end
end
+ context 'when the user has not approved the merge-request' do
+ it 'exposes that the user has not approved the MR' do
+ expect(subject).to include(approved: false)
+ end
+ end
+
+ context 'when the user has approved the merge-request' do
+ before do
+ merge_request.approvals.create!(user: user)
+ end
+
+ it 'exposes that the user has approved the MR' do
+ expect(subject).to include(approved: true)
+ end
+ end
+
context 'when `status` is preloaded' do
before do
user.create_status!(availability: :busy)
@@ -35,5 +54,27 @@ RSpec.describe MergeRequestUserEntity do
expect(subject[:availability]).to eq('busy')
end
end
+
+ describe 'performance' do
+ let_it_be(:user_a) { create(:user) }
+ let_it_be(:user_b) { create(:user) }
+ let_it_be(:merge_request_b) { create(:merge_request) }
+
+ it 'is linear in the number of merge requests' do
+ pending "See: https://gitlab.com/gitlab-org/gitlab/-/issues/322549"
+ baseline = ActiveRecord::QueryRecorder.new do
+ ent = described_class.new(user_a, request: request, merge_request: merge_request)
+ ent.as_json
+ end
+
+ expect do
+ a = described_class.new(user_a, request: request, merge_request: merge_request_b)
+ b = described_class.new(user_b, request: request, merge_request: merge_request_b)
+
+ a.as_json
+ b.as_json
+ end.not_to exceed_query_limit(baseline)
+ end
+ end
end
end
diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb
index e0f6ab68034..bcaaa61eb04 100644
--- a/spec/serializers/pipeline_serializer_spec.rb
+++ b/spec/serializers/pipeline_serializer_spec.rb
@@ -209,6 +209,22 @@ RSpec.describe PipelineSerializer do
end
end
+ context 'with scheduled and manual builds' do
+ let(:ref) { 'feature' }
+
+ before do
+ create(:ci_build, :scheduled, pipeline: resource.first)
+ create(:ci_build, :scheduled, pipeline: resource.second)
+ create(:ci_build, :manual, pipeline: resource.first)
+ create(:ci_build, :manual, pipeline: resource.second)
+ end
+
+ it 'sends at most one metadata query for each type of build', :request_store do
+ # 1 for the existing failed builds and 2 for the added scheduled and manual builds
+ expect { subject }.not_to exceed_query_limit(1 + 2).for_query /SELECT "ci_builds_metadata".*/
+ end
+ end
+
def create_pipeline(status)
create(:ci_empty_pipeline,
project: project,
diff --git a/spec/serializers/test_suite_comparer_entity_spec.rb b/spec/serializers/test_suite_comparer_entity_spec.rb
index a63f5683779..318d1d3c1e3 100644
--- a/spec/serializers/test_suite_comparer_entity_spec.rb
+++ b/spec/serializers/test_suite_comparer_entity_spec.rb
@@ -35,6 +35,7 @@ RSpec.describe TestSuiteComparerEntity do
end
expect(subject[:resolved_failures]).to be_empty
expect(subject[:existing_failures]).to be_empty
+ expect(subject[:suite_errors]).to be_nil
end
end
@@ -56,6 +57,7 @@ RSpec.describe TestSuiteComparerEntity do
end
expect(subject[:resolved_failures]).to be_empty
expect(subject[:existing_failures]).to be_empty
+ expect(subject[:suite_errors]).to be_nil
end
end
@@ -77,6 +79,7 @@ RSpec.describe TestSuiteComparerEntity do
expect(existing_failure[:execution_time]).to eq(test_case_failed.execution_time)
expect(existing_failure[:system_output]).to eq(test_case_failed.system_output)
end
+ expect(subject[:suite_errors]).to be_nil
end
end
@@ -98,6 +101,47 @@ RSpec.describe TestSuiteComparerEntity do
expect(resolved_failure[:system_output]).to eq(test_case_success.system_output)
end
expect(subject[:existing_failures]).to be_empty
+ expect(subject[:suite_errors]).to be_nil
+ end
+ end
+
+ context 'when head suite has suite error' do
+ before do
+ allow(head_suite).to receive(:suite_error).and_return('some error')
+ end
+
+ it 'contains suite error for head suite' do
+ expect(subject[:suite_errors]).to eq(
+ head: 'some error',
+ base: nil
+ )
+ end
+ end
+
+ context 'when base suite has suite error' do
+ before do
+ allow(base_suite).to receive(:suite_error).and_return('some error')
+ end
+
+ it 'contains suite error for head suite' do
+ expect(subject[:suite_errors]).to eq(
+ head: nil,
+ base: 'some error'
+ )
+ end
+ end
+
+ context 'when base and head suite both have suite errors' do
+ before do
+ allow(head_suite).to receive(:suite_error).and_return('head error')
+ allow(base_suite).to receive(:suite_error).and_return('base error')
+ end
+
+ it 'contains suite error for head suite' do
+ expect(subject[:suite_errors]).to eq(
+ head: 'head error',
+ base: 'base error'
+ )
end
end
end
diff --git a/spec/serializers/test_suite_summary_entity_spec.rb b/spec/serializers/test_suite_summary_entity_spec.rb
index 864781ccfce..3d43feba910 100644
--- a/spec/serializers/test_suite_summary_entity_spec.rb
+++ b/spec/serializers/test_suite_summary_entity_spec.rb
@@ -20,5 +20,9 @@ RSpec.describe TestSuiteSummaryEntity do
it 'contains the build_ids' do
expect(as_json).to include(:build_ids)
end
+
+ it 'contains the suite_error' do
+ expect(as_json).to include(:suite_error)
+ end
end
end
diff --git a/spec/services/alert_management/create_alert_issue_service_spec.rb b/spec/services/alert_management/create_alert_issue_service_spec.rb
index 2834322be7b..695e90ebd92 100644
--- a/spec/services/alert_management/create_alert_issue_service_spec.rb
+++ b/spec/services/alert_management/create_alert_issue_service_spec.rb
@@ -118,9 +118,36 @@ RSpec.describe AlertManagement::CreateAlertIssueService do
context 'when the alert is generic' do
let(:alert) { generic_alert }
let(:issue) { subject.payload[:issue] }
+ let(:default_alert_title) { described_class::DEFAULT_ALERT_TITLE }
it_behaves_like 'creating an alert issue'
it_behaves_like 'setting an issue attributes'
+
+ context 'when alert title matches the default title exactly' do
+ before do
+ generic_alert.update!(title: default_alert_title)
+ end
+
+ it 'updates issue title with the IID' do
+ execute
+
+ expect(created_issue.title).to eq("New: Incident #{created_issue.iid}")
+ end
+ end
+
+ context 'when the alert title contains the default title' do
+ let(:non_default_alert_title) { "Not #{default_alert_title}" }
+
+ before do
+ generic_alert.update!(title: non_default_alert_title)
+ end
+
+ it 'does not change issue title' do
+ execute
+
+ expect(created_issue.title).to eq(non_default_alert_title)
+ end
+ end
end
context 'when issue cannot be created' do
diff --git a/spec/services/alert_management/process_prometheus_alert_service_spec.rb b/spec/services/alert_management/process_prometheus_alert_service_spec.rb
index 288a33b71cd..9bd71ea6f64 100644
--- a/spec/services/alert_management/process_prometheus_alert_service_spec.rb
+++ b/spec/services/alert_management/process_prometheus_alert_service_spec.rb
@@ -11,6 +11,7 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
describe '#execute' do
let(:service) { described_class.new(project, payload) }
+ let(:source) { 'Prometheus' }
let(:auto_close_incident) { true }
let(:create_issue) { true }
let(:send_email) { true }
@@ -31,7 +32,7 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
subject(:execute) { service.execute }
context 'when alert payload is valid' do
- let(:parsed_payload) { Gitlab::AlertManagement::Payload.parse(project, payload, monitoring_tool: 'Prometheus') }
+ let(:parsed_payload) { Gitlab::AlertManagement::Payload.parse(project, payload, monitoring_tool: source) }
let(:fingerprint) { parsed_payload.gitlab_fingerprint }
let(:payload) do
{
@@ -112,9 +113,7 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
it_behaves_like 'Alert Notification Service sends notification email'
it_behaves_like 'processes incident issues'
- it 'creates a system note corresponding to alert creation' do
- expect { subject }.to change(Note, :count).by(1)
- end
+ it_behaves_like 'creates single system note based on the source of the alert'
context 'when auto-alert creation is disabled' do
let(:create_issue) { false }
@@ -158,17 +157,20 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
context 'when Prometheus alert status is resolved' do
let(:status) { 'resolved' }
- let!(:alert) { create(:alert_management_alert, project: project, fingerprint: fingerprint) }
+ let!(:alert) { create(:alert_management_alert, project: project, fingerprint: fingerprint, monitoring_tool: source) }
context 'when auto_resolve_incident set to true' do
context 'when status can be changed' do
it_behaves_like 'Alert Notification Service sends notification email'
it_behaves_like 'does not process incident issues'
- it 'resolves an existing alert' do
+ it 'resolves an existing alert without error' do
+ expect(Gitlab::AppLogger).not_to receive(:warn)
expect { execute }.to change { alert.reload.resolved? }.to(true)
end
+ it_behaves_like 'creates status-change system note for an auto-resolved alert'
+
context 'existing issue' do
let!(:alert) { create(:alert_management_alert, :with_issue, project: project, fingerprint: fingerprint) }
@@ -215,6 +217,8 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
it 'does not resolve an existing alert' do
expect { execute }.not_to change { alert.reload.resolved? }
end
+
+ it_behaves_like 'creates single system note based on the source of the alert'
end
context 'when emails are disabled' do
diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb
index 29b49db42f9..2fd544ab949 100644
--- a/spec/services/boards/issues/list_service_spec.rb
+++ b/spec/services/boards/issues/list_service_spec.rb
@@ -4,40 +4,41 @@ require 'spec_helper'
RSpec.describe Boards::Issues::ListService do
describe '#execute' do
- context 'when parent is a project' do
- let(:user) { create(:user) }
- let(:project) { create(:project) }
- let(:board) { create(:board, project: project) }
-
- let(:m1) { create(:milestone, project: project) }
- let(:m2) { create(:milestone, project: project) }
-
- let(:bug) { create(:label, project: project, name: 'Bug') }
- let(:development) { create(:label, project: project, name: 'Development') }
- let(:testing) { create(:label, project: project, name: 'Testing') }
- let(:p1) { create(:label, title: 'P1', project: project, priority: 1) }
- let(:p2) { create(:label, title: 'P2', project: project, priority: 2) }
- let(:p3) { create(:label, title: 'P3', project: project, priority: 3) }
-
- let!(:backlog) { create(:backlog_list, board: board) }
- let!(:list1) { create(:list, board: board, label: development, position: 0) }
- let!(:list2) { create(:list, board: board, label: testing, position: 1) }
- let!(:closed) { create(:closed_list, board: board) }
+ let_it_be(:user) { create(:user) }
- let!(:opened_issue1) { create(:labeled_issue, project: project, milestone: m1, title: 'Issue 1', labels: [bug]) }
- let!(:opened_issue2) { create(:labeled_issue, project: project, milestone: m2, title: 'Issue 2', labels: [p2]) }
- let!(:reopened_issue1) { create(:issue, :opened, project: project, title: 'Reopened Issue 1' ) }
-
- let!(:list1_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [p2, development]) }
- let!(:list1_issue2) { create(:labeled_issue, project: project, milestone: m2, labels: [development]) }
- let!(:list1_issue3) { create(:labeled_issue, project: project, milestone: m1, labels: [development, p1]) }
- let!(:list2_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [testing]) }
-
- let!(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug], closed_at: 1.day.ago) }
- let!(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3], closed_at: 2.days.ago) }
- let!(:closed_issue3) { create(:issue, :closed, project: project, closed_at: 1.week.ago) }
- let!(:closed_issue4) { create(:labeled_issue, :closed, project: project, labels: [p1], closed_at: 1.year.ago) }
- let!(:closed_issue5) { create(:labeled_issue, :closed, project: project, labels: [development], closed_at: 2.years.ago) }
+ context 'when parent is a project' do
+ let_it_be(:project) { create(:project, :empty_repo) }
+ let_it_be(:board) { create(:board, project: project) }
+
+ let_it_be(:m1) { create(:milestone, project: project) }
+ let_it_be(:m2) { create(:milestone, project: project) }
+
+ let_it_be(:bug) { create(:label, project: project, name: 'Bug') }
+ let_it_be(:development) { create(:label, project: project, name: 'Development') }
+ let_it_be(:testing) { create(:label, project: project, name: 'Testing') }
+ let_it_be(:p1) { create(:label, title: 'P1', project: project, priority: 1) }
+ let_it_be(:p2) { create(:label, title: 'P2', project: project, priority: 2) }
+ let_it_be(:p3) { create(:label, title: 'P3', project: project, priority: 3) }
+
+ let_it_be(:backlog) { create(:backlog_list, board: board) }
+ let_it_be(:list1) { create(:list, board: board, label: development, position: 0) }
+ let_it_be(:list2) { create(:list, board: board, label: testing, position: 1) }
+ let_it_be(:closed) { create(:closed_list, board: board) }
+
+ let_it_be(:opened_issue1) { create(:labeled_issue, project: project, milestone: m1, title: 'Issue 1', labels: [bug]) }
+ let_it_be(:opened_issue2) { create(:labeled_issue, project: project, milestone: m2, title: 'Issue 2', labels: [p2]) }
+ let_it_be(:reopened_issue1) { create(:issue, :opened, project: project, title: 'Reopened Issue 1' ) }
+
+ let_it_be(:list1_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [p2, development]) }
+ let_it_be(:list1_issue2) { create(:labeled_issue, project: project, milestone: m2, labels: [development]) }
+ let_it_be(:list1_issue3) { create(:labeled_issue, project: project, milestone: m1, labels: [development, p1]) }
+ let_it_be(:list2_issue1) { create(:labeled_issue, project: project, milestone: m1, labels: [testing]) }
+
+ let_it_be(:closed_issue1) { create(:labeled_issue, :closed, project: project, labels: [bug], closed_at: 1.day.ago) }
+ let_it_be(:closed_issue2) { create(:labeled_issue, :closed, project: project, labels: [p3], closed_at: 2.days.ago) }
+ let_it_be(:closed_issue3) { create(:issue, :closed, project: project, closed_at: 1.week.ago) }
+ let_it_be(:closed_issue4) { create(:labeled_issue, :closed, project: project, labels: [p1], closed_at: 1.year.ago) }
+ let_it_be(:closed_issue5) { create(:labeled_issue, :closed, project: project, labels: [development], closed_at: 2.years.ago) }
let(:parent) { project }
@@ -48,14 +49,16 @@ RSpec.describe Boards::Issues::ListService do
it_behaves_like 'issues list service'
context 'when project is archived' do
- let(:project) { create(:project, :archived) }
+ before do
+ project.update!(archived: true)
+ end
it_behaves_like 'issues list service'
end
end
+ # rubocop: disable RSpec/MultipleMemoizedHelpers
context 'when parent is a group' do
- let(:user) { create(:user) }
let(:project) { create(:project, :empty_repo, namespace: group) }
let(:project1) { create(:project, :empty_repo, namespace: group) }
let(:project_archived) { create(:project, :empty_repo, :archived, namespace: group) }
@@ -104,7 +107,7 @@ RSpec.describe Boards::Issues::ListService do
group.add_developer(user)
end
- context 'and group has no parent' do
+ context 'when the group has no parent' do
let(:parent) { group }
let(:group) { create(:group) }
let(:board) { create(:board, group: group) }
@@ -112,7 +115,7 @@ RSpec.describe Boards::Issues::ListService do
it_behaves_like 'issues list service'
end
- context 'and group is an ancestor' do
+ context 'when the group is an ancestor' do
let(:parent) { create(:group) }
let(:group) { create(:group, parent: parent) }
let!(:backlog) { create(:backlog_list, board: board) }
@@ -125,5 +128,6 @@ RSpec.describe Boards::Issues::ListService do
it_behaves_like 'issues list service'
end
end
+ # rubocop: enable RSpec/MultipleMemoizedHelpers
end
end
diff --git a/spec/services/boards/lists/list_service_spec.rb b/spec/services/boards/lists/list_service_spec.rb
index dfe65f3d241..21619abf6aa 100644
--- a/spec/services/boards/lists/list_service_spec.rb
+++ b/spec/services/boards/lists/list_service_spec.rb
@@ -8,6 +8,26 @@ RSpec.describe Boards::Lists::ListService do
describe '#execute' do
let(:service) { described_class.new(parent, user) }
+ shared_examples 'hidden lists' do
+ let!(:list) { create(:list, board: board, label: label) }
+
+ context 'when hide_backlog_list is true' do
+ it 'hides backlog list' do
+ board.update!(hide_backlog_list: true)
+
+ expect(service.execute(board)).to match_array([board.closed_list, list])
+ end
+ end
+
+ context 'when hide_closed_list is true' do
+ it 'hides closed list' do
+ board.update!(hide_closed_list: true)
+
+ expect(service.execute(board)).to match_array([board.backlog_list, list])
+ end
+ end
+ end
+
context 'when board parent is a project' do
let(:project) { create(:project) }
let(:board) { create(:board, project: project) }
@@ -16,6 +36,7 @@ RSpec.describe Boards::Lists::ListService do
let(:parent) { project }
it_behaves_like 'lists list service'
+ it_behaves_like 'hidden lists'
end
context 'when board parent is a group' do
@@ -26,6 +47,7 @@ RSpec.describe Boards::Lists::ListService do
let(:parent) { group }
it_behaves_like 'lists list service'
+ it_behaves_like 'hidden lists'
end
end
end
diff --git a/spec/services/bulk_import_service_spec.rb b/spec/services/bulk_import_service_spec.rb
index e4a50b9d523..1b60a5cb0f8 100644
--- a/spec/services/bulk_import_service_spec.rb
+++ b/spec/services/bulk_import_service_spec.rb
@@ -48,5 +48,22 @@ RSpec.describe BulkImportService do
subject.execute
end
+
+ it 'returns success ServiceResponse' do
+ result = subject.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result).to be_success
+ end
+
+ it 'returns ServiceResponse with error if validation fails' do
+ params[0][:source_full_path] = nil
+
+ result = subject.execute
+
+ expect(result).to be_a(ServiceResponse)
+ expect(result).to be_error
+ expect(result.message).to eq("Validation failed: Source full path can't be blank")
+ end
end
end
diff --git a/spec/services/ci/build_report_result_service_spec.rb b/spec/services/ci/build_report_result_service_spec.rb
index 7c2702af086..c5238b7f5e0 100644
--- a/spec/services/ci/build_report_result_service_spec.rb
+++ b/spec/services/ci/build_report_result_service_spec.rb
@@ -10,13 +10,17 @@ RSpec.describe Ci::BuildReportResultService do
let(:build) { create(:ci_build, :success, :test_reports) }
it 'creates a build report result entry', :aggregate_failures do
+ expect { build_report_result }.to change { Ci::BuildReportResult.count }.by(1)
expect(build_report_result.tests_name).to eq("test")
expect(build_report_result.tests_success).to eq(2)
expect(build_report_result.tests_failed).to eq(2)
expect(build_report_result.tests_errored).to eq(0)
expect(build_report_result.tests_skipped).to eq(0)
expect(build_report_result.tests_duration).to eq(0.010284)
- expect(Ci::BuildReportResult.count).to eq(1)
+ end
+
+ it 'tracks unique test cases parsed' do
+ build_report_result
unique_test_cases_parsed = Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(
event_names: described_class::EVENT_NAME,
@@ -26,6 +30,32 @@ RSpec.describe Ci::BuildReportResultService do
expect(unique_test_cases_parsed).to eq(4)
end
+ context 'and build has test report parsing errors' do
+ let(:build) { create(:ci_build, :success, :broken_test_reports) }
+
+ it 'creates a build report result entry with suite error', :aggregate_failures do
+ expect { build_report_result }.to change { Ci::BuildReportResult.count }.by(1)
+ expect(build_report_result.tests_name).to eq("test")
+ expect(build_report_result.tests_success).to eq(0)
+ expect(build_report_result.tests_failed).to eq(0)
+ expect(build_report_result.tests_errored).to eq(0)
+ expect(build_report_result.tests_skipped).to eq(0)
+ expect(build_report_result.tests_duration).to eq(0)
+ expect(build_report_result.suite_error).to be_present
+ end
+
+ it 'does not track unique test cases parsed' do
+ build_report_result
+
+ unique_test_cases_parsed = Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(
+ event_names: described_class::EVENT_NAME,
+ start_date: 2.weeks.ago,
+ end_date: 2.weeks.from_now
+ )
+ expect(unique_test_cases_parsed).to eq(0)
+ end
+ end
+
context 'when data has already been persisted' do
it 'raises an error and do not persist the same data twice' do
expect { 2.times { described_class.new.execute(build) } }.to raise_error(ActiveRecord::RecordNotUnique)
diff --git a/spec/services/ci/create_pipeline_service/environment_spec.rb b/spec/services/ci/create_pipeline_service/environment_spec.rb
new file mode 100644
index 00000000000..0ed63012325
--- /dev/null
+++ b/spec/services/ci/create_pipeline_service/environment_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::CreatePipelineService do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:developer) { create(:user) }
+ let(:service) { described_class.new(project, user, ref: 'master') }
+ let(:user) { developer }
+
+ before_all do
+ project.add_developer(developer)
+ end
+
+ describe '#execute' do
+ subject { service.execute(:push) }
+
+ context 'with deployment tier' do
+ before do
+ config = YAML.dump(
+ deploy: {
+ script: 'ls',
+ environment: { name: "review/$CI_COMMIT_REF_NAME", deployment_tier: tier }
+ })
+
+ stub_ci_pipeline_yaml_file(config)
+ end
+
+ let(:tier) { 'development' }
+
+ it 'creates the environment with the expected tier' do
+ is_expected.to be_created_successfully
+
+ expect(Environment.find_by_name("review/master")).to be_development
+ end
+
+ context 'when tier is testing' do
+ let(:tier) { 'testing' }
+
+ it 'creates the environment with the expected tier' do
+ is_expected.to be_created_successfully
+
+ expect(Environment.find_by_name("review/master")).to be_testing
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/ci/create_pipeline_service/needs_spec.rb b/spec/services/ci/create_pipeline_service/needs_spec.rb
index 512091035a2..a6b0a9662c9 100644
--- a/spec/services/ci/create_pipeline_service/needs_spec.rb
+++ b/spec/services/ci/create_pipeline_service/needs_spec.rb
@@ -238,5 +238,51 @@ RSpec.describe Ci::CreatePipelineService do
.to eq('jobs:invalid_dag_job:needs config can not be an empty hash')
end
end
+
+ context 'when the needed job has rules' do
+ let(:config) do
+ <<~YAML
+ build:
+ stage: build
+ script: exit 0
+ rules:
+ - if: $CI_COMMIT_REF_NAME == "invalid"
+
+ test:
+ stage: test
+ script: exit 0
+ needs: [build]
+ YAML
+ end
+
+ it 'returns error' do
+ expect(pipeline.yaml_errors)
+ .to eq("'test' job needs 'build' job, but it was not added to the pipeline")
+ end
+
+ context 'when need is optional' do
+ let(:config) do
+ <<~YAML
+ build:
+ stage: build
+ script: exit 0
+ rules:
+ - if: $CI_COMMIT_REF_NAME == "invalid"
+
+ test:
+ stage: test
+ script: exit 0
+ needs:
+ - job: build
+ optional: true
+ YAML
+ end
+
+ it 'creates the pipeline without an error' do
+ expect(pipeline).to be_persisted
+ expect(pipeline.builds.pluck(:name)).to contain_exactly('test')
+ end
+ end
+ end
end
end
diff --git a/spec/services/ci/create_pipeline_service/parallel_spec.rb b/spec/services/ci/create_pipeline_service/parallel_spec.rb
new file mode 100644
index 00000000000..5e34a67d376
--- /dev/null
+++ b/spec/services/ci/create_pipeline_service/parallel_spec.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Ci::CreatePipelineService do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { project.owner }
+
+ let(:service) { described_class.new(project, user, { ref: 'master' }) }
+ let(:pipeline) { service.execute(:push) }
+
+ before do
+ stub_ci_pipeline_yaml_file(config)
+ end
+
+ context 'job:parallel' do
+ context 'numeric' do
+ let(:config) do
+ <<-EOY
+ job:
+ script: "echo job"
+ parallel: 3
+ EOY
+ end
+
+ it 'creates the pipeline' do
+ expect(pipeline).to be_created_successfully
+ end
+
+ it 'creates 3 jobs' do
+ expect(pipeline.processables.pluck(:name)).to contain_exactly(
+ 'job 1/3', 'job 2/3', 'job 3/3'
+ )
+ end
+ end
+
+ context 'matrix' do
+ let(:config) do
+ <<-EOY
+ job:
+ script: "echo job"
+ parallel:
+ matrix:
+ - PROVIDER: ovh
+ STACK: [monitoring, app]
+ - PROVIDER: [gcp, vultr]
+ STACK: [data]
+ EOY
+ end
+
+ it 'creates the pipeline' do
+ expect(pipeline).to be_created_successfully
+ end
+
+ it 'creates 4 builds with the corresponding matrix variables' do
+ expect(pipeline.processables.pluck(:name)).to contain_exactly(
+ 'job: [gcp, data]', 'job: [ovh, app]', 'job: [ovh, monitoring]', 'job: [vultr, data]'
+ )
+
+ job1 = find_job('job: [gcp, data]')
+ job2 = find_job('job: [ovh, app]')
+ job3 = find_job('job: [ovh, monitoring]')
+ job4 = find_job('job: [vultr, data]')
+
+ expect(job1.scoped_variables.to_hash).to include('PROVIDER' => 'gcp', 'STACK' => 'data')
+ expect(job2.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'app')
+ expect(job3.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'monitoring')
+ expect(job4.scoped_variables.to_hash).to include('PROVIDER' => 'vultr', 'STACK' => 'data')
+ end
+
+ context 'when a bridge is using parallel:matrix' do
+ let(:config) do
+ <<-EOY
+ job:
+ stage: test
+ script: "echo job"
+
+ deploy:
+ stage: deploy
+ trigger:
+ include: child.yml
+ parallel:
+ matrix:
+ - PROVIDER: ovh
+ STACK: [monitoring, app]
+ - PROVIDER: [gcp, vultr]
+ STACK: [data]
+ EOY
+ end
+
+ it 'creates the pipeline' do
+ expect(pipeline).to be_created_successfully
+ end
+
+ it 'creates 1 build and 4 bridges with the corresponding matrix variables' do
+ expect(pipeline.processables.pluck(:name)).to contain_exactly(
+ 'job', 'deploy: [gcp, data]', 'deploy: [ovh, app]', 'deploy: [ovh, monitoring]', 'deploy: [vultr, data]'
+ )
+
+ bridge1 = find_job('deploy: [gcp, data]')
+ bridge2 = find_job('deploy: [ovh, app]')
+ bridge3 = find_job('deploy: [ovh, monitoring]')
+ bridge4 = find_job('deploy: [vultr, data]')
+
+ expect(bridge1.scoped_variables.to_hash).to include('PROVIDER' => 'gcp', 'STACK' => 'data')
+ expect(bridge2.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'app')
+ expect(bridge3.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'monitoring')
+ expect(bridge4.scoped_variables.to_hash).to include('PROVIDER' => 'vultr', 'STACK' => 'data')
+ end
+ end
+ end
+ end
+
+ private
+
+ def find_job(name)
+ pipeline.processables.find { |job| job.name == name }
+ end
+end
diff --git a/spec/services/ci/create_pipeline_service/rules_spec.rb b/spec/services/ci/create_pipeline_service/rules_spec.rb
index 04ecac6a85a..e97e74c1515 100644
--- a/spec/services/ci/create_pipeline_service/rules_spec.rb
+++ b/spec/services/ci/create_pipeline_service/rules_spec.rb
@@ -174,33 +174,19 @@ RSpec.describe Ci::CreatePipelineService do
let(:ref) { 'refs/heads/master' }
it 'overrides VAR1' do
- variables = job.scoped_variables_hash
+ variables = job.scoped_variables.to_hash
expect(variables['VAR1']).to eq('overridden var 1')
expect(variables['VAR2']).to eq('my var 2')
expect(variables['VAR3']).to be_nil
end
-
- context 'when FF ci_rules_variables is disabled' do
- before do
- stub_feature_flags(ci_rules_variables: false)
- end
-
- it 'does not affect variables' do
- variables = job.scoped_variables_hash
-
- expect(variables['VAR1']).to eq('my var 1')
- expect(variables['VAR2']).to eq('my var 2')
- expect(variables['VAR3']).to be_nil
- end
- end
end
context 'when matching to the second rule' do
let(:ref) { 'refs/heads/feature' }
it 'overrides VAR2 and adds VAR3' do
- variables = job.scoped_variables_hash
+ variables = job.scoped_variables.to_hash
expect(variables['VAR1']).to eq('my var 1')
expect(variables['VAR2']).to eq('overridden var 2')
@@ -212,7 +198,7 @@ RSpec.describe Ci::CreatePipelineService do
let(:ref) { 'refs/heads/wip' }
it 'does not affect vars' do
- variables = job.scoped_variables_hash
+ variables = job.scoped_variables.to_hash
expect(variables['VAR1']).to eq('my var 1')
expect(variables['VAR2']).to eq('my var 2')
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index 1005985b3e4..9fafc57a770 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -101,7 +101,7 @@ RSpec.describe Ci::CreatePipelineService do
describe 'recording a conversion event' do
it 'schedules a record conversion event worker' do
- expect(Experiments::RecordConversionEventWorker).to receive(:perform_async).with(:ci_syntax_templates, user.id)
+ expect(Experiments::RecordConversionEventWorker).to receive(:perform_async).with(:ci_syntax_templates_b, user.id)
pipeline
end
diff --git a/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb b/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb
index 1edcef2977b..d315dd35632 100644
--- a/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb
+++ b/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb
@@ -77,14 +77,6 @@ RSpec.describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared
it 'does not remove the files' do
expect { subject }.not_to change { artifact.file.exists? }
end
-
- it 'reports metrics for destroyed artifacts' do
- counter = service.send(:destroyed_artifacts_counter)
-
- expect(counter).to receive(:increment).with({}, 1).and_call_original
-
- subject
- end
end
end
@@ -244,5 +236,17 @@ RSpec.describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared
expect { subject }.to change { Ci::JobArtifact.count }.by(-1)
end
end
+
+ context 'when all artifacts are locked' do
+ before do
+ pipeline = create(:ci_pipeline, locked: :artifacts_locked)
+ job = create(:ci_build, pipeline: pipeline)
+ artifact.update!(job: job)
+ end
+
+ it 'destroys no artifacts' do
+ expect { subject }.to change { Ci::JobArtifact.count }.by(0)
+ end
+ end
end
end
diff --git a/spec/services/ci/expire_pipeline_cache_service_spec.rb b/spec/services/ci/expire_pipeline_cache_service_spec.rb
index 8df5d0bc159..3dbf2dbb8f1 100644
--- a/spec/services/ci/expire_pipeline_cache_service_spec.rb
+++ b/spec/services/ci/expire_pipeline_cache_service_spec.rb
@@ -13,10 +13,14 @@ RSpec.describe Ci::ExpirePipelineCacheService do
pipelines_path = "/#{project.full_path}/-/pipelines.json"
new_mr_pipelines_path = "/#{project.full_path}/-/merge_requests/new.json"
pipeline_path = "/#{project.full_path}/-/pipelines/#{pipeline.id}.json"
+ graphql_pipeline_path = "/api/graphql:pipelines/id/#{pipeline.id}"
- expect_any_instance_of(Gitlab::EtagCaching::Store).to receive(:touch).with(pipelines_path)
- expect_any_instance_of(Gitlab::EtagCaching::Store).to receive(:touch).with(new_mr_pipelines_path)
- expect_any_instance_of(Gitlab::EtagCaching::Store).to receive(:touch).with(pipeline_path)
+ expect_next_instance_of(Gitlab::EtagCaching::Store) do |store|
+ expect(store).to receive(:touch).with(pipelines_path)
+ expect(store).to receive(:touch).with(new_mr_pipelines_path)
+ expect(store).to receive(:touch).with(pipeline_path)
+ expect(store).to receive(:touch).with(graphql_pipeline_path)
+ end
subject.execute(pipeline)
end
@@ -59,5 +63,36 @@ RSpec.describe Ci::ExpirePipelineCacheService do
expect(Project.find(project_with_repo.id).pipeline_status.has_status?).to be_falsey
end
end
+
+ context 'when the pipeline is triggered by another pipeline' do
+ let(:source) { create(:ci_sources_pipeline, pipeline: pipeline) }
+
+ it 'updates the cache of dependent pipeline' do
+ dependent_pipeline_path = "/#{source.source_project.full_path}/-/pipelines/#{source.source_pipeline.id}.json"
+
+ expect_next_instance_of(Gitlab::EtagCaching::Store) do |store|
+ allow(store).to receive(:touch)
+ expect(store).to receive(:touch).with(dependent_pipeline_path)
+ end
+
+ subject.execute(pipeline)
+ end
+ end
+
+ context 'when the pipeline triggered another pipeline' do
+ let(:build) { create(:ci_build, pipeline: pipeline) }
+ let(:source) { create(:ci_sources_pipeline, source_job: build) }
+
+ it 'updates the cache of dependent pipeline' do
+ dependent_pipeline_path = "/#{source.project.full_path}/-/pipelines/#{source.pipeline.id}.json"
+
+ expect_next_instance_of(Gitlab::EtagCaching::Store) do |store|
+ allow(store).to receive(:touch)
+ expect(store).to receive(:touch).with(dependent_pipeline_path)
+ end
+
+ subject.execute(pipeline)
+ end
+ end
end
end
diff --git a/spec/services/ci/job_artifacts_destroy_batch_service_spec.rb b/spec/services/ci/job_artifacts_destroy_batch_service_spec.rb
new file mode 100644
index 00000000000..74fbbf28ef1
--- /dev/null
+++ b/spec/services/ci/job_artifacts_destroy_batch_service_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::JobArtifactsDestroyBatchService do
+ include ExclusiveLeaseHelpers
+
+ let(:artifacts) { Ci::JobArtifact.all }
+ let(:service) { described_class.new(artifacts, pick_up_at: Time.current) }
+
+ describe '.execute' do
+ subject(:execute) { service.execute }
+
+ let_it_be(:artifact, refind: true) do
+ create(:ci_job_artifact)
+ end
+
+ context 'when the artifact has a file attached to it' do
+ before do
+ artifact.file = fixture_file_upload(Rails.root.join('spec/fixtures/ci_build_artifacts.zip'), 'application/zip')
+ artifact.save!
+ end
+
+ it 'creates a deleted object' do
+ expect { subject }.to change { Ci::DeletedObject.count }.by(1)
+ end
+
+ it 'resets project statistics' do
+ expect(ProjectStatistics).to receive(:increment_statistic).once
+ .with(artifact.project, :build_artifacts_size, -artifact.file.size)
+ .and_call_original
+
+ execute
+ end
+
+ it 'does not remove the files' do
+ expect { execute }.not_to change { artifact.file.exists? }
+ end
+
+ it 'reports metrics for destroyed artifacts' do
+ expect_next_instance_of(Gitlab::Ci::Artifacts::Metrics) do |metrics|
+ expect(metrics).to receive(:increment_destroyed_artifacts).with(1).and_call_original
+ end
+
+ execute
+ end
+ end
+
+ context 'when failed to destroy artifact' do
+ context 'when the import fails' do
+ before do
+ expect(Ci::DeletedObject)
+ .to receive(:bulk_import)
+ .once
+ .and_raise(ActiveRecord::RecordNotDestroyed)
+ end
+
+ it 'raises an exception and stop destroying' do
+ expect { execute }.to raise_error(ActiveRecord::RecordNotDestroyed)
+ .and not_change { Ci::JobArtifact.count }.from(1)
+ end
+ end
+ end
+
+ context 'when there are no artifacts' do
+ let(:artifacts) { Ci::JobArtifact.none }
+
+ before do
+ artifact.destroy!
+ end
+
+ it 'does not raise error' do
+ expect { execute }.not_to raise_error
+ end
+
+ it 'reports the number of destroyed artifacts' do
+ is_expected.to eq(destroyed_artifacts_count: 0, status: :success)
+ end
+ end
+ end
+end
diff --git a/spec/services/ci/pipeline_processing/shared_processing_service.rb b/spec/services/ci/pipeline_processing/shared_processing_service.rb
index bbd7422b435..13c924a3089 100644
--- a/spec/services/ci/pipeline_processing/shared_processing_service.rb
+++ b/spec/services/ci/pipeline_processing/shared_processing_service.rb
@@ -1,21 +1,13 @@
# frozen_string_literal: true
RSpec.shared_examples 'Pipeline Processing Service' do
- let(:user) { create(:user) }
- let(:project) { create(:project) }
+ let(:project) { create(:project, :repository) }
+ let(:user) { project.owner }
let(:pipeline) do
create(:ci_empty_pipeline, ref: 'master', project: project)
end
- before do
- stub_ci_pipeline_to_return_yaml_file
-
- stub_not_protect_default_branch
-
- project.add_developer(user)
- end
-
context 'when simple pipeline is defined' do
before do
create_build('linux', stage_idx: 0)
@@ -843,19 +835,97 @@ RSpec.shared_examples 'Pipeline Processing Service' do
create(:ci_build_need, build: deploy, name: 'linux:build')
end
- it 'makes deploy DAG to be waiting for optional manual to finish' do
+ it 'makes deploy DAG to be skipped' do
expect(process_pipeline).to be_truthy
- expect(stages).to eq(%w(skipped created))
+ expect(stages).to eq(%w(skipped skipped))
expect(all_builds.manual).to contain_exactly(linux_build)
- expect(all_builds.created).to contain_exactly(deploy)
+ expect(all_builds.skipped).to contain_exactly(deploy)
+ end
+
+ context 'when FF ci_fix_pipeline_status_for_dag_needs_manual is disabled' do
+ before do
+ stub_feature_flags(ci_fix_pipeline_status_for_dag_needs_manual: false)
+ end
+
+ it 'makes deploy DAG to be waiting for optional manual to finish' do
+ expect(process_pipeline).to be_truthy
+
+ expect(stages).to eq(%w(skipped created))
+ expect(all_builds.manual).to contain_exactly(linux_build)
+ expect(all_builds.created).to contain_exactly(deploy)
+ end
+ end
+ end
+
+ context 'when a bridge job has parallel:matrix config', :sidekiq_inline do
+ let(:parent_config) do
+ <<-EOY
+ test:
+ stage: test
+ script: echo test
+
+ deploy:
+ stage: deploy
+ trigger:
+ include: .child.yml
+ parallel:
+ matrix:
+ - PROVIDER: ovh
+ STACK: [monitoring, app]
+ EOY
+ end
+
+ let(:child_config) do
+ <<-EOY
+ test:
+ stage: test
+ script: echo test
+ EOY
+ end
+
+ let(:pipeline) do
+ Ci::CreatePipelineService.new(project, user, { ref: 'master' }).execute(:push)
+ end
+
+ before do
+ allow_next_instance_of(Repository) do |repository|
+ allow(repository)
+ .to receive(:blob_data_at)
+ .with(an_instance_of(String), '.gitlab-ci.yml')
+ .and_return(parent_config)
+
+ allow(repository)
+ .to receive(:blob_data_at)
+ .with(an_instance_of(String), '.child.yml')
+ .and_return(child_config)
+ end
+ end
+
+ it 'creates pipeline with bridges, then passes the matrix variables to downstream jobs' do
+ expect(all_builds_names).to contain_exactly('test', 'deploy: [ovh, monitoring]', 'deploy: [ovh, app]')
+ expect(all_builds_statuses).to contain_exactly('pending', 'created', 'created')
+
+ succeed_pending
+
+ # bridge jobs directly transition to success
+ expect(all_builds_statuses).to contain_exactly('success', 'success', 'success')
+
+ bridge1 = all_builds.find_by(name: 'deploy: [ovh, monitoring]')
+ bridge2 = all_builds.find_by(name: 'deploy: [ovh, app]')
+
+ downstream_job1 = bridge1.downstream_pipeline.processables.first
+ downstream_job2 = bridge2.downstream_pipeline.processables.first
+
+ expect(downstream_job1.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'monitoring')
+ expect(downstream_job2.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'app')
end
end
private
def all_builds
- pipeline.builds.order(:stage_idx, :id)
+ pipeline.processables.order(:stage_idx, :id)
end
def builds
diff --git a/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb b/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb
index 2936d6fae4d..a9f9db8c689 100644
--- a/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb
+++ b/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb
@@ -1,21 +1,19 @@
# frozen_string_literal: true
RSpec.shared_context 'Pipeline Processing Service Tests With Yaml' do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { project.owner }
+
where(:test_file_path) do
Dir.glob(Rails.root.join('spec/services/ci/pipeline_processing/test_cases/*.yml'))
end
with_them do
let(:test_file) { YAML.load_file(test_file_path) }
-
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
let(:pipeline) { Ci::CreatePipelineService.new(project, user, ref: 'master').execute(:pipeline) }
before do
stub_ci_pipeline_yaml_file(YAML.dump(test_file['config']))
- stub_not_protect_default_branch
- project.add_developer(user)
end
it 'follows transitions' do
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build2_build1_rules_out_test_needs_build1_with_optional.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build2_build1_rules_out_test_needs_build1_with_optional.yml
new file mode 100644
index 00000000000..170e1b589bb
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_build2_build1_rules_out_test_needs_build1_with_optional.yml
@@ -0,0 +1,50 @@
+config:
+ build1:
+ stage: build
+ script: exit 0
+ rules:
+ - if: $CI_COMMIT_REF_NAME == "invalid"
+
+ build2:
+ stage: build
+ script: exit 0
+
+ test:
+ stage: test
+ script: exit 0
+ needs:
+ - job: build1
+ optional: true
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ build: pending
+ test: pending
+ jobs:
+ build2: pending
+ test: pending
+
+transitions:
+ - event: success
+ jobs: [test]
+ expect:
+ pipeline: running
+ stages:
+ build: pending
+ test: success
+ jobs:
+ build2: pending
+ test: success
+
+ - event: success
+ jobs: [build2]
+ expect:
+ pipeline: success
+ stages:
+ build: success
+ test: success
+ jobs:
+ build2: success
+ test: success
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_rules_out_test_needs_build_with_optional.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_rules_out_test_needs_build_with_optional.yml
new file mode 100644
index 00000000000..85e7aa04a24
--- /dev/null
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_rules_out_test_needs_build_with_optional.yml
@@ -0,0 +1,31 @@
+config:
+ build:
+ stage: build
+ script: exit 0
+ rules:
+ - if: $CI_COMMIT_REF_NAME == "invalid"
+
+ test:
+ stage: test
+ script: exit 0
+ needs:
+ - job: build
+ optional: true
+
+init:
+ expect:
+ pipeline: pending
+ stages:
+ test: pending
+ jobs:
+ test: pending
+
+transitions:
+ - event: success
+ jobs: [test]
+ expect:
+ pipeline: success
+ stages:
+ test: success
+ jobs:
+ test: success
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_both.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_both.yml
index 60f803bc3d0..96377b00c85 100644
--- a/spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_both.yml
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_both.yml
@@ -30,12 +30,12 @@ transitions:
- event: success
jobs: [build]
expect:
- pipeline: running
+ pipeline: success
stages:
build: success
test: skipped
- deploy: created
+ deploy: skipped
jobs:
build: success
test: manual
- deploy: created
+ deploy: skipped
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_test.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_test.yml
index 4e4b2f22224..69640630ef4 100644
--- a/spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_test.yml
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_succeds_test_manual_allow_failure_true_deploy_needs_test.yml
@@ -30,12 +30,12 @@ transitions:
- event: success
jobs: [build]
expect:
- pipeline: running
+ pipeline: success
stages:
build: success
test: skipped
- deploy: created
+ deploy: skipped
jobs:
build: success
test: manual
- deploy: created
+ deploy: skipped
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_build_test_manual_review_deploy.yml b/spec/services/ci/pipeline_processing/test_cases/dag_build_test_manual_review_deploy.yml
index fef28dcfbbe..8de484d6793 100644
--- a/spec/services/ci/pipeline_processing/test_cases/dag_build_test_manual_review_deploy.yml
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_build_test_manual_review_deploy.yml
@@ -54,29 +54,29 @@ transitions:
stages:
build: success
test: pending
- review: created
- deploy: created
+ review: skipped
+ deploy: skipped
jobs:
build: success
test: pending
release_test: manual
- review: created
- staging: created
- production: created
+ review: skipped
+ staging: skipped
+ production: skipped
- event: success
jobs: [test]
expect:
- pipeline: running
+ pipeline: success
stages:
build: success
test: success
- review: created
- deploy: created
+ review: skipped
+ deploy: skipped
jobs:
build: success
test: success
release_test: manual
- review: created
- staging: created
- production: created
+ review: skipped
+ staging: skipped
+ production: skipped
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true.yml b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true.yml
index d8ca563b141..b8fcdd1566a 100644
--- a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true.yml
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true.yml
@@ -12,13 +12,13 @@ config:
init:
expect:
- pipeline: created
+ pipeline: skipped
stages:
test: skipped
- deploy: created
+ deploy: skipped
jobs:
test: manual
- deploy: created
+ deploy: skipped
transitions:
- event: enqueue
@@ -27,10 +27,10 @@ transitions:
pipeline: pending
stages:
test: pending
- deploy: created
+ deploy: skipped
jobs:
test: pending
- deploy: created
+ deploy: skipped
- event: run
jobs: [test]
@@ -38,21 +38,18 @@ transitions:
pipeline: running
stages:
test: running
- deploy: created
+ deploy: skipped
jobs:
test: running
- deploy: created
+ deploy: skipped
- event: drop
jobs: [test]
expect:
- pipeline: running
+ pipeline: success
stages:
test: success
- deploy: pending
+ deploy: skipped
jobs:
test: failed
- deploy: pending
-
-# TOOD: should we run deploy?
-# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080
+ deploy: skipped
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_always.yml b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_always.yml
index ba0a20f49a7..a4a98bf4629 100644
--- a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_always.yml
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_always.yml
@@ -13,15 +13,12 @@ config:
init:
expect:
- pipeline: created
+ pipeline: pending
stages:
test: skipped
- deploy: created
+ deploy: pending
jobs:
test: manual
- deploy: created
+ deploy: pending
transitions: []
-
-# TODO: should we run `deploy`?
-# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_on_failure.yml b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_on_failure.yml
index d375c6a49e0..81aad4940b6 100644
--- a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_on_failure.yml
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_deploy_on_failure.yml
@@ -13,13 +13,13 @@ config:
init:
expect:
- pipeline: created
+ pipeline: skipped
stages:
test: skipped
- deploy: created
+ deploy: skipped
jobs:
test: manual
- deploy: created
+ deploy: skipped
transitions:
- event: enqueue
@@ -28,10 +28,10 @@ transitions:
pipeline: pending
stages:
test: pending
- deploy: created
+ deploy: skipped
jobs:
test: pending
- deploy: created
+ deploy: skipped
- event: drop
jobs: [test]
@@ -43,6 +43,3 @@ transitions:
jobs:
test: failed
deploy: skipped
-
-# TODO: should we run `deploy`?
-# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080
diff --git a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_other_test_succeeds_deploy_needs_both.yml b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_other_test_succeeds_deploy_needs_both.yml
index 34073b92ccc..a5bb103d1a5 100644
--- a/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_other_test_succeeds_deploy_needs_both.yml
+++ b/spec/services/ci/pipeline_processing/test_cases/dag_test_manual_allow_failure_true_other_test_succeeds_deploy_needs_both.yml
@@ -19,24 +19,21 @@ init:
pipeline: pending
stages:
test: pending
- deploy: created
+ deploy: skipped
jobs:
test1: pending
test2: manual
- deploy: created
+ deploy: skipped
transitions:
- event: success
jobs: [test1]
expect:
- pipeline: running
+ pipeline: success
stages:
test: success
- deploy: created
+ deploy: skipped
jobs:
test1: success
test2: manual
- deploy: created
-
-# TODO: should deploy run?
-# Further discussions: https://gitlab.com/gitlab-org/gitlab/-/issues/213080
+ deploy: skipped
diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb
index d316c9a262b..e02536fd07f 100644
--- a/spec/services/ci/process_pipeline_service_spec.rb
+++ b/spec/services/ci/process_pipeline_service_spec.rb
@@ -43,42 +43,59 @@ RSpec.describe Ci::ProcessPipelineService do
let!(:build) { create_build('build') }
let!(:test) { create_build('test') }
- it 'returns unique statuses' do
- subject.execute
+ context 'when FF ci_remove_update_retried_from_process_pipeline is enabled' do
+ it 'does not update older builds as retried' do
+ subject.execute
- expect(all_builds.latest).to contain_exactly(build, test)
- expect(all_builds.retried).to contain_exactly(build_retried)
+ expect(all_builds.latest).to contain_exactly(build, build_retried, test)
+ expect(all_builds.retried).to be_empty
+ end
end
- context 'counter ci_legacy_update_jobs_as_retried_total' do
- let(:counter) { double(increment: true) }
-
+ context 'when FF ci_remove_update_retried_from_process_pipeline is disabled' do
before do
- allow(Gitlab::Metrics).to receive(:counter).and_call_original
- allow(Gitlab::Metrics).to receive(:counter)
- .with(:ci_legacy_update_jobs_as_retried_total, anything)
- .and_return(counter)
+ stub_feature_flags(ci_remove_update_retried_from_process_pipeline: false)
end
- it 'increments the counter' do
- expect(counter).to receive(:increment)
-
+ it 'returns unique statuses' do
subject.execute
+
+ expect(all_builds.latest).to contain_exactly(build, test)
+ expect(all_builds.retried).to contain_exactly(build_retried)
end
- context 'when the previous build has already retried column true' do
+ context 'counter ci_legacy_update_jobs_as_retried_total' do
+ let(:counter) { double(increment: true) }
+
before do
- build_retried.update_columns(retried: true)
+ allow(Gitlab::Metrics).to receive(:counter).and_call_original
+ allow(Gitlab::Metrics).to receive(:counter)
+ .with(:ci_legacy_update_jobs_as_retried_total, anything)
+ .and_return(counter)
end
- it 'does not increment the counter' do
- expect(counter).not_to receive(:increment)
+ it 'increments the counter' do
+ expect(counter).to receive(:increment)
subject.execute
end
+
+ context 'when the previous build has already retried column true' do
+ before do
+ build_retried.update_columns(retried: true)
+ end
+
+ it 'does not increment the counter' do
+ expect(counter).not_to receive(:increment)
+
+ subject.execute
+ end
+ end
end
end
+ private
+
def create_build(name, **opts)
create(:ci_build, :created, pipeline: pipeline, name: name, **opts)
end
diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb
index 88770c8095b..9187dd4f300 100644
--- a/spec/services/ci/register_job_service_spec.rb
+++ b/spec/services/ci/register_job_service_spec.rb
@@ -13,573 +13,656 @@ module Ci
let!(:pending_job) { create(:ci_build, pipeline: pipeline) }
describe '#execute' do
- context 'runner follow tag list' do
- it "picks build with the same tag" do
- pending_job.update!(tag_list: ["linux"])
- specific_runner.update!(tag_list: ["linux"])
- expect(execute(specific_runner)).to eq(pending_job)
- end
-
- it "does not pick build with different tag" do
- pending_job.update!(tag_list: ["linux"])
- specific_runner.update!(tag_list: ["win32"])
- expect(execute(specific_runner)).to be_falsey
- end
+ shared_examples 'handles runner assignment' do
+ context 'runner follow tag list' do
+ it "picks build with the same tag" do
+ pending_job.update!(tag_list: ["linux"])
+ specific_runner.update!(tag_list: ["linux"])
+ expect(execute(specific_runner)).to eq(pending_job)
+ end
- it "picks build without tag" do
- expect(execute(specific_runner)).to eq(pending_job)
- end
+ it "does not pick build with different tag" do
+ pending_job.update!(tag_list: ["linux"])
+ specific_runner.update!(tag_list: ["win32"])
+ expect(execute(specific_runner)).to be_falsey
+ end
- it "does not pick build with tag" do
- pending_job.update!(tag_list: ["linux"])
- expect(execute(specific_runner)).to be_falsey
- end
+ it "picks build without tag" do
+ expect(execute(specific_runner)).to eq(pending_job)
+ end
- it "pick build without tag" do
- specific_runner.update!(tag_list: ["win32"])
- expect(execute(specific_runner)).to eq(pending_job)
- end
- end
+ it "does not pick build with tag" do
+ pending_job.update!(tag_list: ["linux"])
+ expect(execute(specific_runner)).to be_falsey
+ end
- context 'deleted projects' do
- before do
- project.update!(pending_delete: true)
+ it "pick build without tag" do
+ specific_runner.update!(tag_list: ["win32"])
+ expect(execute(specific_runner)).to eq(pending_job)
+ end
end
- context 'for shared runners' do
+ context 'deleted projects' do
before do
- project.update!(shared_runners_enabled: true)
+ project.update!(pending_delete: true)
end
- it 'does not pick a build' do
- expect(execute(shared_runner)).to be_nil
+ context 'for shared runners' do
+ before do
+ project.update!(shared_runners_enabled: true)
+ end
+
+ it 'does not pick a build' do
+ expect(execute(shared_runner)).to be_nil
+ end
end
- end
- context 'for specific runner' do
- it 'does not pick a build' do
- expect(execute(specific_runner)).to be_nil
+ context 'for specific runner' do
+ it 'does not pick a build' do
+ expect(execute(specific_runner)).to be_nil
+ end
end
end
- end
- context 'allow shared runners' do
- before do
- project.update!(shared_runners_enabled: true)
- end
+ context 'allow shared runners' do
+ before do
+ project.update!(shared_runners_enabled: true)
+ end
+
+ context 'for multiple builds' do
+ let!(:project2) { create :project, shared_runners_enabled: true }
+ let!(:pipeline2) { create :ci_pipeline, project: project2 }
+ let!(:project3) { create :project, shared_runners_enabled: true }
+ let!(:pipeline3) { create :ci_pipeline, project: project3 }
+ let!(:build1_project1) { pending_job }
+ let!(:build2_project1) { FactoryBot.create :ci_build, pipeline: pipeline }
+ let!(:build3_project1) { FactoryBot.create :ci_build, pipeline: pipeline }
+ let!(:build1_project2) { FactoryBot.create :ci_build, pipeline: pipeline2 }
+ let!(:build2_project2) { FactoryBot.create :ci_build, pipeline: pipeline2 }
+ let!(:build1_project3) { FactoryBot.create :ci_build, pipeline: pipeline3 }
+
+ it 'prefers projects without builds first' do
+ # it gets for one build from each of the projects
+ expect(execute(shared_runner)).to eq(build1_project1)
+ expect(execute(shared_runner)).to eq(build1_project2)
+ expect(execute(shared_runner)).to eq(build1_project3)
+
+ # then it gets a second build from each of the projects
+ expect(execute(shared_runner)).to eq(build2_project1)
+ expect(execute(shared_runner)).to eq(build2_project2)
+
+ # in the end the third build
+ expect(execute(shared_runner)).to eq(build3_project1)
+ end
- context 'for multiple builds' do
- let!(:project2) { create :project, shared_runners_enabled: true }
- let!(:pipeline2) { create :ci_pipeline, project: project2 }
- let!(:project3) { create :project, shared_runners_enabled: true }
- let!(:pipeline3) { create :ci_pipeline, project: project3 }
- let!(:build1_project1) { pending_job }
- let!(:build2_project1) { FactoryBot.create :ci_build, pipeline: pipeline }
- let!(:build3_project1) { FactoryBot.create :ci_build, pipeline: pipeline }
- let!(:build1_project2) { FactoryBot.create :ci_build, pipeline: pipeline2 }
- let!(:build2_project2) { FactoryBot.create :ci_build, pipeline: pipeline2 }
- let!(:build1_project3) { FactoryBot.create :ci_build, pipeline: pipeline3 }
-
- it 'prefers projects without builds first' do
- # it gets for one build from each of the projects
- expect(execute(shared_runner)).to eq(build1_project1)
- expect(execute(shared_runner)).to eq(build1_project2)
- expect(execute(shared_runner)).to eq(build1_project3)
-
- # then it gets a second build from each of the projects
- expect(execute(shared_runner)).to eq(build2_project1)
- expect(execute(shared_runner)).to eq(build2_project2)
-
- # in the end the third build
- expect(execute(shared_runner)).to eq(build3_project1)
- end
-
- it 'equalises number of running builds' do
- # after finishing the first build for project 1, get a second build from the same project
- expect(execute(shared_runner)).to eq(build1_project1)
- build1_project1.reload.success
- expect(execute(shared_runner)).to eq(build2_project1)
-
- expect(execute(shared_runner)).to eq(build1_project2)
- build1_project2.reload.success
- expect(execute(shared_runner)).to eq(build2_project2)
- expect(execute(shared_runner)).to eq(build1_project3)
- expect(execute(shared_runner)).to eq(build3_project1)
+ it 'equalises number of running builds' do
+ # after finishing the first build for project 1, get a second build from the same project
+ expect(execute(shared_runner)).to eq(build1_project1)
+ build1_project1.reload.success
+ expect(execute(shared_runner)).to eq(build2_project1)
+
+ expect(execute(shared_runner)).to eq(build1_project2)
+ build1_project2.reload.success
+ expect(execute(shared_runner)).to eq(build2_project2)
+ expect(execute(shared_runner)).to eq(build1_project3)
+ expect(execute(shared_runner)).to eq(build3_project1)
+ end
end
- end
- context 'shared runner' do
- let(:response) { described_class.new(shared_runner).execute }
- let(:build) { response.build }
+ context 'shared runner' do
+ let(:response) { described_class.new(shared_runner).execute }
+ let(:build) { response.build }
- it { expect(build).to be_kind_of(Build) }
- it { expect(build).to be_valid }
- it { expect(build).to be_running }
- it { expect(build.runner).to eq(shared_runner) }
- it { expect(Gitlab::Json.parse(response.build_json)['id']).to eq(build.id) }
- end
+ it { expect(build).to be_kind_of(Build) }
+ it { expect(build).to be_valid }
+ it { expect(build).to be_running }
+ it { expect(build.runner).to eq(shared_runner) }
+ it { expect(Gitlab::Json.parse(response.build_json)['id']).to eq(build.id) }
+ end
- context 'specific runner' do
- let(:build) { execute(specific_runner) }
+ context 'specific runner' do
+ let(:build) { execute(specific_runner) }
- it { expect(build).to be_kind_of(Build) }
- it { expect(build).to be_valid }
- it { expect(build).to be_running }
- it { expect(build.runner).to eq(specific_runner) }
+ it { expect(build).to be_kind_of(Build) }
+ it { expect(build).to be_valid }
+ it { expect(build).to be_running }
+ it { expect(build.runner).to eq(specific_runner) }
+ end
end
- end
- context 'disallow shared runners' do
- before do
- project.update!(shared_runners_enabled: false)
- end
+ context 'disallow shared runners' do
+ before do
+ project.update!(shared_runners_enabled: false)
+ end
- context 'shared runner' do
- let(:build) { execute(shared_runner) }
+ context 'shared runner' do
+ let(:build) { execute(shared_runner) }
- it { expect(build).to be_nil }
- end
+ it { expect(build).to be_nil }
+ end
- context 'specific runner' do
- let(:build) { execute(specific_runner) }
+ context 'specific runner' do
+ let(:build) { execute(specific_runner) }
- it { expect(build).to be_kind_of(Build) }
- it { expect(build).to be_valid }
- it { expect(build).to be_running }
- it { expect(build.runner).to eq(specific_runner) }
+ it { expect(build).to be_kind_of(Build) }
+ it { expect(build).to be_valid }
+ it { expect(build).to be_running }
+ it { expect(build.runner).to eq(specific_runner) }
+ end
end
- end
- context 'disallow when builds are disabled' do
- before do
- project.update!(shared_runners_enabled: true, group_runners_enabled: true)
- project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED)
- end
+ context 'disallow when builds are disabled' do
+ before do
+ project.update!(shared_runners_enabled: true, group_runners_enabled: true)
+ project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED)
+ end
- context 'and uses shared runner' do
- let(:build) { execute(shared_runner) }
+ context 'and uses shared runner' do
+ let(:build) { execute(shared_runner) }
- it { expect(build).to be_nil }
- end
+ it { expect(build).to be_nil }
+ end
- context 'and uses group runner' do
- let(:build) { execute(group_runner) }
+ context 'and uses group runner' do
+ let(:build) { execute(group_runner) }
- it { expect(build).to be_nil }
- end
+ it { expect(build).to be_nil }
+ end
- context 'and uses project runner' do
- let(:build) { execute(specific_runner) }
+ context 'and uses project runner' do
+ let(:build) { execute(specific_runner) }
- it { expect(build).to be_nil }
+ it { expect(build).to be_nil }
+ end
end
- end
- context 'allow group runners' do
- before do
- project.update!(group_runners_enabled: true)
- end
+ context 'allow group runners' do
+ before do
+ project.update!(group_runners_enabled: true)
+ end
- context 'for multiple builds' do
- let!(:project2) { create(:project, group_runners_enabled: true, group: group) }
- let!(:pipeline2) { create(:ci_pipeline, project: project2) }
- let!(:project3) { create(:project, group_runners_enabled: true, group: group) }
- let!(:pipeline3) { create(:ci_pipeline, project: project3) }
+ context 'for multiple builds' do
+ let!(:project2) { create(:project, group_runners_enabled: true, group: group) }
+ let!(:pipeline2) { create(:ci_pipeline, project: project2) }
+ let!(:project3) { create(:project, group_runners_enabled: true, group: group) }
+ let!(:pipeline3) { create(:ci_pipeline, project: project3) }
- let!(:build1_project1) { pending_job }
- let!(:build2_project1) { create(:ci_build, pipeline: pipeline) }
- let!(:build3_project1) { create(:ci_build, pipeline: pipeline) }
- let!(:build1_project2) { create(:ci_build, pipeline: pipeline2) }
- let!(:build2_project2) { create(:ci_build, pipeline: pipeline2) }
- let!(:build1_project3) { create(:ci_build, pipeline: pipeline3) }
+ let!(:build1_project1) { pending_job }
+ let!(:build2_project1) { create(:ci_build, pipeline: pipeline) }
+ let!(:build3_project1) { create(:ci_build, pipeline: pipeline) }
+ let!(:build1_project2) { create(:ci_build, pipeline: pipeline2) }
+ let!(:build2_project2) { create(:ci_build, pipeline: pipeline2) }
+ let!(:build1_project3) { create(:ci_build, pipeline: pipeline3) }
- # these shouldn't influence the scheduling
- let!(:unrelated_group) { create(:group) }
- let!(:unrelated_project) { create(:project, group_runners_enabled: true, group: unrelated_group) }
- let!(:unrelated_pipeline) { create(:ci_pipeline, project: unrelated_project) }
- let!(:build1_unrelated_project) { create(:ci_build, pipeline: unrelated_pipeline) }
- let!(:unrelated_group_runner) { create(:ci_runner, :group, groups: [unrelated_group]) }
+ # these shouldn't influence the scheduling
+ let!(:unrelated_group) { create(:group) }
+ let!(:unrelated_project) { create(:project, group_runners_enabled: true, group: unrelated_group) }
+ let!(:unrelated_pipeline) { create(:ci_pipeline, project: unrelated_project) }
+ let!(:build1_unrelated_project) { create(:ci_build, pipeline: unrelated_pipeline) }
+ let!(:unrelated_group_runner) { create(:ci_runner, :group, groups: [unrelated_group]) }
- it 'does not consider builds from other group runners' do
- expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 6
- execute(group_runner)
+ it 'does not consider builds from other group runners' do
+ expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 6
+ execute(group_runner)
- expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 5
- execute(group_runner)
+ expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 5
+ execute(group_runner)
- expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 4
- execute(group_runner)
+ expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 4
+ execute(group_runner)
- expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 3
- execute(group_runner)
+ expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 3
+ execute(group_runner)
- expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 2
- execute(group_runner)
+ expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 2
+ execute(group_runner)
- expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 1
- execute(group_runner)
+ expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 1
+ execute(group_runner)
- expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 0
- expect(execute(group_runner)).to be_nil
+ expect(described_class.new(group_runner).send(:builds_for_group_runner).count).to eq 0
+ expect(execute(group_runner)).to be_nil
+ end
end
- end
- context 'group runner' do
- let(:build) { execute(group_runner) }
+ context 'group runner' do
+ let(:build) { execute(group_runner) }
- it { expect(build).to be_kind_of(Build) }
- it { expect(build).to be_valid }
- it { expect(build).to be_running }
- it { expect(build.runner).to eq(group_runner) }
+ it { expect(build).to be_kind_of(Build) }
+ it { expect(build).to be_valid }
+ it { expect(build).to be_running }
+ it { expect(build.runner).to eq(group_runner) }
+ end
end
- end
- context 'disallow group runners' do
- before do
- project.update!(group_runners_enabled: false)
- end
+ context 'disallow group runners' do
+ before do
+ project.update!(group_runners_enabled: false)
+ end
- context 'group runner' do
- let(:build) { execute(group_runner) }
+ context 'group runner' do
+ let(:build) { execute(group_runner) }
- it { expect(build).to be_nil }
+ it { expect(build).to be_nil }
+ end
end
- end
- context 'when first build is stalled' do
- before do
- allow_any_instance_of(Ci::RegisterJobService).to receive(:assign_runner!).and_call_original
- allow_any_instance_of(Ci::RegisterJobService).to receive(:assign_runner!)
- .with(pending_job, anything).and_raise(ActiveRecord::StaleObjectError)
- end
+ context 'when first build is stalled' do
+ before do
+ allow_any_instance_of(Ci::RegisterJobService).to receive(:assign_runner!).and_call_original
+ allow_any_instance_of(Ci::RegisterJobService).to receive(:assign_runner!)
+ .with(pending_job, anything).and_raise(ActiveRecord::StaleObjectError)
+ end
- subject { described_class.new(specific_runner).execute }
+ subject { described_class.new(specific_runner).execute }
- context 'with multiple builds are in queue' do
- let!(:other_build) { create :ci_build, pipeline: pipeline }
+ context 'with multiple builds are in queue' do
+ let!(:other_build) { create :ci_build, pipeline: pipeline }
- before do
- allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner)
- .and_return(Ci::Build.where(id: [pending_job, other_build]))
- end
+ before do
+ allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner)
+ .and_return(Ci::Build.where(id: [pending_job, other_build]))
+ end
- it "receives second build from the queue" do
- expect(subject).to be_valid
- expect(subject.build).to eq(other_build)
+ it "receives second build from the queue" do
+ expect(subject).to be_valid
+ expect(subject.build).to eq(other_build)
+ end
end
- end
- context 'when single build is in queue' do
- before do
- allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner)
- .and_return(Ci::Build.where(id: pending_job))
- end
+ context 'when single build is in queue' do
+ before do
+ allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner)
+ .and_return(Ci::Build.where(id: pending_job))
+ end
- it "does not receive any valid result" do
- expect(subject).not_to be_valid
+ it "does not receive any valid result" do
+ expect(subject).not_to be_valid
+ end
end
- end
- context 'when there is no build in queue' do
- before do
- allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner)
- .and_return(Ci::Build.none)
- end
+ context 'when there is no build in queue' do
+ before do
+ allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_project_runner)
+ .and_return(Ci::Build.none)
+ end
- it "does not receive builds but result is valid" do
- expect(subject).to be_valid
- expect(subject.build).to be_nil
+ it "does not receive builds but result is valid" do
+ expect(subject).to be_valid
+ expect(subject.build).to be_nil
+ end
end
end
- end
- context 'when access_level of runner is not_protected' do
- let!(:specific_runner) { create(:ci_runner, :project, projects: [project]) }
+ context 'when access_level of runner is not_protected' do
+ let!(:specific_runner) { create(:ci_runner, :project, projects: [project]) }
- context 'when a job is protected' do
- let!(:pending_job) { create(:ci_build, :protected, pipeline: pipeline) }
+ context 'when a job is protected' do
+ let!(:pending_job) { create(:ci_build, :protected, pipeline: pipeline) }
- it 'picks the job' do
- expect(execute(specific_runner)).to eq(pending_job)
+ it 'picks the job' do
+ expect(execute(specific_runner)).to eq(pending_job)
+ end
end
- end
- context 'when a job is unprotected' do
- let!(:pending_job) { create(:ci_build, pipeline: pipeline) }
+ context 'when a job is unprotected' do
+ let!(:pending_job) { create(:ci_build, pipeline: pipeline) }
- it 'picks the job' do
- expect(execute(specific_runner)).to eq(pending_job)
+ it 'picks the job' do
+ expect(execute(specific_runner)).to eq(pending_job)
+ end
end
- end
- context 'when protected attribute of a job is nil' do
- let!(:pending_job) { create(:ci_build, pipeline: pipeline) }
+ context 'when protected attribute of a job is nil' do
+ let!(:pending_job) { create(:ci_build, pipeline: pipeline) }
- before do
- pending_job.update_attribute(:protected, nil)
- end
+ before do
+ pending_job.update_attribute(:protected, nil)
+ end
- it 'picks the job' do
- expect(execute(specific_runner)).to eq(pending_job)
+ it 'picks the job' do
+ expect(execute(specific_runner)).to eq(pending_job)
+ end
end
end
- end
- context 'when access_level of runner is ref_protected' do
- let!(:specific_runner) { create(:ci_runner, :project, :ref_protected, projects: [project]) }
+ context 'when access_level of runner is ref_protected' do
+ let!(:specific_runner) { create(:ci_runner, :project, :ref_protected, projects: [project]) }
- context 'when a job is protected' do
- let!(:pending_job) { create(:ci_build, :protected, pipeline: pipeline) }
+ context 'when a job is protected' do
+ let!(:pending_job) { create(:ci_build, :protected, pipeline: pipeline) }
- it 'picks the job' do
- expect(execute(specific_runner)).to eq(pending_job)
+ it 'picks the job' do
+ expect(execute(specific_runner)).to eq(pending_job)
+ end
end
- end
- context 'when a job is unprotected' do
- let!(:pending_job) { create(:ci_build, pipeline: pipeline) }
+ context 'when a job is unprotected' do
+ let!(:pending_job) { create(:ci_build, pipeline: pipeline) }
- it 'does not pick the job' do
- expect(execute(specific_runner)).to be_nil
+ it 'does not pick the job' do
+ expect(execute(specific_runner)).to be_nil
+ end
end
- end
- context 'when protected attribute of a job is nil' do
- let!(:pending_job) { create(:ci_build, pipeline: pipeline) }
+ context 'when protected attribute of a job is nil' do
+ let!(:pending_job) { create(:ci_build, pipeline: pipeline) }
- before do
- pending_job.update_attribute(:protected, nil)
- end
+ before do
+ pending_job.update_attribute(:protected, nil)
+ end
- it 'does not pick the job' do
- expect(execute(specific_runner)).to be_nil
+ it 'does not pick the job' do
+ expect(execute(specific_runner)).to be_nil
+ end
end
end
- end
- context 'runner feature set is verified' do
- let(:options) { { artifacts: { reports: { junit: "junit.xml" } } } }
- let!(:pending_job) { create(:ci_build, :pending, pipeline: pipeline, options: options) }
+ context 'runner feature set is verified' do
+ let(:options) { { artifacts: { reports: { junit: "junit.xml" } } } }
+ let!(:pending_job) { create(:ci_build, :pending, pipeline: pipeline, options: options) }
- subject { execute(specific_runner, params) }
+ subject { execute(specific_runner, params) }
- context 'when feature is missing by runner' do
- let(:params) { {} }
+ context 'when feature is missing by runner' do
+ let(:params) { {} }
- it 'does not pick the build and drops the build' do
- expect(subject).to be_nil
- expect(pending_job.reload).to be_failed
- expect(pending_job).to be_runner_unsupported
+ it 'does not pick the build and drops the build' do
+ expect(subject).to be_nil
+ expect(pending_job.reload).to be_failed
+ expect(pending_job).to be_runner_unsupported
+ end
end
- end
- context 'when feature is supported by runner' do
- let(:params) do
- { info: { features: { upload_multiple_artifacts: true } } }
- end
+ context 'when feature is supported by runner' do
+ let(:params) do
+ { info: { features: { upload_multiple_artifacts: true } } }
+ end
- it 'does pick job' do
- expect(subject).not_to be_nil
+ it 'does pick job' do
+ expect(subject).not_to be_nil
+ end
end
end
- end
- context 'when "dependencies" keyword is specified' do
- shared_examples 'not pick' do
- it 'does not pick the build and drops the build' do
- expect(subject).to be_nil
- expect(pending_job.reload).to be_failed
- expect(pending_job).to be_missing_dependency_failure
+ context 'when "dependencies" keyword is specified' do
+ shared_examples 'not pick' do
+ it 'does not pick the build and drops the build' do
+ expect(subject).to be_nil
+ expect(pending_job.reload).to be_failed
+ expect(pending_job).to be_missing_dependency_failure
+ end
end
- end
- shared_examples 'validation is active' do
- context 'when depended job has not been completed yet' do
- let!(:pre_stage_job) { create(:ci_build, :manual, pipeline: pipeline, name: 'test', stage_idx: 0) }
+ shared_examples 'validation is active' do
+ context 'when depended job has not been completed yet' do
+ let!(:pre_stage_job) { create(:ci_build, :manual, pipeline: pipeline, name: 'test', stage_idx: 0) }
- it { expect(subject).to eq(pending_job) }
- end
+ it { expect(subject).to eq(pending_job) }
+ end
- context 'when artifacts of depended job has been expired' do
- let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) }
+ context 'when artifacts of depended job has been expired' do
+ let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) }
- it_behaves_like 'not pick'
- end
+ it_behaves_like 'not pick'
+ end
- context 'when artifacts of depended job has been erased' do
- let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago) }
+ context 'when artifacts of depended job has been erased' do
+ let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago) }
- before do
- pre_stage_job.erase
+ before do
+ pre_stage_job.erase
+ end
+
+ it_behaves_like 'not pick'
end
- it_behaves_like 'not pick'
+ context 'when job object is staled' do
+ let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) }
+
+ before do
+ allow_any_instance_of(Ci::Build).to receive(:drop!)
+ .and_raise(ActiveRecord::StaleObjectError.new(pending_job, :drop!))
+ end
+
+ it 'does not drop nor pick' do
+ expect(subject).to be_nil
+ end
+ end
end
- context 'when job object is staled' do
- let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) }
+ shared_examples 'validation is not active' do
+ context 'when depended job has not been completed yet' do
+ let!(:pre_stage_job) { create(:ci_build, :manual, pipeline: pipeline, name: 'test', stage_idx: 0) }
- before do
- allow_any_instance_of(Ci::Build).to receive(:drop!)
- .and_raise(ActiveRecord::StaleObjectError.new(pending_job, :drop!))
+ it { expect(subject).to eq(pending_job) }
end
- it 'does not drop nor pick' do
- expect(subject).to be_nil
+ context 'when artifacts of depended job has been expired' do
+ let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) }
+
+ it { expect(subject).to eq(pending_job) }
end
- end
- end
- shared_examples 'validation is not active' do
- context 'when depended job has not been completed yet' do
- let!(:pre_stage_job) { create(:ci_build, :manual, pipeline: pipeline, name: 'test', stage_idx: 0) }
+ context 'when artifacts of depended job has been erased' do
+ let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago) }
- it { expect(subject).to eq(pending_job) }
+ before do
+ pre_stage_job.erase
+ end
+
+ it { expect(subject).to eq(pending_job) }
+ end
end
- context 'when artifacts of depended job has been expired' do
- let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) }
+ before do
+ stub_feature_flags(ci_validate_build_dependencies_override: false)
+ end
+
+ let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0) }
- it { expect(subject).to eq(pending_job) }
+ let!(:pending_job) do
+ create(:ci_build, :pending,
+ pipeline: pipeline, stage_idx: 1,
+ options: { script: ["bash"], dependencies: ['test'] })
end
- context 'when artifacts of depended job has been erased' do
- let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago) }
+ subject { execute(specific_runner) }
+ context 'when validates for dependencies is enabled' do
before do
- pre_stage_job.erase
+ stub_feature_flags(ci_validate_build_dependencies_override: false)
end
- it { expect(subject).to eq(pending_job) }
+ it_behaves_like 'validation is active'
+
+ context 'when the main feature flag is enabled for a specific project' do
+ before do
+ stub_feature_flags(ci_validate_build_dependencies: pipeline.project)
+ end
+
+ it_behaves_like 'validation is active'
+ end
+
+ context 'when the main feature flag is enabled for a different project' do
+ before do
+ stub_feature_flags(ci_validate_build_dependencies: create(:project))
+ end
+
+ it_behaves_like 'validation is not active'
+ end
end
- end
- before do
- stub_feature_flags(ci_validate_build_dependencies_override: false)
+ context 'when validates for dependencies is disabled' do
+ before do
+ stub_feature_flags(ci_validate_build_dependencies_override: true)
+ end
+
+ it_behaves_like 'validation is not active'
+ end
end
- let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0) }
+ context 'when build is degenerated' do
+ let!(:pending_job) { create(:ci_build, :pending, :degenerated, pipeline: pipeline) }
+
+ subject { execute(specific_runner, {}) }
+
+ it 'does not pick the build and drops the build' do
+ expect(subject).to be_nil
- let!(:pending_job) do
- create(:ci_build, :pending,
- pipeline: pipeline, stage_idx: 1,
- options: { script: ["bash"], dependencies: ['test'] })
+ pending_job.reload
+ expect(pending_job).to be_failed
+ expect(pending_job).to be_archived_failure
+ end
end
- subject { execute(specific_runner) }
+ context 'when build has data integrity problem' do
+ let!(:pending_job) do
+ create(:ci_build, :pending, pipeline: pipeline)
+ end
- context 'when validates for dependencies is enabled' do
before do
- stub_feature_flags(ci_validate_build_dependencies_override: false)
+ pending_job.update_columns(options: "string")
end
- it_behaves_like 'validation is active'
+ subject { execute(specific_runner, {}) }
- context 'when the main feature flag is enabled for a specific project' do
- before do
- stub_feature_flags(ci_validate_build_dependencies: pipeline.project)
- end
+ it 'does drop the build and logs both failures' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(anything, a_hash_including(build_id: pending_job.id))
+ .twice
+ .and_call_original
- it_behaves_like 'validation is active'
- end
-
- context 'when the main feature flag is enabled for a different project' do
- before do
- stub_feature_flags(ci_validate_build_dependencies: create(:project))
- end
+ expect(subject).to be_nil
- it_behaves_like 'validation is not active'
+ pending_job.reload
+ expect(pending_job).to be_failed
+ expect(pending_job).to be_data_integrity_failure
end
end
- context 'when validates for dependencies is disabled' do
+ context 'when build fails to be run!' do
+ let!(:pending_job) do
+ create(:ci_build, :pending, pipeline: pipeline)
+ end
+
before do
- stub_feature_flags(ci_validate_build_dependencies_override: true)
+ expect_any_instance_of(Ci::Build).to receive(:run!)
+ .and_raise(RuntimeError, 'scheduler error')
end
- it_behaves_like 'validation is not active'
+ subject { execute(specific_runner, {}) }
+
+ it 'does drop the build and logs failure' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(anything, a_hash_including(build_id: pending_job.id))
+ .once
+ .and_call_original
+
+ expect(subject).to be_nil
+
+ pending_job.reload
+ expect(pending_job).to be_failed
+ expect(pending_job).to be_scheduler_failure
+ end
end
- end
- context 'when build is degenerated' do
- let!(:pending_job) { create(:ci_build, :pending, :degenerated, pipeline: pipeline) }
+ context 'when an exception is raised during a persistent ref creation' do
+ before do
+ allow_any_instance_of(Ci::PersistentRef).to receive(:exist?) { false }
+ allow_any_instance_of(Ci::PersistentRef).to receive(:create_ref) { raise ArgumentError }
+ end
- subject { execute(specific_runner, {}) }
+ subject { execute(specific_runner, {}) }
- it 'does not pick the build and drops the build' do
- expect(subject).to be_nil
+ it 'picks the build' do
+ expect(subject).to eq(pending_job)
- pending_job.reload
- expect(pending_job).to be_failed
- expect(pending_job).to be_archived_failure
+ pending_job.reload
+ expect(pending_job).to be_running
+ end
end
- end
- context 'when build has data integrity problem' do
- let!(:pending_job) do
- create(:ci_build, :pending, pipeline: pipeline)
- end
+ context 'when only some builds can be matched by runner' do
+ let!(:specific_runner) { create(:ci_runner, :project, projects: [project], tag_list: %w[matching]) }
+ let!(:pending_job) { create(:ci_build, pipeline: pipeline, tag_list: %w[matching]) }
- before do
- pending_job.update_columns(options: "string")
+ before do
+ # create additional matching and non-matching jobs
+ create_list(:ci_build, 2, pipeline: pipeline, tag_list: %w[matching])
+ create(:ci_build, pipeline: pipeline, tag_list: %w[non-matching])
+ end
+
+ it "observes queue size of only matching jobs" do
+ # pending_job + 2 x matching ones
+ expect(Gitlab::Ci::Queue::Metrics.queue_size_total).to receive(:observe).with({}, 3)
+
+ expect(execute(specific_runner)).to eq(pending_job)
+ end
end
- subject { execute(specific_runner, {}) }
+ context 'when ci_register_job_temporary_lock is enabled' do
+ before do
+ stub_feature_flags(ci_register_job_temporary_lock: true)
- it 'does drop the build and logs both failures' do
- expect(Gitlab::ErrorTracking).to receive(:track_exception)
- .with(anything, a_hash_including(build_id: pending_job.id))
- .twice
- .and_call_original
+ allow(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment)
+ end
- expect(subject).to be_nil
+ context 'when a build is temporarily locked' do
+ let(:service) { described_class.new(specific_runner) }
- pending_job.reload
- expect(pending_job).to be_failed
- expect(pending_job).to be_data_integrity_failure
- end
- end
+ before do
+ service.send(:acquire_temporary_lock, pending_job.id)
+ end
- context 'when build fails to be run!' do
- let!(:pending_job) do
- create(:ci_build, :pending, pipeline: pipeline)
- end
+ it 'skips this build and marks queue as invalid' do
+ expect(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment)
+ .with(operation: :queue_iteration)
+ expect(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment)
+ .with(operation: :build_temporary_locked)
- before do
- expect_any_instance_of(Ci::Build).to receive(:run!)
- .and_raise(RuntimeError, 'scheduler error')
- end
+ expect(service.execute).not_to be_valid
+ end
- subject { execute(specific_runner, {}) }
+ context 'when there is another build in queue' do
+ let!(:next_pending_job) { create(:ci_build, pipeline: pipeline) }
- it 'does drop the build and logs failure' do
- expect(Gitlab::ErrorTracking).to receive(:track_exception)
- .with(anything, a_hash_including(build_id: pending_job.id))
- .once
- .and_call_original
+ it 'skips this build and picks another build' do
+ expect(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment)
+ .with(operation: :queue_iteration).twice
+ expect(Gitlab::Ci::Queue::Metrics.queue_operations_total).to receive(:increment)
+ .with(operation: :build_temporary_locked)
- expect(subject).to be_nil
+ result = service.execute
- pending_job.reload
- expect(pending_job).to be_failed
- expect(pending_job).to be_scheduler_failure
+ expect(result.build).to eq(next_pending_job)
+ expect(result).to be_valid
+ end
+ end
+ end
end
end
- context 'when an exception is raised during a persistent ref creation' do
+ context 'when ci_register_job_service_one_by_one is enabled' do
before do
- allow_any_instance_of(Ci::PersistentRef).to receive(:exist?) { false }
- allow_any_instance_of(Ci::PersistentRef).to receive(:create_ref) { raise ArgumentError }
+ stub_feature_flags(ci_register_job_service_one_by_one: true)
end
- subject { execute(specific_runner, {}) }
+ it 'picks builds one-by-one' do
+ expect(Ci::Build).to receive(:find).with(pending_job.id).and_call_original
- it 'picks the build' do
- expect(subject).to eq(pending_job)
+ expect(execute(specific_runner)).to eq(pending_job)
+ end
+
+ include_examples 'handles runner assignment'
+ end
- pending_job.reload
- expect(pending_job).to be_running
+ context 'when ci_register_job_service_one_by_one is disabled' do
+ before do
+ stub_feature_flags(ci_register_job_service_one_by_one: false)
end
+
+ include_examples 'handles runner assignment'
end
end
@@ -590,22 +673,14 @@ module Ci
before do
allow(Time).to receive(:now).and_return(current_time)
-
- # Stub defaults for any metrics other than the ones we're testing
- allow(Gitlab::Metrics).to receive(:counter)
- .with(any_args)
- .and_return(Gitlab::Metrics::NullMetric.instance)
- allow(Gitlab::Metrics).to receive(:histogram)
- .with(any_args)
- .and_return(Gitlab::Metrics::NullMetric.instance)
-
# Stub tested metrics
- allow(Gitlab::Metrics).to receive(:counter)
- .with(:job_register_attempts_total, anything)
- .and_return(attempt_counter)
- allow(Gitlab::Metrics).to receive(:histogram)
- .with(:job_queue_duration_seconds, anything, anything, anything)
- .and_return(job_queue_duration_seconds)
+ allow(Gitlab::Ci::Queue::Metrics)
+ .to receive(:attempt_counter)
+ .and_return(attempt_counter)
+
+ allow(Gitlab::Ci::Queue::Metrics)
+ .to receive(:job_queue_duration_seconds)
+ .and_return(job_queue_duration_seconds)
project.update!(shared_runners_enabled: true)
pending_job.update!(created_at: current_time - 3600, queued_at: current_time - 1800)
@@ -655,7 +730,7 @@ module Ci
context 'when shared runner is used' do
let(:runner) { create(:ci_runner, :instance, tag_list: %w(tag1 tag2)) }
let(:expected_shared_runner) { true }
- let(:expected_shard) { Ci::RegisterJobService::DEFAULT_METRICS_SHARD }
+ let(:expected_shard) { ::Gitlab::Ci::Queue::Metrics::DEFAULT_METRICS_SHARD }
let(:expected_jobs_running_for_project_first_job) { 0 }
let(:expected_jobs_running_for_project_third_job) { 2 }
@@ -694,7 +769,7 @@ module Ci
context 'when specific runner is used' do
let(:runner) { create(:ci_runner, :project, projects: [project], tag_list: %w(tag1 metrics_shard::shard_tag tag2)) }
let(:expected_shared_runner) { false }
- let(:expected_shard) { Ci::RegisterJobService::DEFAULT_METRICS_SHARD }
+ let(:expected_shard) { ::Gitlab::Ci::Queue::Metrics::DEFAULT_METRICS_SHARD }
let(:expected_jobs_running_for_project_first_job) { '+Inf' }
let(:expected_jobs_running_for_project_third_job) { '+Inf' }
@@ -715,6 +790,46 @@ module Ci
end
end
+ context 'when max queue depth is reached' do
+ let!(:pending_job) { create(:ci_build, :pending, :degenerated, pipeline: pipeline) }
+ let!(:pending_job_2) { create(:ci_build, :pending, :degenerated, pipeline: pipeline) }
+ let!(:pending_job_3) { create(:ci_build, :pending, pipeline: pipeline) }
+
+ before do
+ stub_const("#{described_class}::MAX_QUEUE_DEPTH", 2)
+ end
+
+ context 'when feature is enabled' do
+ before do
+ stub_feature_flags(gitlab_ci_builds_queue_limit: true)
+ end
+
+ it 'returns 409 conflict' do
+ expect(Ci::Build.pending.unstarted.count).to eq 3
+
+ result = described_class.new(specific_runner).execute
+
+ expect(result).not_to be_valid
+ expect(result.build).to be_nil
+ end
+ end
+
+ context 'when feature is disabled' do
+ before do
+ stub_feature_flags(gitlab_ci_builds_queue_limit: false)
+ end
+
+ it 'returns a valid result' do
+ expect(Ci::Build.pending.unstarted.count).to eq 3
+
+ result = described_class.new(specific_runner).execute
+
+ expect(result).to be_valid
+ expect(result.build).to eq pending_job_3
+ end
+ end
+ end
+
def execute(runner, params = {})
described_class.new(runner).execute(params).build
end
diff --git a/spec/services/ci/update_build_queue_service_spec.rb b/spec/services/ci/update_build_queue_service_spec.rb
index ebccfdc5140..2d9f80a249d 100644
--- a/spec/services/ci/update_build_queue_service_spec.rb
+++ b/spec/services/ci/update_build_queue_service_spec.rb
@@ -26,6 +26,25 @@ RSpec.describe Ci::UpdateBuildQueueService do
end
it_behaves_like 'refreshes runner'
+
+ it 'avoids running redundant queries' do
+ expect(Ci::Runner).not_to receive(:owned_or_instance_wide)
+
+ subject.execute(build)
+ end
+
+ context 'when feature flag ci_reduce_queries_when_ticking_runner_queue is disabled' do
+ before do
+ stub_feature_flags(ci_reduce_queries_when_ticking_runner_queue: false)
+ stub_feature_flags(ci_runners_short_circuit_assignable_for: false)
+ end
+
+ it 'runs redundant queries using `owned_or_instance_wide` scope' do
+ expect(Ci::Runner).to receive(:owned_or_instance_wide).and_call_original
+
+ subject.execute(build)
+ end
+ end
end
end
@@ -97,4 +116,43 @@ RSpec.describe Ci::UpdateBuildQueueService do
it_behaves_like 'does not refresh runner'
end
end
+
+ context 'avoids N+1 queries', :request_store do
+ let!(:build) { create(:ci_build, pipeline: pipeline, tag_list: %w[a b]) }
+ let!(:project_runner) { create(:ci_runner, :project, :online, projects: [project], tag_list: %w[a b c]) }
+
+ context 'when ci_preload_runner_tags and ci_reduce_queries_when_ticking_runner_queue are enabled' do
+ before do
+ stub_feature_flags(
+ ci_reduce_queries_when_ticking_runner_queue: true,
+ ci_preload_runner_tags: true
+ )
+ end
+
+ it 'does execute the same amount of queries regardless of number of runners' do
+ control_count = ActiveRecord::QueryRecorder.new { subject.execute(build) }.count
+
+ create_list(:ci_runner, 10, :project, :online, projects: [project], tag_list: %w[b c d])
+
+ expect { subject.execute(build) }.not_to exceed_all_query_limit(control_count)
+ end
+ end
+
+ context 'when ci_preload_runner_tags and ci_reduce_queries_when_ticking_runner_queue are disabled' do
+ before do
+ stub_feature_flags(
+ ci_reduce_queries_when_ticking_runner_queue: false,
+ ci_preload_runner_tags: false
+ )
+ end
+
+ it 'does execute more queries for more runners' do
+ control_count = ActiveRecord::QueryRecorder.new { subject.execute(build) }.count
+
+ create_list(:ci_runner, 10, :project, :online, projects: [project], tag_list: %w[b c d])
+
+ expect { subject.execute(build) }.to exceed_all_query_limit(control_count)
+ end
+ end
+ end
end
diff --git a/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb b/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb
index 90956e7b4ea..98963f57341 100644
--- a/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb
+++ b/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb
@@ -39,6 +39,8 @@ RSpec.describe Clusters::Kubernetes::CreateOrUpdateNamespaceService, '#execute'
stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_KNATIVE_SERVING_ROLE_BINDING_NAME, namespace: namespace)
stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_NAME, namespace: namespace)
stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME, namespace: namespace)
+ stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_CILIUM_ROLE_NAME, namespace: namespace)
+ stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_CILIUM_ROLE_BINDING_NAME, namespace: namespace)
stub_kubeclient_get_secret(
api_url,
diff --git a/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb b/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb
index a4f018aec0c..11045dfe950 100644
--- a/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb
+++ b/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb
@@ -147,6 +147,8 @@ RSpec.describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do
stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_KNATIVE_SERVING_ROLE_BINDING_NAME, namespace: namespace)
stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_NAME, namespace: namespace)
stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME, namespace: namespace)
+ stub_kubeclient_put_role(api_url, Clusters::Kubernetes::GITLAB_CILIUM_ROLE_NAME, namespace: namespace)
+ stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_CILIUM_ROLE_BINDING_NAME, namespace: namespace)
end
it 'creates a namespace object' do
@@ -243,6 +245,47 @@ RSpec.describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do
)
)
end
+
+ it 'creates a role granting cilium permissions to the service account' do
+ subject
+
+ expect(WebMock).to have_requested(:put, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/roles/#{Clusters::Kubernetes::GITLAB_CILIUM_ROLE_NAME}").with(
+ body: hash_including(
+ metadata: {
+ name: Clusters::Kubernetes::GITLAB_CILIUM_ROLE_NAME,
+ namespace: namespace
+ },
+ rules: [{
+ apiGroups: %w(cilium.io),
+ resources: %w(ciliumnetworkpolicies),
+ verbs: %w(get list create update patch)
+ }]
+ )
+ )
+ end
+
+ it 'creates a role binding granting cilium permissions to the service account' do
+ subject
+
+ expect(WebMock).to have_requested(:put, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/rolebindings/#{Clusters::Kubernetes::GITLAB_CILIUM_ROLE_BINDING_NAME}").with(
+ body: hash_including(
+ metadata: {
+ name: Clusters::Kubernetes::GITLAB_CILIUM_ROLE_BINDING_NAME,
+ namespace: namespace
+ },
+ roleRef: {
+ apiGroup: 'rbac.authorization.k8s.io',
+ kind: 'Role',
+ name: Clusters::Kubernetes::GITLAB_CILIUM_ROLE_NAME
+ },
+ subjects: [{
+ kind: 'ServiceAccount',
+ name: service_account_name,
+ namespace: namespace
+ }]
+ )
+ )
+ end
end
end
end
diff --git a/spec/services/dependency_proxy/find_or_create_manifest_service_spec.rb b/spec/services/dependency_proxy/find_or_create_manifest_service_spec.rb
index c375e5a2fa3..40a2f954786 100644
--- a/spec/services/dependency_proxy/find_or_create_manifest_service_spec.rb
+++ b/spec/services/dependency_proxy/find_or_create_manifest_service_spec.rb
@@ -10,7 +10,12 @@ RSpec.describe DependencyProxy::FindOrCreateManifestService do
let(:manifest) { dependency_proxy_manifest.file.read }
let(:group) { dependency_proxy_manifest.group }
let(:token) { Digest::SHA256.hexdigest('123') }
- let(:headers) { { 'docker-content-digest' => dependency_proxy_manifest.digest } }
+ let(:headers) do
+ {
+ 'docker-content-digest' => dependency_proxy_manifest.digest,
+ 'content-type' => dependency_proxy_manifest.content_type
+ }
+ end
describe '#execute' do
subject { described_class.new(group, image, tag, token).execute }
@@ -18,22 +23,37 @@ RSpec.describe DependencyProxy::FindOrCreateManifestService do
context 'when no manifest exists' do
let_it_be(:image) { 'new-image' }
- before do
- stub_manifest_head(image, tag, digest: dependency_proxy_manifest.digest)
- stub_manifest_download(image, tag, headers: headers)
+ shared_examples 'downloading the manifest' do
+ it 'downloads manifest from remote registry if there is no cached one', :aggregate_failures do
+ expect { subject }.to change { group.dependency_proxy_manifests.count }.by(1)
+ expect(subject[:status]).to eq(:success)
+ expect(subject[:manifest]).to be_a(DependencyProxy::Manifest)
+ expect(subject[:manifest]).to be_persisted
+ end
end
- it 'downloads manifest from remote registry if there is no cached one', :aggregate_failures do
- expect { subject }.to change { group.dependency_proxy_manifests.count }.by(1)
- expect(subject[:status]).to eq(:success)
- expect(subject[:manifest]).to be_a(DependencyProxy::Manifest)
- expect(subject[:manifest]).to be_persisted
+ context 'successful head request' do
+ before do
+ stub_manifest_head(image, tag, headers: headers)
+ stub_manifest_download(image, tag, headers: headers)
+ end
+
+ it_behaves_like 'downloading the manifest'
+ end
+
+ context 'failed head request' do
+ before do
+ stub_manifest_head(image, tag, status: :error)
+ stub_manifest_download(image, tag, headers: headers)
+ end
+
+ it_behaves_like 'downloading the manifest'
end
end
context 'when manifest exists' do
before do
- stub_manifest_head(image, tag, digest: dependency_proxy_manifest.digest)
+ stub_manifest_head(image, tag, headers: headers)
end
shared_examples 'using the cached manifest' do
@@ -48,15 +68,17 @@ RSpec.describe DependencyProxy::FindOrCreateManifestService do
context 'when digest is stale' do
let(:digest) { 'new-digest' }
+ let(:content_type) { 'new-content-type' }
before do
- stub_manifest_head(image, tag, digest: digest)
- stub_manifest_download(image, tag, headers: { 'docker-content-digest' => digest })
+ stub_manifest_head(image, tag, headers: { 'docker-content-digest' => digest, 'content-type' => content_type })
+ stub_manifest_download(image, tag, headers: { 'docker-content-digest' => digest, 'content-type' => content_type })
end
it 'downloads the new manifest and updates the existing record', :aggregate_failures do
expect(subject[:status]).to eq(:success)
expect(subject[:manifest]).to eq(dependency_proxy_manifest)
+ expect(subject[:manifest].content_type).to eq(content_type)
expect(subject[:manifest].digest).to eq(digest)
end
end
diff --git a/spec/services/dependency_proxy/head_manifest_service_spec.rb b/spec/services/dependency_proxy/head_manifest_service_spec.rb
index 7c7ebe4d181..9c1e4d650f8 100644
--- a/spec/services/dependency_proxy/head_manifest_service_spec.rb
+++ b/spec/services/dependency_proxy/head_manifest_service_spec.rb
@@ -8,12 +8,19 @@ RSpec.describe DependencyProxy::HeadManifestService do
let(:tag) { 'latest' }
let(:token) { Digest::SHA256.hexdigest('123') }
let(:digest) { '12345' }
+ let(:content_type) { 'foo' }
+ let(:headers) do
+ {
+ 'docker-content-digest' => digest,
+ 'content-type' => content_type
+ }
+ end
subject { described_class.new(image, tag, token).execute }
context 'remote request is successful' do
before do
- stub_manifest_head(image, tag, digest: digest)
+ stub_manifest_head(image, tag, headers: headers)
end
it { expect(subject[:status]).to eq(:success) }
diff --git a/spec/services/dependency_proxy/pull_manifest_service_spec.rb b/spec/services/dependency_proxy/pull_manifest_service_spec.rb
index b760839d1fb..b3053174cc0 100644
--- a/spec/services/dependency_proxy/pull_manifest_service_spec.rb
+++ b/spec/services/dependency_proxy/pull_manifest_service_spec.rb
@@ -9,7 +9,10 @@ RSpec.describe DependencyProxy::PullManifestService do
let(:token) { Digest::SHA256.hexdigest('123') }
let(:manifest) { { foo: 'bar' }.to_json }
let(:digest) { '12345' }
- let(:headers) { { 'docker-content-digest' => digest } }
+ let(:content_type) { 'foo' }
+ let(:headers) do
+ { 'docker-content-digest' => digest, 'content-type' => content_type }
+ end
subject { described_class.new(image, tag, token).execute_with_manifest(&method(:check_response)) }
@@ -25,6 +28,7 @@ RSpec.describe DependencyProxy::PullManifestService do
expect(response[:status]).to eq(:success)
expect(response[:file].read).to eq(manifest)
expect(response[:digest]).to eq(digest)
+ expect(response[:content_type]).to eq(content_type)
end
subject
diff --git a/spec/services/deployments/update_environment_service_spec.rb b/spec/services/deployments/update_environment_service_spec.rb
index 92488c62315..372805cc0fd 100644
--- a/spec/services/deployments/update_environment_service_spec.rb
+++ b/spec/services/deployments/update_environment_service_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe Deployments::UpdateEnvironmentService do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
- let(:options) { { name: 'production' } }
+ let(:options) { { name: environment_name } }
let(:pipeline) do
create(
:ci_pipeline,
@@ -20,13 +20,14 @@ RSpec.describe Deployments::UpdateEnvironmentService do
pipeline: pipeline,
ref: 'master',
tag: false,
- environment: 'production',
+ environment: environment_name,
options: { environment: options },
project: project)
end
let(:deployment) { job.deployment }
let(:environment) { deployment.environment }
+ let(:environment_name) { 'production' }
subject(:service) { described_class.new(deployment) }
@@ -131,6 +132,56 @@ RSpec.describe Deployments::UpdateEnvironmentService do
end
end
end
+
+ context 'when deployment tier is specified' do
+ let(:environment_name) { 'customer-portal' }
+ let(:options) { { name: environment_name, deployment_tier: 'production' } }
+
+ context 'when tier has already been set' do
+ before do
+ environment.update_column(:tier, Environment.tiers[:other])
+ end
+
+ it 'overwrites the guessed tier by the specified deployment tier' do
+ expect { subject.execute }
+ .to change { environment.reset.tier }.from('other').to('production')
+ end
+ end
+
+ context 'when tier has not been set' do
+ before do
+ environment.update_column(:tier, nil)
+ end
+
+ it 'sets the specified deployment tier' do
+ expect { subject.execute }
+ .to change { environment.reset.tier }.from(nil).to('production')
+ end
+
+ context 'when deployment was created by an external CD system' do
+ before do
+ deployment.update_column(:deployable_id, nil)
+ end
+
+ it 'guesses the deployment tier' do
+ expect { subject.execute }
+ .to change { environment.reset.tier }.from(nil).to('other')
+ end
+ end
+ end
+ end
+
+ context 'when deployment tier is not specified' do
+ let(:environment_name) { 'customer-portal' }
+ let(:options) { { name: environment_name } }
+
+ it 'guesses the deployment tier' do
+ environment.update_column(:tier, nil)
+
+ expect { subject.execute }
+ .to change { environment.reset.tier }.from(nil).to('other')
+ end
+ end
end
describe '#expanded_environment_url' do
diff --git a/spec/services/environments/schedule_to_delete_review_apps_service_spec.rb b/spec/services/environments/schedule_to_delete_review_apps_service_spec.rb
new file mode 100644
index 00000000000..401d6203b2c
--- /dev/null
+++ b/spec/services/environments/schedule_to_delete_review_apps_service_spec.rb
@@ -0,0 +1,136 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe Environments::ScheduleToDeleteReviewAppsService do
+ include ExclusiveLeaseHelpers
+
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:project) { create(:project, :private, :repository, namespace: maintainer.namespace) }
+
+ let(:service) { described_class.new(project, current_user, before: 30.days.ago, dry_run: dry_run) }
+ let(:dry_run) { false }
+ let(:current_user) { maintainer }
+
+ before do
+ project.add_maintainer(maintainer)
+ project.add_developer(developer)
+ project.add_reporter(reporter)
+ end
+
+ describe "#execute" do
+ subject { service.execute }
+
+ shared_examples "can schedule for deletion" do
+ let!(:old_stopped_review_env) { create(:environment, :with_review_app, :stopped, created_at: 31.days.ago, project: project) }
+ let!(:new_stopped_review_env) { create(:environment, :with_review_app, :stopped, project: project) }
+ let!(:old_active_review_env) { create(:environment, :with_review_app, :available, created_at: 31.days.ago, project: project) }
+ let!(:old_stopped_other_env) { create(:environment, :stopped, created_at: 31.days.ago, project: project) }
+ let!(:new_stopped_other_env) { create(:environment, :stopped, project: project) }
+ let!(:old_active_other_env) { create(:environment, :available, created_at: 31.days.ago, project: project) }
+ let!(:already_deleting_env) { create(:environment, :with_review_app, :stopped, created_at: 31.days.ago, project: project, auto_delete_at: 1.day.from_now) }
+ let(:already_deleting_time) { already_deleting_env.reload.auto_delete_at }
+
+ context "live run" do
+ let(:dry_run) { false }
+
+ around do |example|
+ freeze_time { example.run }
+ end
+
+ it "marks the correct environment as scheduled_entries" do
+ expect(subject.success?).to be_truthy
+ expect(subject.scheduled_entries).to contain_exactly(old_stopped_review_env)
+ expect(subject.unprocessable_entries).to be_empty
+
+ old_stopped_review_env.reload
+ new_stopped_review_env.reload
+ old_active_review_env.reload
+ old_stopped_other_env.reload
+ new_stopped_other_env.reload
+ old_active_other_env.reload
+ already_deleting_env.reload
+
+ expect(old_stopped_review_env.auto_delete_at).to eq(1.week.from_now)
+ expect(new_stopped_review_env.auto_delete_at).to be_nil
+ expect(old_active_review_env.auto_delete_at).to be_nil
+ expect(old_stopped_other_env.auto_delete_at).to be_nil
+ expect(new_stopped_other_env.auto_delete_at).to be_nil
+ expect(old_active_other_env.auto_delete_at).to be_nil
+ expect(already_deleting_env.auto_delete_at).to eq(already_deleting_time)
+ end
+ end
+
+ context "dry run" do
+ let(:dry_run) { true }
+
+ it "returns the same but doesn't update the record" do
+ expect(subject.success?).to be_truthy
+ expect(subject.scheduled_entries).to contain_exactly(old_stopped_review_env)
+ expect(subject.unprocessable_entries).to be_empty
+
+ old_stopped_review_env.reload
+ new_stopped_review_env.reload
+ old_active_review_env.reload
+ old_stopped_other_env.reload
+ new_stopped_other_env.reload
+ old_active_other_env.reload
+ already_deleting_env.reload
+
+ expect(old_stopped_review_env.auto_delete_at).to be_nil
+ expect(new_stopped_review_env.auto_delete_at).to be_nil
+ expect(old_active_review_env.auto_delete_at).to be_nil
+ expect(old_stopped_other_env.auto_delete_at).to be_nil
+ expect(new_stopped_other_env.auto_delete_at).to be_nil
+ expect(old_active_other_env.auto_delete_at).to be_nil
+ expect(already_deleting_env.auto_delete_at).to eq(already_deleting_time)
+ end
+ end
+
+ describe "execution in parallel" do
+ before do
+ stub_exclusive_lease_taken(service.send(:key))
+ end
+
+ it "does not execute unsafe_mark_scheduled_entries_environments" do
+ expect(service).not_to receive(:unsafe_mark_scheduled_entries_environments)
+
+ expect(subject.success?).to be_falsey
+ expect(subject.status).to eq(:conflict)
+ end
+ end
+ end
+
+ context "as a maintainer" do
+ let(:current_user) { maintainer }
+
+ it_behaves_like "can schedule for deletion"
+ end
+
+ context "as a developer" do
+ let(:current_user) { developer }
+
+ it_behaves_like "can schedule for deletion"
+ end
+
+ context "as a reporter" do
+ let(:current_user) { reporter }
+
+ it "fails to delete environments" do
+ old_stopped_review_env = create(:environment, :with_review_app, :stopped, created_at: 31.days.ago, project: project)
+
+ expect(subject.success?).to be_falsey
+
+ # Both of these should be empty as we fail before testing them
+ expect(subject.scheduled_entries).to be_empty
+ expect(subject.unprocessable_entries).to be_empty
+
+ old_stopped_review_env.reload
+
+ expect(old_stopped_review_env.auto_delete_at).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/services/groups/destroy_service_spec.rb b/spec/services/groups/destroy_service_spec.rb
index 2f9bb72939a..a5fce315d91 100644
--- a/spec/services/groups/destroy_service_spec.rb
+++ b/spec/services/groups/destroy_service_spec.rb
@@ -229,10 +229,10 @@ RSpec.describe Groups::DestroyService do
# will still be executed for the nested group as they fall under the same hierarchy
# and hence we need to account for this scenario.
expect(UserProjectAccessChangedService)
- .to receive(:new).with(shared_with_group.user_ids_for_project_authorizations).and_call_original
+ .to receive(:new).with(shared_with_group.users_ids_of_direct_members).and_call_original
expect(UserProjectAccessChangedService)
- .not_to receive(:new).with(shared_group.user_ids_for_project_authorizations)
+ .not_to receive(:new).with(shared_group.users_ids_of_direct_members)
destroy_group(shared_group, user, false)
end
@@ -246,7 +246,7 @@ RSpec.describe Groups::DestroyService do
it 'makes use of a specific service to update project authorizations' do
expect(UserProjectAccessChangedService)
- .to receive(:new).with(shared_with_group.user_ids_for_project_authorizations).and_call_original
+ .to receive(:new).with(shared_with_group.users_ids_of_direct_members).and_call_original
destroy_group(shared_with_group, user, false)
end
diff --git a/spec/services/groups/group_links/create_service_spec.rb b/spec/services/groups/group_links/create_service_spec.rb
index fb88433d8f6..df994b9f2a3 100644
--- a/spec/services/groups/group_links/create_service_spec.rb
+++ b/spec/services/groups/group_links/create_service_spec.rb
@@ -74,46 +74,56 @@ RSpec.describe Groups::GroupLinks::CreateService, '#execute' do
end
end
- context 'group hierarchies' do
+ context 'project authorizations based on group hierarchies' do
before do
group_parent.add_owner(parent_group_user)
group.add_owner(group_user)
group_child.add_owner(child_group_user)
end
- context 'group user' do
- let(:user) { group_user }
+ context 'project authorizations refresh' do
+ it 'is executed only for the direct members of the group' do
+ expect(UserProjectAccessChangedService).to receive(:new).with(contain_exactly(group_user.id)).and_call_original
- it 'create proper authorizations' do
subject.execute(shared_group)
-
- expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey
- expect(Ability.allowed?(user, :read_project, project)).to be_truthy
- expect(Ability.allowed?(user, :read_project, project_child)).to be_truthy
end
end
- context 'parent group user' do
- let(:user) { parent_group_user }
+ context 'project authorizations' do
+ context 'group user' do
+ let(:user) { group_user }
- it 'create proper authorizations' do
- subject.execute(shared_group)
+ it 'create proper authorizations' do
+ subject.execute(shared_group)
- expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey
- expect(Ability.allowed?(user, :read_project, project)).to be_falsey
- expect(Ability.allowed?(user, :read_project, project_child)).to be_falsey
+ expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey
+ expect(Ability.allowed?(user, :read_project, project)).to be_truthy
+ expect(Ability.allowed?(user, :read_project, project_child)).to be_truthy
+ end
end
- end
- context 'child group user' do
- let(:user) { child_group_user }
+ context 'parent group user' do
+ let(:user) { parent_group_user }
- it 'create proper authorizations' do
- subject.execute(shared_group)
+ it 'create proper authorizations' do
+ subject.execute(shared_group)
+
+ expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey
+ expect(Ability.allowed?(user, :read_project, project)).to be_falsey
+ expect(Ability.allowed?(user, :read_project, project_child)).to be_falsey
+ end
+ end
+
+ context 'child group user' do
+ let(:user) { child_group_user }
+
+ it 'create proper authorizations' do
+ subject.execute(shared_group)
- expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey
- expect(Ability.allowed?(user, :read_project, project)).to be_falsey
- expect(Ability.allowed?(user, :read_project, project_child)).to be_falsey
+ expect(Ability.allowed?(user, :read_project, project_parent)).to be_falsey
+ expect(Ability.allowed?(user, :read_project, project)).to be_falsey
+ expect(Ability.allowed?(user, :read_project, project_child)).to be_falsey
+ end
end
end
end
diff --git a/spec/services/groups/group_links/destroy_service_spec.rb b/spec/services/groups/group_links/destroy_service_spec.rb
index 22fe8a1d58b..97fe23e9147 100644
--- a/spec/services/groups/group_links/destroy_service_spec.rb
+++ b/spec/services/groups/group_links/destroy_service_spec.rb
@@ -47,8 +47,8 @@ RSpec.describe Groups::GroupLinks::DestroyService, '#execute' do
it 'updates project authorization once per group' do
expect(GroupGroupLink).to receive(:delete).and_call_original
- expect(group).to receive(:refresh_members_authorized_projects).once
- expect(another_group).to receive(:refresh_members_authorized_projects).once
+ expect(group).to receive(:refresh_members_authorized_projects).with(direct_members_only: true).once
+ expect(another_group).to receive(:refresh_members_authorized_projects).with(direct_members_only: true).once
subject.execute(links)
end
diff --git a/spec/services/groups/group_links/update_service_spec.rb b/spec/services/groups/group_links/update_service_spec.rb
index e4ff83d7926..436cdf89a0f 100644
--- a/spec/services/groups/group_links/update_service_spec.rb
+++ b/spec/services/groups/group_links/update_service_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe Groups::GroupLinks::UpdateService, '#execute' do
let_it_be(:group) { create(:group, :private) }
let_it_be(:shared_group) { create(:group, :private) }
let_it_be(:project) { create(:project, group: shared_group) }
- let(:group_member) { create(:user) }
+ let(:group_member_user) { create(:user) }
let!(:link) { create(:group_group_link, shared_group: shared_group, shared_with_group: group) }
let(:expiry_date) { 1.month.from_now.to_date }
@@ -20,7 +20,7 @@ RSpec.describe Groups::GroupLinks::UpdateService, '#execute' do
subject { described_class.new(link).execute(group_link_params) }
before do
- group.add_developer(group_member)
+ group.add_developer(group_member_user)
end
it 'updates existing link' do
@@ -36,11 +36,11 @@ RSpec.describe Groups::GroupLinks::UpdateService, '#execute' do
end
it 'updates project permissions' do
- expect { subject }.to change { group_member.can?(:create_release, project) }.from(true).to(false)
+ expect { subject }.to change { group_member_user.can?(:create_release, project) }.from(true).to(false)
end
it 'executes UserProjectAccessChangedService' do
- expect_next_instance_of(UserProjectAccessChangedService) do |service|
+ expect_next_instance_of(UserProjectAccessChangedService, [group_member_user.id]) do |service|
expect(service).to receive(:execute)
end
diff --git a/spec/services/groups/import_export/import_service_spec.rb b/spec/services/groups/import_export/import_service_spec.rb
index 0c7765dcd38..ad5c4364deb 100644
--- a/spec/services/groups/import_export/import_service_spec.rb
+++ b/spec/services/groups/import_export/import_service_spec.rb
@@ -54,7 +54,7 @@ RSpec.describe Groups::ImportExport::ImportService do
end
context 'with group_import_ndjson feature flag disabled' do
- let(:user) { create(:admin) }
+ let(:user) { create(:user) }
let(:group) { create(:group) }
let(:import_logger) { instance_double(Gitlab::Import::Logger) }
@@ -63,6 +63,8 @@ RSpec.describe Groups::ImportExport::ImportService do
before do
stub_feature_flags(group_import_ndjson: false)
+ group.add_owner(user)
+
ImportExportUpload.create!(group: group, import_file: import_file)
allow(Gitlab::Import::Logger).to receive(:build).and_return(import_logger)
@@ -95,7 +97,7 @@ RSpec.describe Groups::ImportExport::ImportService do
end
context 'when importing a ndjson export' do
- let(:user) { create(:admin) }
+ let(:user) { create(:user) }
let(:group) { create(:group) }
let(:service) { described_class.new(group: group, user: user) }
let(:import_file) { fixture_file_upload('spec/fixtures/group_export.tar.gz') }
@@ -115,6 +117,10 @@ RSpec.describe Groups::ImportExport::ImportService do
end
context 'when user has correct permissions' do
+ before do
+ group.add_owner(user)
+ end
+
it 'imports group structure successfully' do
expect(subject).to be_truthy
end
@@ -147,8 +153,6 @@ RSpec.describe Groups::ImportExport::ImportService do
end
context 'when user does not have correct permissions' do
- let(:user) { create(:user) }
-
it 'logs the error and raises an exception' do
expect(import_logger).to receive(:error).with(
group_id: group.id,
@@ -188,6 +192,10 @@ RSpec.describe Groups::ImportExport::ImportService do
context 'when there are errors with the sub-relations' do
let(:import_file) { fixture_file_upload('spec/fixtures/group_export_invalid_subrelations.tar.gz') }
+ before do
+ group.add_owner(user)
+ end
+
it 'successfully imports the group' do
expect(subject).to be_truthy
end
@@ -207,7 +215,7 @@ RSpec.describe Groups::ImportExport::ImportService do
end
context 'when importing a json export' do
- let(:user) { create(:admin) }
+ let(:user) { create(:user) }
let(:group) { create(:group) }
let(:service) { described_class.new(group: group, user: user) }
let(:import_file) { fixture_file_upload('spec/fixtures/legacy_group_export.tar.gz') }
@@ -227,6 +235,10 @@ RSpec.describe Groups::ImportExport::ImportService do
end
context 'when user has correct permissions' do
+ before do
+ group.add_owner(user)
+ end
+
it 'imports group structure successfully' do
expect(subject).to be_truthy
end
@@ -259,8 +271,6 @@ RSpec.describe Groups::ImportExport::ImportService do
end
context 'when user does not have correct permissions' do
- let(:user) { create(:user) }
-
it 'logs the error and raises an exception' do
expect(import_logger).to receive(:error).with(
group_id: group.id,
@@ -300,6 +310,10 @@ RSpec.describe Groups::ImportExport::ImportService do
context 'when there are errors with the sub-relations' do
let(:import_file) { fixture_file_upload('spec/fixtures/legacy_group_export_invalid_subrelations.tar.gz') }
+ before do
+ group.add_owner(user)
+ end
+
it 'successfully imports the group' do
expect(subject).to be_truthy
end
diff --git a/spec/services/import/github_service_spec.rb b/spec/services/import/github_service_spec.rb
index 408d7767254..776df01d399 100644
--- a/spec/services/import/github_service_spec.rb
+++ b/spec/services/import/github_service_spec.rb
@@ -54,6 +54,62 @@ RSpec.describe Import::GithubService do
expect { subject.execute(access_params, :github) }.to raise_error(exception)
end
+
+ context 'repository size validation' do
+ let(:repository_double) { double(name: 'repository', size: 99) }
+
+ before do
+ expect(client).to receive(:repository).and_return(repository_double)
+
+ allow_next_instance_of(Gitlab::LegacyGithubImport::ProjectCreator) do |creator|
+ allow(creator).to receive(:execute).and_return(double(persisted?: true))
+ end
+ end
+
+ context 'when there is no repository size limit defined' do
+ it 'skips the check and succeeds' do
+ expect(subject.execute(access_params, :github)).to include(status: :success)
+ end
+ end
+
+ context 'when the target namespace repository size limit is defined' do
+ let_it_be(:group) { create(:group, repository_size_limit: 100) }
+
+ before do
+ params[:target_namespace] = group.full_path
+ end
+
+ it 'succeeds when the repository is smaller than the limit' do
+ expect(subject.execute(access_params, :github)).to include(status: :success)
+ end
+
+ it 'returns error when the repository is larger than the limit' do
+ allow(repository_double).to receive(:size).and_return(101)
+
+ expect(subject.execute(access_params, :github)).to include(size_limit_error)
+ end
+ end
+
+ context 'when target namespace repository limit is not defined' do
+ let_it_be(:group) { create(:group) }
+
+ before do
+ stub_application_setting(repository_size_limit: 100)
+ end
+
+ context 'when application size limit is defined' do
+ it 'succeeds when the repository is smaller than the limit' do
+ expect(subject.execute(access_params, :github)).to include(status: :success)
+ end
+
+ it 'returns error when the repository is larger than the limit' do
+ allow(repository_double).to receive(:size).and_return(101)
+
+ expect(subject.execute(access_params, :github)).to include(size_limit_error)
+ end
+ end
+ end
+ end
end
context 'when remove_legacy_github_client feature flag is enabled' do
@@ -71,4 +127,12 @@ RSpec.describe Import::GithubService do
include_examples 'handles errors', Gitlab::LegacyGithubImport::Client
end
+
+ def size_limit_error
+ {
+ status: :error,
+ http_status: :unprocessable_entity,
+ message: '"repository" size (101 Bytes) is larger than the limit of 100 Bytes.'
+ }
+ end
end
diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb
index 79543fe9f5d..c749f282cd3 100644
--- a/spec/services/issuable/bulk_update_service_spec.rb
+++ b/spec/services/issuable/bulk_update_service_spec.rb
@@ -31,23 +31,6 @@ RSpec.describe Issuable::BulkUpdateService do
end
end
- shared_examples 'updates iterations' do
- it 'succeeds' do
- result = bulk_update(issuables, sprint_id: iteration.id)
-
- expect(result.success?).to be_truthy
- expect(result.payload[:count]).to eq(issuables.count)
- end
-
- it 'updates the issuables iteration' do
- bulk_update(issuables, sprint_id: iteration.id)
-
- issuables.each do |issuable|
- expect(issuable.reload.iteration).to eq(iteration)
- end
- end
- end
-
shared_examples 'updating labels' do
def create_issue_with_labels(labels)
create(:labeled_issue, project: project, labels: labels)
@@ -250,21 +233,6 @@ RSpec.describe Issuable::BulkUpdateService do
it_behaves_like 'updates milestones'
end
- describe 'updating iterations' do
- let_it_be(:group) { create(:group) }
- let_it_be(:project) { create(:project, group: group) }
- let_it_be(:issuables) { [create(:issue, project: project)] }
- let_it_be(:iteration) { create(:iteration, group: group) }
-
- let(:parent) { project }
-
- before do
- group.add_reporter(user)
- end
-
- it_behaves_like 'updates iterations'
- end
-
describe 'updating labels' do
let(:bug) { create(:label, project: project) }
let(:regression) { create(:label, project: project) }
@@ -347,19 +315,6 @@ RSpec.describe Issuable::BulkUpdateService do
end
end
- describe 'updating iterations' do
- let_it_be(:iteration) { create(:iteration, group: group) }
- let_it_be(:project) { create(:project, :repository, group: group) }
-
- context 'when issues' do
- let_it_be(:issue1) { create(:issue, project: project) }
- let_it_be(:issue2) { create(:issue, project: project) }
- let_it_be(:issuables) { [issue1, issue2] }
-
- it_behaves_like 'updates iterations'
- end
- end
-
describe 'updating labels' do
let(:project) { create(:project, :repository, group: group) }
let(:bug) { create(:group_label, group: group) }
diff --git a/spec/services/issuable/process_assignees_spec.rb b/spec/services/issuable/process_assignees_spec.rb
new file mode 100644
index 00000000000..876c84957cc
--- /dev/null
+++ b/spec/services/issuable/process_assignees_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Issuable::ProcessAssignees do
+ describe '#execute' do
+ it 'returns assignee_ids when assignee_ids are specified' do
+ process = Issuable::ProcessAssignees.new(assignee_ids: %w(5 7 9),
+ add_assignee_ids: %w(2 4 6),
+ remove_assignee_ids: %w(4 7 11),
+ existing_assignee_ids: %w(1 3 9),
+ extra_assignee_ids: %w(2 5 12))
+ result = process.execute
+
+ expect(result.sort).to eq(%w(5 7 9).sort)
+ end
+
+ it 'combines other ids when assignee_ids is empty' do
+ process = Issuable::ProcessAssignees.new(assignee_ids: [],
+ add_assignee_ids: %w(2 4 6),
+ remove_assignee_ids: %w(4 7 11),
+ existing_assignee_ids: %w(1 3 11),
+ extra_assignee_ids: %w(2 5 12))
+ result = process.execute
+
+ expect(result.sort).to eq(%w(1 2 3 5 6 12).sort)
+ end
+
+ it 'combines other ids when assignee_ids is nil' do
+ process = Issuable::ProcessAssignees.new(assignee_ids: nil,
+ add_assignee_ids: %w(2 4 6),
+ remove_assignee_ids: %w(4 7 11),
+ existing_assignee_ids: %w(1 3 11),
+ extra_assignee_ids: %w(2 5 12))
+ result = process.execute
+
+ expect(result.sort).to eq(%w(1 2 3 5 6 12).sort)
+ end
+
+ it 'combines other ids when assignee_ids and add_assignee_ids are nil' do
+ process = Issuable::ProcessAssignees.new(assignee_ids: nil,
+ add_assignee_ids: nil,
+ remove_assignee_ids: %w(4 7 11),
+ existing_assignee_ids: %w(1 3 11),
+ extra_assignee_ids: %w(2 5 12))
+ result = process.execute
+
+ expect(result.sort).to eq(%w(1 2 3 5 12).sort)
+ end
+
+ it 'combines other ids when assignee_ids and remove_assignee_ids are nil' do
+ process = Issuable::ProcessAssignees.new(assignee_ids: nil,
+ add_assignee_ids: %w(2 4 6),
+ remove_assignee_ids: nil,
+ existing_assignee_ids: %w(1 3 11),
+ extra_assignee_ids: %w(2 5 12))
+ result = process.execute
+
+ expect(result.sort).to eq(%w(1 2 4 3 5 6 11 12).sort)
+ end
+
+ it 'combines ids when only add_assignee_ids and remove_assignee_ids are passed' do
+ process = Issuable::ProcessAssignees.new(assignee_ids: nil,
+ add_assignee_ids: %w(2 4 6),
+ remove_assignee_ids: %w(4 7 11))
+ result = process.execute
+
+ expect(result.sort).to eq(%w(2 6).sort)
+ end
+ end
+end
diff --git a/spec/services/issues/clone_service_spec.rb b/spec/services/issues/clone_service_spec.rb
index 512a60b1382..9ceb4ffeec5 100644
--- a/spec/services/issues/clone_service_spec.rb
+++ b/spec/services/issues/clone_service_spec.rb
@@ -280,6 +280,12 @@ RSpec.describe Issues::CloneService do
expect(new_issue.designs.first.notes.size).to eq(1)
end
end
+
+ context 'issue relative position' do
+ let(:subject) { clone_service.execute(old_issue, new_project) }
+
+ it_behaves_like 'copy or reset relative position'
+ end
end
describe 'clone permissions' do
diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb
index e42e9722297..d548e5ee74a 100644
--- a/spec/services/issues/create_service_spec.rb
+++ b/spec/services/issues/create_service_spec.rb
@@ -286,6 +286,12 @@ RSpec.describe Issues::CreateService do
issue
end
+
+ it 'schedules a namespace onboarding create action worker' do
+ expect(Namespaces::OnboardingIssueCreatedWorker).to receive(:perform_async).with(project.namespace.id)
+
+ issue
+ end
end
context 'issue create service' do
diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb
index 9b8d21bb8eb..eb124f07900 100644
--- a/spec/services/issues/move_service_spec.rb
+++ b/spec/services/issues/move_service_spec.rb
@@ -244,6 +244,12 @@ RSpec.describe Issues::MoveService do
expect(new_issue.designs.first.notes.size).to eq(1)
end
end
+
+ context 'issue relative position' do
+ let(:subject) { move_service.execute(old_issue, new_project) }
+
+ it_behaves_like 'copy or reset relative position'
+ end
end
describe 'move permissions' do
diff --git a/spec/services/jira_import/users_importer_spec.rb b/spec/services/jira_import/users_importer_spec.rb
index 7112443502c..c825f899f80 100644
--- a/spec/services/jira_import/users_importer_spec.rb
+++ b/spec/services/jira_import/users_importer_spec.rb
@@ -54,8 +54,11 @@ RSpec.describe JiraImport::UsersImporter do
end
context 'when jira client raises an error' do
+ let(:error) { Timeout::Error.new }
+
it 'returns an error response' do
- expect(client).to receive(:get).and_raise(Timeout::Error)
+ expect(client).to receive(:get).and_raise(error)
+ expect(Gitlab::ErrorTracking).to receive(:log_exception).with(error, project_id: project.id)
expect(subject.error?).to be_truthy
expect(subject.message).to include('There was an error when communicating to Jira')
diff --git a/spec/services/labels/promote_service_spec.rb b/spec/services/labels/promote_service_spec.rb
index 15d53857f33..81c24b26c9f 100644
--- a/spec/services/labels/promote_service_spec.rb
+++ b/spec/services/labels/promote_service_spec.rb
@@ -4,9 +4,9 @@ require 'spec_helper'
RSpec.describe Labels::PromoteService do
describe '#execute' do
- let!(:user) { create(:user) }
+ let_it_be(:user) { create(:user) }
- context 'project without group' do
+ context 'without a group' do
let!(:project_1) { create(:project) }
let!(:project_label_1_1) { create(:label, project: project_1) }
@@ -18,40 +18,40 @@ RSpec.describe Labels::PromoteService do
end
end
- context 'project with group' do
- let!(:promoted_label_name) { "Promoted Label" }
- let!(:untouched_label_name) { "Untouched Label" }
- let!(:promoted_description) { "Promoted Description" }
- let!(:promoted_color) { "#0000FF" }
- let!(:label_2_1_priority) { 1 }
- let!(:label_3_1_priority) { 2 }
+ context 'with a group' do
+ let_it_be(:promoted_label_name) { "Promoted Label" }
+ let_it_be(:untouched_label_name) { "Untouched Label" }
+ let_it_be(:promoted_description) { "Promoted Description" }
+ let_it_be(:promoted_color) { "#0000FF" }
+ let_it_be(:label_2_1_priority) { 1 }
+ let_it_be(:label_3_1_priority) { 2 }
- let!(:group_1) { create(:group) }
- let!(:group_2) { create(:group) }
+ let_it_be(:group_1) { create(:group) }
+ let_it_be(:group_2) { create(:group) }
- let!(:project_1) { create(:project, namespace: group_1) }
- let!(:project_2) { create(:project, namespace: group_1) }
- let!(:project_3) { create(:project, namespace: group_1) }
- let!(:project_4) { create(:project, namespace: group_2) }
+ let_it_be(:project_1) { create(:project, :repository, namespace: group_1) }
+ let_it_be(:project_2) { create(:project, :repository, namespace: group_1) }
+ let_it_be(:project_3) { create(:project, :repository, namespace: group_1) }
+ let_it_be(:project_4) { create(:project, :repository, namespace: group_2) }
# Labels/issues can't be lazily created so we might as well eager initialize
# all other objects too since we use them inside
- let!(:project_label_1_1) { create(:label, project: project_1, name: promoted_label_name, color: promoted_color, description: promoted_description) }
- let!(:project_label_1_2) { create(:label, project: project_1, name: untouched_label_name) }
- let!(:project_label_2_1) { create(:label, project: project_2, priority: label_2_1_priority, name: promoted_label_name, color: "#FF0000") }
- let!(:project_label_3_1) { create(:label, project: project_3, priority: label_3_1_priority, name: promoted_label_name) }
- let!(:project_label_3_2) { create(:label, project: project_3, priority: 1, name: untouched_label_name) }
- let!(:project_label_4_1) { create(:label, project: project_4, name: promoted_label_name) }
+ let_it_be(:project_label_1_1) { create(:label, project: project_1, name: promoted_label_name, color: promoted_color, description: promoted_description) }
+ let_it_be(:project_label_1_2) { create(:label, project: project_1, name: untouched_label_name) }
+ let_it_be(:project_label_2_1) { create(:label, project: project_2, priority: label_2_1_priority, name: promoted_label_name, color: "#FF0000") }
+ let_it_be(:project_label_3_1) { create(:label, project: project_3, priority: label_3_1_priority, name: promoted_label_name) }
+ let_it_be(:project_label_3_2) { create(:label, project: project_3, priority: 1, name: untouched_label_name) }
+ let_it_be(:project_label_4_1) { create(:label, project: project_4, name: promoted_label_name) }
- let!(:issue_1_1) { create(:labeled_issue, project: project_1, labels: [project_label_1_1, project_label_1_2]) }
- let!(:issue_1_2) { create(:labeled_issue, project: project_1, labels: [project_label_1_2]) }
- let!(:issue_2_1) { create(:labeled_issue, project: project_2, labels: [project_label_2_1]) }
- let!(:issue_4_1) { create(:labeled_issue, project: project_4, labels: [project_label_4_1]) }
+ let_it_be(:issue_1_1) { create(:labeled_issue, project: project_1, labels: [project_label_1_1, project_label_1_2]) }
+ let_it_be(:issue_1_2) { create(:labeled_issue, project: project_1, labels: [project_label_1_2]) }
+ let_it_be(:issue_2_1) { create(:labeled_issue, project: project_2, labels: [project_label_2_1]) }
+ let_it_be(:issue_4_1) { create(:labeled_issue, project: project_4, labels: [project_label_4_1]) }
- let!(:merge_3_1) { create(:labeled_merge_request, source_project: project_3, target_project: project_3, labels: [project_label_3_1, project_label_3_2]) }
+ let_it_be(:merge_3_1) { create(:labeled_merge_request, source_project: project_3, target_project: project_3, labels: [project_label_3_1, project_label_3_2]) }
- let!(:issue_board_2_1) { create(:board, project: project_2) }
- let!(:issue_board_list_2_1) { create(:list, board: issue_board_2_1, label: project_label_2_1) }
+ let_it_be(:issue_board_2_1) { create(:board, project: project_2) }
+ let_it_be(:issue_board_list_2_1) { create(:list, board: issue_board_2_1, label: project_label_2_1) }
let(:new_label) { group_1.labels.find_by(title: promoted_label_name) }
@@ -82,8 +82,8 @@ RSpec.describe Labels::PromoteService do
expect { service.execute(project_label_1_1) }.to change { Subscription.count }.from(4).to(3)
- expect(new_label.subscribed?(user)).to be_truthy
- expect(new_label.subscribed?(user2)).to be_truthy
+ expect(new_label).to be_subscribed(user)
+ expect(new_label).to be_subscribed(user2)
end
it 'recreates priorities' do
@@ -165,12 +165,12 @@ RSpec.describe Labels::PromoteService do
service.execute(project_label_1_1)
Label.reflect_on_all_associations.each do |association|
- expect(project_label_1_1.send(association.name).any?).to be_falsey
+ expect(project_label_1_1.send(association.name).reset).not_to be_any
end
end
end
- context 'if there is an existing identical group label' do
+ context 'when there is an existing identical group label' do
let!(:existing_group_label) { create(:group_label, group: group_1, title: project_label_1_1.title ) }
it 'uses the existing group label' do
@@ -187,7 +187,7 @@ RSpec.describe Labels::PromoteService do
it_behaves_like 'promoting a project label to a group label'
end
- context 'if there is no existing identical group label' do
+ context 'when there is no existing identical group label' do
let(:existing_group_label) { nil }
it 'recreates the label as a group label' do
diff --git a/spec/services/members/invite_service_spec.rb b/spec/services/members/invite_service_spec.rb
index 08cdf0d3ae1..cced93896a5 100644
--- a/spec/services/members/invite_service_spec.rb
+++ b/spec/services/members/invite_service_spec.rb
@@ -2,76 +2,155 @@
require 'spec_helper'
-RSpec.describe Members::InviteService do
- let(:project) { create(:project) }
- let(:user) { create(:user) }
- let(:project_user) { create(:user) }
-
- before do
- project.add_maintainer(user)
+RSpec.describe Members::InviteService, :aggregate_failures do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { project.owner }
+ let_it_be(:project_user) { create(:user) }
+ let(:params) { {} }
+ let(:base_params) { { access_level: Gitlab::Access::GUEST } }
+
+ subject(:result) { described_class.new(user, base_params.merge(params)).execute(project) }
+
+ context 'when email is previously unused by current members' do
+ let(:params) { { email: 'email@example.org' } }
+
+ it 'successfully creates a member' do
+ expect { result }.to change(ProjectMember, :count).by(1)
+ expect(result[:status]).to eq(:success)
+ end
end
- it 'adds an existing user to members' do
- params = { email: project_user.email.to_s, access_level: Gitlab::Access::GUEST }
- result = described_class.new(user, params).execute(project)
+ context 'when emails are passed as an array' do
+ let(:params) { { email: %w[email@example.org email2@example.org] } }
- expect(result[:status]).to eq(:success)
- expect(project.users).to include project_user
+ it 'successfully creates members' do
+ expect { result }.to change(ProjectMember, :count).by(2)
+ expect(result[:status]).to eq(:success)
+ end
end
- it 'creates a new user for an unknown email address' do
- params = { email: 'email@example.org', access_level: Gitlab::Access::GUEST }
- result = described_class.new(user, params).execute(project)
+ context 'when emails are passed as an empty string' do
+ let(:params) { { email: '' } }
- expect(result[:status]).to eq(:success)
+ it 'returns an error' do
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq('Email cannot be blank')
+ end
end
- it 'limits the number of emails to 100' do
- emails = Array.new(101).map { |n| "email#{n}@example.com" }
- params = { email: emails, access_level: Gitlab::Access::GUEST }
+ context 'when email param is not included' do
+ it 'returns an error' do
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq('Email cannot be blank')
+ end
+ end
- result = described_class.new(user, params).execute(project)
+ context 'when email is not a valid email' do
+ let(:params) { { email: '_bogus_' } }
- expect(result[:status]).to eq(:error)
- expect(result[:message]).to eq('Too many users specified (limit is 100)')
+ it 'returns an error' do
+ expect { result }.not_to change(ProjectMember, :count)
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]['_bogus_']).to eq("Invite email is invalid")
+ end
end
- it 'does not invite an invalid email' do
- params = { email: project_user.id.to_s, access_level: Gitlab::Access::GUEST }
- result = described_class.new(user, params).execute(project)
+ context 'when duplicate email addresses are passed' do
+ let(:params) { { email: 'email@example.org,email@example.org' } }
+
+ it 'only creates one member per unique address' do
+ expect { result }.to change(ProjectMember, :count).by(1)
+ expect(result[:status]).to eq(:success)
+ end
+ end
- expect(result[:status]).to eq(:error)
- expect(result[:message][project_user.id.to_s]).to eq("Invite email is invalid")
- expect(project.users).not_to include project_user
+ context 'when observing email limits' 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 } }
+
+ it 'limits the number of emails to 100' do
+ expect { result }.not_to change(ProjectMember, :count)
+ expect(result[:status]).to eq(:error)
+ 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 { result }.not_to change(ProjectMember, :count)
+ expect(result[:status]).to eq(:error)
+ 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 { result }.to change(ProjectMember, :count).by(101)
+ expect(result[:status]).to eq(:success)
+ end
+ end
end
- it 'does not invite to an invalid access level' do
- params = { email: project_user.email, access_level: -1 }
- result = described_class.new(user, params).execute(project)
+ context 'when email belongs to an existing user' do
+ let(:params) { { email: project_user.email } }
- expect(result[:status]).to eq(:error)
- expect(result[:message][project_user.email]).to eq("Access level is not included in the list")
+ it 'adds an existing user to members' do
+ expect { result }.to change(ProjectMember, :count).by(1)
+ expect(result[:status]).to eq(:success)
+ expect(project.users).to include project_user
+ end
end
- it 'does not add a member with an existing invite' do
- invited_member = create(:project_member, :invited, project: project)
+ context 'when access level is not valid' do
+ let(:params) { { email: project_user.email, access_level: -1 } }
- params = { email: invited_member.invite_email,
- access_level: Gitlab::Access::GUEST }
- result = described_class.new(user, params).execute(project)
+ it 'returns an error' do
+ expect { result }.not_to change(ProjectMember, :count)
+ expect(result[:status]).to eq(:error)
+ expect(result[:message][project_user.email]).to eq("Access level is not included in the list")
+ 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}" } }
- expect(result[:status]).to eq(:error)
- expect(result[:message][invited_member.invite_email]).to eq("Member already invited to #{project.name}")
+ it 'adds new email and returns an error for the already invited email' do
+ expect { result }.to change(ProjectMember, :count).by(1)
+ expect(result[:status]).to eq(:error)
+ expect(result[:message][invited_member.invite_email]).to eq("Member already invited to #{project.name}")
+ expect(project.users).to include project_user
+ end
end
- it 'does not add a member with an access_request' do
- requested_member = create(:project_member, :access_request, project: project)
+ 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}" } }
+
+ it 'adds new email and returns an error for the already invited email' do
+ expect { result }.to change(ProjectMember, :count).by(1)
+ expect(result[:status]).to eq(:error)
+ expect(result[:message][requested_member.user.email])
+ .to eq("Member cannot be invited because they already requested to join #{project.name}")
+ expect(project.users).to include project_user
+ end
+ end
- params = { email: requested_member.user.email,
- access_level: Gitlab::Access::GUEST }
- result = described_class.new(user, params).execute(project)
+ 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}" } }
- expect(result[:status]).to eq(:error)
- expect(result[:message][requested_member.user.email]).to eq("Member cannot be invited because they already requested to join #{project.name}")
+ it 'adds new email and returns an error for the already invited email' do
+ expect { result }.to change(ProjectMember, :count).by(1)
+ expect(result[:status]).to eq(:error)
+ expect(result[:message][existing_member.user.email]).to eq("Already a member of #{project.name}")
+ expect(project.users).to include project_user
+ end
end
end
diff --git a/spec/services/merge_requests/after_create_service_spec.rb b/spec/services/merge_requests/after_create_service_spec.rb
index f21feb70bc5..dce351d8a31 100644
--- a/spec/services/merge_requests/after_create_service_spec.rb
+++ b/spec/services/merge_requests/after_create_service_spec.rb
@@ -32,6 +32,10 @@ RSpec.describe MergeRequests::AfterCreateService do
.to receive(:track_create_mr_action)
.with(user: merge_request.author)
+ expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
+ .to receive(:track_mr_including_ci_config)
+ .with(user: merge_request.author, merge_request: merge_request)
+
execute_service
end
@@ -67,5 +71,27 @@ RSpec.describe MergeRequests::AfterCreateService do
it_behaves_like 'records an onboarding progress action', :merge_request_created do
let(:namespace) { merge_request.target_project.namespace }
end
+
+ context 'when merge request is in unchecked state' do
+ before do
+ merge_request.mark_as_unchecked!
+ execute_service
+ end
+
+ it 'does not change its state' do
+ expect(merge_request.reload).to be_unchecked
+ end
+ end
+
+ context 'when merge request is in preparing state' do
+ before do
+ merge_request.mark_as_preparing!
+ execute_service
+ end
+
+ it 'marks the merge request as unchecked' do
+ expect(merge_request.reload).to be_unchecked
+ end
+ end
end
end
diff --git a/spec/services/merge_requests/build_service_spec.rb b/spec/services/merge_requests/build_service_spec.rb
index 22b3456708f..8adf6d69f73 100644
--- a/spec/services/merge_requests/build_service_spec.rb
+++ b/spec/services/merge_requests/build_service_spec.rb
@@ -19,8 +19,21 @@ RSpec.describe MergeRequests::BuildService do
let(:label_ids) { [] }
let(:merge_request) { service.execute }
let(:compare) { double(:compare, commits: commits) }
- let(:commit_1) { double(:commit_1, sha: 'f00ba7', safe_message: "Initial commit\n\nCreate the app") }
- let(:commit_2) { double(:commit_2, sha: 'f00ba7', safe_message: 'This is a bad commit message!') }
+ let(:commit_1) do
+ double(:commit_1, sha: 'f00ba6', safe_message: 'Initial commit',
+ gitaly_commit?: false, id: 'f00ba6', parent_ids: ['f00ba5'])
+ end
+
+ let(:commit_2) do
+ double(:commit_2, sha: 'f00ba7', safe_message: "Closes #1234 Second commit\n\nCreate the app",
+ gitaly_commit?: false, id: 'f00ba7', parent_ids: ['f00ba6'])
+ end
+
+ let(:commit_3) do
+ double(:commit_3, sha: 'f00ba8', safe_message: 'This is a bad commit message!',
+ gitaly_commit?: false, id: 'f00ba8', parent_ids: ['f00ba7'])
+ end
+
let(:commits) { nil }
let(:params) do
@@ -47,6 +60,7 @@ RSpec.describe MergeRequests::BuildService do
allow(CompareService).to receive_message_chain(:new, :execute).and_return(compare)
allow(project).to receive(:commit).and_return(commit_1)
allow(project).to receive(:commit).and_return(commit_2)
+ allow(project).to receive(:commit).and_return(commit_3)
end
shared_examples 'allows the merge request to be created' do
@@ -137,7 +151,7 @@ RSpec.describe MergeRequests::BuildService do
context 'when target branch is missing' do
let(:target_branch) { nil }
- let(:commits) { Commit.decorate([commit_1], project) }
+ let(:commits) { Commit.decorate([commit_2], project) }
before do
stub_compare
@@ -199,8 +213,8 @@ RSpec.describe MergeRequests::BuildService do
end
context 'one commit in the diff' do
- let(:commits) { Commit.decorate([commit_1], project) }
- let(:commit_description) { commit_1.safe_message.split(/\n+/, 2).last }
+ let(:commits) { Commit.decorate([commit_2], project) }
+ let(:commit_description) { commit_2.safe_message.split(/\n+/, 2).last }
before do
stub_compare
@@ -209,7 +223,7 @@ RSpec.describe MergeRequests::BuildService do
it_behaves_like 'allows the merge request to be created'
it 'uses the title of the commit as the title of the merge request' do
- expect(merge_request.title).to eq(commit_1.safe_message.split("\n").first)
+ expect(merge_request.title).to eq(commit_2.safe_message.split("\n").first)
end
it 'uses the description of the commit as the description of the merge request' do
@@ -225,10 +239,10 @@ RSpec.describe MergeRequests::BuildService do
end
context 'commit has no description' do
- let(:commits) { Commit.decorate([commit_2], project) }
+ let(:commits) { Commit.decorate([commit_3], project) }
it 'uses the title of the commit as the title of the merge request' do
- expect(merge_request.title).to eq(commit_2.safe_message)
+ expect(merge_request.title).to eq(commit_3.safe_message)
end
it 'sets the description to nil' do
@@ -257,7 +271,7 @@ RSpec.describe MergeRequests::BuildService do
end
it 'uses the title of the commit as the title of the merge request' do
- expect(merge_request.title).to eq('Initial commit')
+ expect(merge_request.title).to eq('Closes #1234 Second commit')
end
it 'appends the closing description' do
@@ -310,8 +324,8 @@ RSpec.describe MergeRequests::BuildService do
end
end
- context 'more than one commit in the diff' do
- let(:commits) { Commit.decorate([commit_1, commit_2], project) }
+ context 'no multi-line commit messages in the diff' do
+ let(:commits) { Commit.decorate([commit_1, commit_3], project) }
before do
stub_compare
@@ -365,6 +379,55 @@ RSpec.describe MergeRequests::BuildService do
end
end
end
+ end
+
+ context 'a multi-line commit message in the diff' do
+ let(:commits) { Commit.decorate([commit_1, commit_2, commit_3], project) }
+
+ before do
+ stub_compare
+ end
+
+ it_behaves_like 'allows the merge request to be created'
+
+ it 'uses the first line of the first multi-line commit message as the title' do
+ expect(merge_request.title).to eq('Closes #1234 Second commit')
+ end
+
+ it 'adds the remaining lines of the first multi-line commit message as the description' do
+ expect(merge_request.description).to eq('Create the app')
+ end
+
+ context 'when the source branch matches an issue' do
+ where(:issue_tracker, :source_branch, :title, :closing_message) do
+ :jira | 'FOO-123-fix-issue' | 'Resolve FOO-123 "Fix issue"' | 'Closes FOO-123'
+ :jira | 'fix-issue' | 'Fix issue' | nil
+ :custom_issue_tracker | '123-fix-issue' | 'Resolve #123 "Fix issue"' | 'Closes #123'
+ :custom_issue_tracker | 'fix-issue' | 'Fix issue' | nil
+ :internal | '123-fix-issue' | 'Resolve "A bug"' | 'Closes #123'
+ :internal | 'fix-issue' | 'Fix issue' | nil
+ :internal | '124-fix-issue' | '124 fix issue' | nil
+ end
+
+ with_them do
+ before do
+ if issue_tracker == :internal
+ issue.update!(iid: 123)
+ else
+ create(:"#{issue_tracker}_service", project: project)
+ project.reload
+ end
+ end
+
+ it 'sets the correct title' do
+ expect(merge_request.title).to eq('Closes #1234 Second commit')
+ end
+
+ it 'sets the closing description' do
+ expect(merge_request.description).to eq("Create the app#{closing_message ? "\n\n" + closing_message : ''}")
+ end
+ end
+ end
context 'when the issue is not accessible to user' do
let(:source_branch) { "#{issue.iid}-fix-issue" }
@@ -373,12 +436,12 @@ RSpec.describe MergeRequests::BuildService do
project.team.truncate
end
- it 'uses branch title as the merge request title' do
- expect(merge_request.title).to eq("#{issue.iid} fix issue")
+ it 'uses the first line of the first multi-line commit message as the title' do
+ expect(merge_request.title).to eq('Closes #1234 Second commit')
end
- it 'does not set a description' do
- expect(merge_request.description).to be_nil
+ it 'adds the remaining lines of the first multi-line commit message as the description' do
+ expect(merge_request.description).to eq('Create the app')
end
end
@@ -386,12 +449,12 @@ RSpec.describe MergeRequests::BuildService do
let(:source_branch) { "#{issue.iid}-fix-issue" }
let(:issue_confidential) { true }
- it 'uses the title of the branch as the merge request title' do
- expect(merge_request.title).to eq("#{issue.iid} fix issue")
+ it 'uses the first line of the first multi-line commit message as the title' do
+ expect(merge_request.title).to eq('Closes #1234 Second commit')
end
- it 'does not set a description' do
- expect(merge_request.description).to be_nil
+ it 'adds the remaining lines of the first multi-line commit message as the description' do
+ expect(merge_request.description).to eq('Create the app')
end
end
end
@@ -399,7 +462,7 @@ RSpec.describe MergeRequests::BuildService do
context 'source branch does not exist' do
before do
allow(project).to receive(:commit).with(source_branch).and_return(nil)
- allow(project).to receive(:commit).with(target_branch).and_return(commit_1)
+ allow(project).to receive(:commit).with(target_branch).and_return(commit_2)
end
it_behaves_like 'forbids the merge request from being created' do
@@ -409,7 +472,7 @@ RSpec.describe MergeRequests::BuildService do
context 'target branch does not exist' do
before do
- allow(project).to receive(:commit).with(source_branch).and_return(commit_1)
+ allow(project).to receive(:commit).with(source_branch).and_return(commit_2)
allow(project).to receive(:commit).with(target_branch).and_return(nil)
end
@@ -433,7 +496,7 @@ RSpec.describe MergeRequests::BuildService do
context 'upstream project has disabled merge requests' do
let(:upstream_project) { create(:project, :merge_requests_disabled) }
let(:project) { create(:project, forked_from_project: upstream_project) }
- let(:commits) { Commit.decorate([commit_1], project) }
+ let(:commits) { Commit.decorate([commit_2], project) }
it 'sets target project correctly' do
expect(merge_request.target_project).to eq(project)
@@ -441,8 +504,8 @@ RSpec.describe MergeRequests::BuildService do
end
context 'target_project is set and accessible by current_user' do
- let(:target_project) { create(:project, :public, :repository)}
- let(:commits) { Commit.decorate([commit_1], project) }
+ let(:target_project) { create(:project, :public, :repository) }
+ let(:commits) { Commit.decorate([commit_2], project) }
it 'sets target project correctly' do
expect(merge_request.target_project).to eq(target_project)
@@ -450,8 +513,8 @@ RSpec.describe MergeRequests::BuildService do
end
context 'target_project is set but not accessible by current_user' do
- let(:target_project) { create(:project, :private, :repository)}
- let(:commits) { Commit.decorate([commit_1], project) }
+ let(:target_project) { create(:project, :private, :repository) }
+ let(:commits) { Commit.decorate([commit_2], project) }
it 'sets target project correctly' do
expect(merge_request.target_project).to eq(project)
@@ -469,8 +532,8 @@ RSpec.describe MergeRequests::BuildService do
end
context 'source_project is set and accessible by current_user' do
- let(:source_project) { create(:project, :public, :repository)}
- let(:commits) { Commit.decorate([commit_1], project) }
+ let(:source_project) { create(:project, :public, :repository) }
+ let(:commits) { Commit.decorate([commit_2], project) }
before do
# To create merge requests _from_ a project the user needs at least
@@ -484,8 +547,8 @@ RSpec.describe MergeRequests::BuildService do
end
context 'source_project is set but not accessible by current_user' do
- let(:source_project) { create(:project, :private, :repository)}
- let(:commits) { Commit.decorate([commit_1], project) }
+ let(:source_project) { create(:project, :private, :repository) }
+ let(:commits) { Commit.decorate([commit_2], project) }
it 'sets source project correctly' do
expect(merge_request.source_project).to eq(project)
diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb
index 611f12c8146..87e5750ce6e 100644
--- a/spec/services/merge_requests/merge_service_spec.rb
+++ b/spec/services/merge_requests/merge_service_spec.rb
@@ -258,9 +258,8 @@ RSpec.describe MergeRequests::MergeService do
end
it 'removes the source branch using the author user' do
- expect(::Branches::DeleteService).to receive(:new)
- .with(merge_request.source_project, merge_request.author)
- .and_call_original
+ expect(::MergeRequests::DeleteSourceBranchWorker).to receive(:perform_async).with(merge_request.id, merge_request.source_branch_sha, merge_request.author.id)
+
service.execute(merge_request)
end
@@ -268,7 +267,8 @@ RSpec.describe MergeRequests::MergeService do
let(:service) { described_class.new(project, user, merge_params.merge('should_remove_source_branch' => false)) }
it 'does not delete the source branch' do
- expect(::Branches::DeleteService).not_to receive(:new)
+ expect(::MergeRequests::DeleteSourceBranchWorker).not_to receive(:perform_async)
+
service.execute(merge_request)
end
end
@@ -280,9 +280,8 @@ RSpec.describe MergeRequests::MergeService do
end
it 'removes the source branch using the current user' do
- expect(::Branches::DeleteService).to receive(:new)
- .with(merge_request.source_project, user)
- .and_call_original
+ expect(::MergeRequests::DeleteSourceBranchWorker).to receive(:perform_async).with(merge_request.id, merge_request.source_branch_sha, user.id)
+
service.execute(merge_request)
end
end
diff --git a/spec/services/merge_requests/post_merge_service_spec.rb b/spec/services/merge_requests/post_merge_service_spec.rb
index 71329905558..247b053e729 100644
--- a/spec/services/merge_requests/post_merge_service_spec.rb
+++ b/spec/services/merge_requests/post_merge_service_spec.rb
@@ -130,139 +130,5 @@ RSpec.describe MergeRequests::PostMergeService do
expect(deploy_job.reload.canceled?).to be false
end
end
-
- context 'for a merge request chain' do
- before do
- ::MergeRequests::UpdateService
- .new(project, user, force_remove_source_branch: '1')
- .execute(merge_request)
- end
-
- context 'when there is another MR' do
- let!(:another_merge_request) do
- create(:merge_request,
- source_project: source_project,
- source_branch: 'my-awesome-feature',
- target_project: merge_request.source_project,
- target_branch: merge_request.source_branch
- )
- end
-
- shared_examples 'does not retarget merge request' do
- it 'another merge request is unchanged' do
- expect { subject }.not_to change { another_merge_request.reload.target_branch }
- .from(merge_request.source_branch)
- end
- end
-
- shared_examples 'retargets merge request' do
- it 'another merge request is retargeted' do
- expect(SystemNoteService)
- .to receive(:change_branch).once
- .with(another_merge_request, another_merge_request.project, user,
- 'target', 'delete',
- merge_request.source_branch, merge_request.target_branch)
-
- expect { subject }.to change { another_merge_request.reload.target_branch }
- .from(merge_request.source_branch)
- .to(merge_request.target_branch)
- end
-
- context 'when FF retarget_merge_requests is disabled' do
- before do
- stub_feature_flags(retarget_merge_requests: false)
- end
-
- include_examples 'does not retarget merge request'
- end
-
- context 'when source branch is to be kept' do
- before do
- ::MergeRequests::UpdateService
- .new(project, user, force_remove_source_branch: false)
- .execute(merge_request)
- end
-
- include_examples 'does not retarget merge request'
- end
- end
-
- context 'in the same project' do
- let(:source_project) { project }
-
- it_behaves_like 'retargets merge request'
-
- context 'and is closed' do
- before do
- another_merge_request.close
- end
-
- it_behaves_like 'does not retarget merge request'
- end
-
- context 'and is merged' do
- before do
- another_merge_request.mark_as_merged
- end
-
- it_behaves_like 'does not retarget merge request'
- end
- end
-
- context 'in forked project' do
- let!(:source_project) { fork_project(project) }
-
- context 'when user has access to source project' do
- before do
- source_project.add_developer(user)
- end
-
- it_behaves_like 'retargets merge request'
- end
-
- context 'when user does not have access to source project' do
- it_behaves_like 'does not retarget merge request'
- end
- end
-
- context 'and current and another MR is from a fork' do
- let(:project) { create(:project) }
- let(:source_project) { fork_project(project) }
-
- let(:merge_request) do
- create(:merge_request,
- source_project: source_project,
- target_project: project
- )
- end
-
- before do
- source_project.add_developer(user)
- end
-
- it_behaves_like 'does not retarget merge request'
- end
- end
-
- context 'when many merge requests are to be retargeted' do
- let!(:many_merge_requests) do
- create_list(:merge_request, 10, :unique_branches,
- source_project: merge_request.source_project,
- target_project: merge_request.source_project,
- target_branch: merge_request.source_branch
- )
- end
-
- it 'retargets only 4 of them' do
- subject
-
- expect(many_merge_requests.each(&:reload).pluck(:target_branch).tally)
- .to eq(
- merge_request.source_branch => 6,
- merge_request.target_branch => 4
- )
- end
- end
- end
end
end
diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb
index 747ecbf4fa4..2abe7a23bfe 100644
--- a/spec/services/merge_requests/refresh_service_spec.rb
+++ b/spec/services/merge_requests/refresh_service_spec.rb
@@ -72,6 +72,21 @@ RSpec.describe MergeRequests::RefreshService do
allow(NotificationService).to receive(:new) { notification_service }
end
+ context 'query count' do
+ it 'does not execute a lot of queries' do
+ # Hardcoded the query limit since the queries can also be reduced even
+ # if there are the same number of merge requests (e.g. by preloading
+ # associations). This should also fail in case additional queries are
+ # added elsewhere that affected this service.
+ #
+ # The limit is based on the number of queries executed at the current
+ # state of the service. As we reduce the number of queries executed in
+ # this service, the limit should be reduced as well.
+ expect { refresh_service.execute(@oldrev, @newrev, 'refs/heads/master') }
+ .not_to exceed_query_limit(260)
+ end
+ end
+
it 'executes hooks with update action' do
refresh_service.execute(@oldrev, @newrev, 'refs/heads/master')
reload_mrs
@@ -155,6 +170,18 @@ RSpec.describe MergeRequests::RefreshService do
.not_to change { @merge_request.reload.merge_request_diff }
end
end
+
+ it 'calls the merge request activity counter' do
+ expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
+ .to receive(:track_mr_including_ci_config)
+ .with(user: @merge_request.author, merge_request: @merge_request)
+
+ expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
+ .to receive(:track_mr_including_ci_config)
+ .with(user: @another_merge_request.author, merge_request: @another_merge_request)
+
+ refresh_service.execute(@oldrev, @newrev, 'refs/heads/master')
+ end
end
context 'when pipeline exists for the source branch' do
diff --git a/spec/services/merge_requests/retarget_chain_service_spec.rb b/spec/services/merge_requests/retarget_chain_service_spec.rb
new file mode 100644
index 00000000000..3937fbe58c3
--- /dev/null
+++ b/spec/services/merge_requests/retarget_chain_service_spec.rb
@@ -0,0 +1,154 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe MergeRequests::RetargetChainService do
+ include ProjectForksHelper
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:merge_request, reload: true) { create(:merge_request, assignees: [user]) }
+ let_it_be(:project) { merge_request.project }
+
+ subject { described_class.new(project, user).execute(merge_request) }
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ describe '#execute' do
+ context 'when there is another MR' do
+ let!(:another_merge_request) do
+ create(:merge_request,
+ source_project: source_project,
+ source_branch: 'my-awesome-feature',
+ target_project: merge_request.source_project,
+ target_branch: merge_request.source_branch
+ )
+ end
+
+ shared_examples 'does not retarget merge request' do
+ it 'another merge request is unchanged' do
+ expect { subject }.not_to change { another_merge_request.reload.target_branch }
+ .from(merge_request.source_branch)
+ end
+ end
+
+ shared_examples 'retargets merge request' do
+ it 'another merge request is retargeted' do
+ expect(SystemNoteService)
+ .to receive(:change_branch).once
+ .with(another_merge_request, another_merge_request.project, user,
+ 'target', 'delete',
+ merge_request.source_branch, merge_request.target_branch)
+
+ expect { subject }.to change { another_merge_request.reload.target_branch }
+ .from(merge_request.source_branch)
+ .to(merge_request.target_branch)
+ end
+
+ context 'when FF retarget_merge_requests is disabled' do
+ before do
+ stub_feature_flags(retarget_merge_requests: false)
+ end
+
+ include_examples 'does not retarget merge request'
+ end
+ end
+
+ context 'in the same project' do
+ let(:source_project) { project }
+
+ context 'and current is merged' do
+ before do
+ merge_request.mark_as_merged
+ end
+
+ it_behaves_like 'retargets merge request'
+ end
+
+ context 'and current is closed' do
+ before do
+ merge_request.close
+ end
+
+ it_behaves_like 'does not retarget merge request'
+ end
+
+ context 'and another is closed' do
+ before do
+ another_merge_request.close
+ end
+
+ it_behaves_like 'does not retarget merge request'
+ end
+
+ context 'and another is merged' do
+ before do
+ another_merge_request.mark_as_merged
+ end
+
+ it_behaves_like 'does not retarget merge request'
+ end
+ end
+
+ context 'in forked project' do
+ let!(:source_project) { fork_project(project) }
+
+ context 'when user has access to source project' do
+ before do
+ source_project.add_developer(user)
+ merge_request.mark_as_merged
+ end
+
+ it_behaves_like 'retargets merge request'
+ end
+
+ context 'when user does not have access to source project' do
+ it_behaves_like 'does not retarget merge request'
+ end
+ end
+
+ context 'and current and another MR is from a fork' do
+ let(:project) { create(:project) }
+ let(:source_project) { fork_project(project) }
+
+ let(:merge_request) do
+ create(:merge_request,
+ source_project: source_project,
+ target_project: project
+ )
+ end
+
+ before do
+ source_project.add_developer(user)
+ end
+
+ it_behaves_like 'does not retarget merge request'
+ end
+ end
+
+ context 'when many merge requests are to be retargeted' do
+ let!(:many_merge_requests) do
+ create_list(:merge_request, 10, :unique_branches,
+ source_project: merge_request.source_project,
+ target_project: merge_request.source_project,
+ target_branch: merge_request.source_branch
+ )
+ end
+
+ before do
+ merge_request.mark_as_merged
+ end
+
+ it 'retargets only 4 of them' do
+ subject
+
+ expect(many_merge_requests.each(&:reload).pluck(:target_branch).tally)
+ .to eq(
+ merge_request.source_branch => 6,
+ merge_request.target_branch => 4
+ )
+ 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 edb95840604..7a7f684c6d0 100644
--- a/spec/services/merge_requests/update_service_spec.rb
+++ b/spec/services/merge_requests/update_service_spec.rb
@@ -48,6 +48,8 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
end
context 'valid params' do
+ let(:locked) { true }
+
let(:opts) do
{
title: 'New title',
@@ -58,7 +60,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
label_ids: [label.id],
target_branch: 'target',
force_remove_source_branch: '1',
- discussion_locked: true
+ discussion_locked: locked
}
end
@@ -117,6 +119,139 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
MergeRequests::UpdateService.new(project, user, opts).execute(draft_merge_request)
end
+
+ context 'when MR is locked' do
+ context 'when locked again' do
+ it 'does not track discussion locking' do
+ expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
+ .not_to receive(:track_discussion_locked_action)
+
+ opts[:discussion_locked] = true
+
+ MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
+ end
+ end
+
+ context 'when unlocked' do
+ it 'tracks dicussion unlocking' do
+ expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
+ .to receive(:track_discussion_unlocked_action).once.with(user: user)
+
+ opts[:discussion_locked] = false
+
+ MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
+ end
+ end
+ end
+
+ context 'when MR is unlocked' do
+ let(:locked) { false }
+
+ context 'when unlocked again' do
+ it 'does not track discussion unlocking' do
+ expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
+ .not_to receive(:track_discussion_unlocked_action)
+
+ opts[:discussion_locked] = false
+
+ MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
+ end
+ end
+
+ context 'when locked' do
+ it 'tracks dicussion locking' do
+ expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
+ .to receive(:track_discussion_locked_action).once.with(user: user)
+
+ opts[:discussion_locked] = true
+
+ MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
+ end
+ end
+ end
+
+ it 'tracks time estimate and spend time changes' do
+ expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
+ .to receive(:track_time_estimate_changed_action).once.with(user: user)
+
+ expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
+ .to receive(:track_time_spent_changed_action).once.with(user: user)
+
+ opts[:time_estimate] = 86400
+ opts[:spend_time] = {
+ duration: 3600,
+ user_id: user.id,
+ spent_at: Date.parse('2021-02-24')
+ }
+
+ MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
+ end
+
+ it 'tracks milestone change' do
+ expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
+ .to receive(:track_milestone_changed_action).once.with(user: user)
+
+ opts[:milestone] = milestone
+
+ MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
+ end
+
+ it 'track labels change' do
+ expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
+ .to receive(:track_labels_changed_action).once.with(user: user)
+
+ opts[:label_ids] = [label2.id]
+
+ MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
+ end
+
+ context 'assignees' do
+ context 'when assignees changed' do
+ it 'tracks assignees changed event' do
+ expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
+ .to receive(:track_assignees_changed_action).once.with(user: user)
+
+ opts[:assignees] = [user2]
+
+ MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
+ end
+ end
+
+ context 'when assignees did not change' do
+ it 'does not track assignees changed event' do
+ expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
+ .not_to receive(:track_assignees_changed_action)
+
+ opts[:assignees] = merge_request.assignees
+
+ MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
+ end
+ end
+ end
+
+ context 'reviewers' do
+ context 'when reviewers changed' do
+ it 'tracks reviewers changed event' do
+ expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
+ .to receive(:track_reviewers_changed_action).once.with(user: user)
+
+ opts[:reviewers] = [user2]
+
+ MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
+ end
+ end
+
+ context 'when reviewers did not change' do
+ it 'does not track reviewers changed event' do
+ expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
+ .not_to receive(:track_reviewers_changed_action)
+
+ opts[:reviewers] = merge_request.reviewers
+
+ MergeRequests::UpdateService.new(project, user, opts).execute(merge_request)
+ end
+ end
+ end
end
context 'updating milestone' do
@@ -656,6 +791,48 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
end
end
+ context 'when the draft status is changed' do
+ let!(:non_subscriber) { create(:user) }
+ let!(:subscriber) do
+ create(:user) { |u| merge_request.toggle_subscription(u, project) }
+ end
+
+ before do
+ project.add_developer(non_subscriber)
+ project.add_developer(subscriber)
+ end
+
+ context 'removing draft status' do
+ before do
+ merge_request.update_attribute(:title, 'Draft: New Title')
+ end
+
+ it 'sends notifications for subscribers', :sidekiq_might_not_need_inline do
+ opts = { title: 'New title' }
+
+ perform_enqueued_jobs do
+ @merge_request = described_class.new(project, user, opts).execute(merge_request)
+ end
+
+ should_email(subscriber)
+ should_not_email(non_subscriber)
+ end
+ end
+
+ context 'adding draft status' do
+ it 'does not send notifications', :sidekiq_might_not_need_inline do
+ opts = { title: 'Draft: New title' }
+
+ perform_enqueued_jobs do
+ @merge_request = described_class.new(project, user, opts).execute(merge_request)
+ end
+
+ should_not_email(subscriber)
+ should_not_email(non_subscriber)
+ end
+ end
+ end
+
context 'when the merge request is relabeled' do
let!(:non_subscriber) { create(:user) }
let!(:subscriber) { create(:user) { |u| label.toggle_subscription(u, project) } }
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 7346a5b95ae..28b2e699e5e 100644
--- a/spec/services/namespaces/in_product_marketing_emails_service_spec.rb
+++ b/spec/services/namespaces/in_product_marketing_emails_service_spec.rb
@@ -3,12 +3,15 @@
require 'spec_helper'
RSpec.describe Namespaces::InProductMarketingEmailsService, '#execute' do
- subject(:execute_service) { described_class.new(track, interval).execute }
+ subject(:execute_service) do
+ travel_to(frozen_time) { described_class.new(track, interval).execute }
+ end
let(:track) { :create }
let(:interval) { 1 }
- let(:previous_action_completed_at) { 2.days.ago.middle_of_day }
+ let(:frozen_time) { Time.current }
+ let(:previous_action_completed_at) { frozen_time - 2.days }
let(:current_action_completed_at) { nil }
let(:experiment_enabled) { true }
let(:user_can_perform_current_track_action) { true }
@@ -39,18 +42,18 @@ RSpec.describe Namespaces::InProductMarketingEmailsService, '#execute' do
using RSpec::Parameterized::TableSyntax
where(:track, :interval, :actions_completed) do
- :create | 1 | { created_at: 2.days.ago.middle_of_day }
- :create | 5 | { created_at: 6.days.ago.middle_of_day }
- :create | 10 | { created_at: 11.days.ago.middle_of_day }
- :verify | 1 | { created_at: 2.days.ago.middle_of_day, git_write_at: 2.days.ago.middle_of_day }
- :verify | 5 | { created_at: 6.days.ago.middle_of_day, git_write_at: 6.days.ago.middle_of_day }
- :verify | 10 | { created_at: 11.days.ago.middle_of_day, git_write_at: 11.days.ago.middle_of_day }
- :trial | 1 | { created_at: 2.days.ago.middle_of_day, git_write_at: 2.days.ago.middle_of_day, pipeline_created_at: 2.days.ago.middle_of_day }
- :trial | 5 | { created_at: 6.days.ago.middle_of_day, git_write_at: 6.days.ago.middle_of_day, pipeline_created_at: 6.days.ago.middle_of_day }
- :trial | 10 | { created_at: 11.days.ago.middle_of_day, git_write_at: 11.days.ago.middle_of_day, pipeline_created_at: 11.days.ago.middle_of_day }
- :team | 1 | { created_at: 2.days.ago.middle_of_day, git_write_at: 2.days.ago.middle_of_day, pipeline_created_at: 2.days.ago.middle_of_day, trial_started_at: 2.days.ago.middle_of_day }
- :team | 5 | { created_at: 6.days.ago.middle_of_day, git_write_at: 6.days.ago.middle_of_day, pipeline_created_at: 6.days.ago.middle_of_day, trial_started_at: 6.days.ago.middle_of_day }
- :team | 10 | { created_at: 11.days.ago.middle_of_day, git_write_at: 11.days.ago.middle_of_day, pipeline_created_at: 11.days.ago.middle_of_day, trial_started_at: 11.days.ago.middle_of_day }
+ :create | 1 | { created_at: frozen_time - 2.days }
+ :create | 5 | { created_at: frozen_time - 6.days }
+ :create | 10 | { created_at: frozen_time - 11.days }
+ :verify | 1 | { created_at: frozen_time - 2.days, git_write_at: frozen_time - 2.days }
+ :verify | 5 | { created_at: frozen_time - 6.days, git_write_at: frozen_time - 6.days }
+ :verify | 10 | { created_at: frozen_time - 11.days, git_write_at: frozen_time - 11.days }
+ :trial | 1 | { created_at: frozen_time - 2.days, git_write_at: frozen_time - 2.days, pipeline_created_at: frozen_time - 2.days }
+ :trial | 5 | { created_at: frozen_time - 6.days, git_write_at: frozen_time - 6.days, pipeline_created_at: frozen_time - 6.days }
+ :trial | 10 | { created_at: frozen_time - 11.days, git_write_at: frozen_time - 11.days, pipeline_created_at: frozen_time - 11.days }
+ :team | 1 | { created_at: frozen_time - 2.days, git_write_at: frozen_time - 2.days, pipeline_created_at: frozen_time - 2.days, trial_started_at: frozen_time - 2.days }
+ :team | 5 | { created_at: frozen_time - 6.days, git_write_at: frozen_time - 6.days, pipeline_created_at: frozen_time - 6.days, trial_started_at: frozen_time - 6.days }
+ :team | 10 | { created_at: frozen_time - 11.days, git_write_at: frozen_time - 11.days, pipeline_created_at: frozen_time - 11.days, trial_started_at: frozen_time - 11.days }
end
with_them do
@@ -64,7 +67,7 @@ RSpec.describe Namespaces::InProductMarketingEmailsService, '#execute' do
it { is_expected.not_to send_in_product_marketing_email }
context 'when the previous track actions have been completed' do
- let(:current_action_completed_at) { 2.days.ago.middle_of_day }
+ let(:current_action_completed_at) { frozen_time - 2.days }
it { is_expected.to send_in_product_marketing_email(user.id, group.id, :verify, 0) }
end
@@ -76,7 +79,7 @@ RSpec.describe Namespaces::InProductMarketingEmailsService, '#execute' do
it { is_expected.not_to send_in_product_marketing_email }
context 'when the previous track action was completed within the intervals range' do
- let(:previous_action_completed_at) { 6.days.ago.middle_of_day }
+ let(:previous_action_completed_at) { frozen_time - 6.days }
it { is_expected.to send_in_product_marketing_email(user.id, group.id, :create, 1) }
end
@@ -113,13 +116,13 @@ RSpec.describe Namespaces::InProductMarketingEmailsService, '#execute' do
end
context 'when the previous track action is completed outside the intervals range' do
- let(:previous_action_completed_at) { 3.days.ago }
+ let(:previous_action_completed_at) { frozen_time - 3.days }
it { is_expected.not_to send_in_product_marketing_email }
end
context 'when the current track action is completed' do
- let(:current_action_completed_at) { Time.current }
+ let(:current_action_completed_at) { frozen_time }
it { is_expected.not_to send_in_product_marketing_email }
end
@@ -156,4 +159,20 @@ RSpec.describe Namespaces::InProductMarketingEmailsService, '#execute' do
it { expect { subject }.to raise_error(NotImplementedError, 'No ability defined for track foo') }
end
+
+ context 'when group is a sub-group' do
+ let(:root_group) { create(:group) }
+ let(:group) { create(:group) }
+
+ before do
+ group.parent = root_group
+ group.save!
+
+ allow(Ability).to receive(:allowed?).and_call_original
+ end
+
+ it 'does not raise an exception' do
+ expect { execute_service }.not_to raise_error
+ end
+ end
end
diff --git a/spec/services/notes/build_service_spec.rb b/spec/services/notes/build_service_spec.rb
index 90548cf9a99..deeab66c4e9 100644
--- a/spec/services/notes/build_service_spec.rb
+++ b/spec/services/notes/build_service_spec.rb
@@ -3,29 +3,38 @@
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: author) }
+ let(:mr_note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project, author: note.author) }
+ let(:base_params) { { note: 'Test' } }
+ let(:params) { {} }
+
+ subject(:new_note) { described_class.new(project, user, base_params.merge(params)).execute }
describe '#execute' do
context 'when in_reply_to_discussion_id is specified' do
+ let(:params) { { in_reply_to_discussion_id: note.discussion_id } }
+
context 'when a note with that original discussion ID exists' do
it 'sets the note up to be in reply to that note' do
- new_note = described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute
expect(new_note).to be_valid
expect(new_note.in_reply_to?(note)).to be_truthy
expect(new_note.resolved?).to be_falsey
end
context 'when discussion is resolved' do
+ let(:params) { { in_reply_to_discussion_id: mr_note.discussion_id } }
+
before do
mr_note.resolve!(author)
end
it 'resolves the note' do
- new_note = described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: mr_note.discussion_id).execute
expect(new_note).to be_valid
expect(new_note.resolved?).to be_truthy
end
@@ -34,24 +43,23 @@ RSpec.describe Notes::BuildService do
context 'when a note with that discussion ID exists' do
it 'sets the note up to be in reply to that note' do
- new_note = described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute
expect(new_note).to be_valid
expect(new_note.in_reply_to?(note)).to be_truthy
end
end
context 'when no note with that discussion ID exists' do
+ let(:params) { { in_reply_to_discussion_id: 'foo' } }
+
it 'sets an error' do
- new_note = described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: 'foo').execute
expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found')
end
end
context 'when user has no access to discussion' do
- it 'sets an error' do
- another_user = create(:user)
- new_note = described_class.new(project, another_user, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute
+ let(:user) { create(:user) }
+ it 'sets an error' do
expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found')
end
end
@@ -127,34 +135,118 @@ RSpec.describe Notes::BuildService do
context 'when replying to individual note' do
let(:note) { create(:note_on_issue) }
-
- subject { described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute }
+ let(:params) { { in_reply_to_discussion_id: note.discussion_id } }
it 'sets the note up to be in reply to that note' do
- expect(subject).to be_valid
- expect(subject).to be_a(DiscussionNote)
- expect(subject.discussion_id).to eq(note.discussion_id)
+ expect(new_note).to be_valid
+ expect(new_note).to be_a(DiscussionNote)
+ expect(new_note.discussion_id).to eq(note.discussion_id)
end
context 'when noteable does not support replies' do
let(:note) { create(:note_on_commit) }
it 'builds another individual note' do
- expect(subject).to be_valid
- expect(subject).to be_a(Note)
- expect(subject.discussion_id).not_to eq(note.discussion_id)
+ expect(new_note).to be_valid
+ expect(new_note).to be_a(Note)
+ expect(new_note.discussion_id).not_to eq(note.discussion_id)
+ end
+ end
+ end
+
+ context 'confidential comments' do
+ before do
+ project.add_reporter(author)
+ 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 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
+ end
+
+ context 'when the user cannot read confidential comments' do
+ let(:user) { create(:user) }
+
+ it 'returns `Discussion to reply to cannot be found` error' do
+ expect(new_note.errors.first).to include("Discussion to reply to cannot be found")
+ end
+ 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 } }
+
+ it '`confidential` param is ignored and set to `false`' do
+ expect(new_note.confidential).to be_falsey
+ 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 } }
+
+ it 'note `confidential` flag is set to `true`' do
+ expect(new_note.confidential).to be_truthy
+ end
+ end
+
+ 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 } }
+
+ it 'note `confidential` flag is set to `true`' 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
+
+ let(:admin) { create(:admin) }
+ let(:params) { { confidential: true, noteable: merge_request } }
+
+ it 'note `confidential` flag is set to `true`' do
+ expect(new_note.confidential).to be_truthy
+ end
+ end
+
+ context 'when the user is not allowed' do
+ let(:user) { create(:user) }
+ let(:params) { { confidential: true, noteable: merge_request } }
+
+ it 'note `confidential` flag is set to `false`' do
+ expect(new_note.confidential).to be_falsey
+ end
+ end
+ end
+
+ context 'when the `confidential` note flag is set to `false`' do
+ let(:params) { { confidential: false, noteable: merge_request } }
+
+ it 'note `confidential` flag is set to `false`' do
+ expect(new_note.confidential).to be_falsey
+ end
end
end
end
- it 'builds a note without saving it' do
- new_note = described_class.new(project,
- author,
- noteable_type: note.noteable_type,
- noteable_id: note.noteable_id,
- note: 'Test').execute
- expect(new_note).to be_valid
- expect(new_note).not_to be_persisted
+ context 'when noteable is not set' do
+ let(:params) { { noteable_type: note.noteable_type, noteable_id: note.noteable_id } }
+
+ it 'builds a note without saving it' do
+ expect(new_note).to be_valid
+ expect(new_note).not_to be_persisted
+ end
end
end
end
diff --git a/spec/services/notes/update_service_spec.rb b/spec/services/notes/update_service_spec.rb
index 902fd9958f8..000f3d26efa 100644
--- a/spec/services/notes/update_service_spec.rb
+++ b/spec/services/notes/update_service_spec.rb
@@ -64,6 +64,40 @@ RSpec.describe Notes::UpdateService do
end.to change { counter.unique_events(event_names: event, start_date: 1.day.ago, end_date: 1.day.from_now) }.by(1)
end
+ context 'when note text was changed' do
+ let!(:note) { create(:note, project: project, noteable: issue, author: user2, note: "Old note #{user3.to_reference}") }
+ let(:edit_note_text) { update_note({ note: 'new text' }) }
+
+ it 'update last_edited_at' do
+ travel_to(1.day.from_now) do
+ expect { edit_note_text }.to change { note.reload.last_edited_at }
+ end
+ end
+
+ it 'update updated_by' do
+ travel_to(1.day.from_now) do
+ expect { edit_note_text }.to change { note.reload.updated_by }
+ end
+ end
+ end
+
+ context 'when note text was not changed' do
+ let!(:note) { create(:note, project: project, noteable: issue, author: user2, note: "Old note #{user3.to_reference}") }
+ let(:does_not_edit_note_text) { update_note({}) }
+
+ it 'does not update last_edited_at' do
+ travel_to(1.day.from_now) do
+ expect { does_not_edit_note_text }.not_to change { note.reload.last_edited_at }
+ end
+ end
+
+ it 'does not update updated_by' do
+ travel_to(1.day.from_now) do
+ expect { does_not_edit_note_text }.not_to change { note.reload.updated_by }
+ end
+ end
+ end
+
context 'when the notable is a merge request' do
let(:merge_request) { create(:merge_request, source_project: project) }
let(:note) { create(:note, project: project, noteable: merge_request, author: user, note: "Old note #{user2.to_reference}") }
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index b67c37ba02d..f3cd2776ce7 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -99,6 +99,23 @@ RSpec.describe NotificationService, :mailer do
end
end
+ shared_examples 'is not able to send notifications' do
+ it 'does not send any notification' do
+ user_1 = create(:user)
+ recipient_1 = NotificationRecipient.new(user_1, :custom, custom_action: :new_release)
+ allow(NotificationRecipients::BuildService).to receive(:build_new_release_recipients).and_return([recipient_1])
+
+ expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: current_user.id, klass: object.class, object_id: object.id)
+
+ action
+
+ should_not_email(@u_mentioned)
+ should_not_email(@u_guest_watcher)
+ should_not_email(user_1)
+ should_not_email(current_user)
+ end
+ end
+
# Next shared examples are intended to test notifications of "participants"
#
# they take the following parameters:
@@ -243,11 +260,12 @@ RSpec.describe NotificationService, :mailer do
describe 'AccessToken' do
describe '#access_token_about_to_expire' do
let_it_be(:user) { create(:user) }
+ let_it_be(:pat) { create(:personal_access_token, user: user, expires_at: 5.days.from_now) }
- subject { notification.access_token_about_to_expire(user) }
+ subject { notification.access_token_about_to_expire(user, [pat.name]) }
it 'sends email to the token owner' do
- expect { subject }.to have_enqueued_email(user, mail: "access_token_about_to_expire_email")
+ expect { subject }.to have_enqueued_email(user, [pat.name], mail: "access_token_about_to_expire_email")
end
end
@@ -297,17 +315,17 @@ RSpec.describe NotificationService, :mailer do
describe 'Notes' do
context 'issue note' do
let_it_be(:project) { create(:project, :private) }
- let_it_be(:issue) { create(:issue, project: project, assignees: [assignee]) }
+ let_it_be_with_reload(:issue) { create(:issue, project: project, assignees: [assignee]) }
let_it_be(:mentioned_issue) { create(:issue, assignees: issue.assignees) }
let_it_be_with_reload(:author) { create(:user) }
let(:note) { create(:note_on_issue, author: author, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @unsubscribed_mentioned and @outsider also') }
subject { notification.new_note(note) }
- context 'on service desk issue' do
+ context 'issue_email_participants' do
before do
allow(Notify).to receive(:service_desk_new_note_email)
- .with(Integer, Integer).and_return(mailer)
+ .with(Integer, Integer, String).and_return(mailer)
allow(::Gitlab::IncomingEmail).to receive(:enabled?) { true }
allow(::Gitlab::IncomingEmail).to receive(:supports_wildcard?) { true }
@@ -318,7 +336,7 @@ RSpec.describe NotificationService, :mailer do
def should_email!
expect(Notify).to receive(:service_desk_new_note_email)
- .with(issue.id, note.id)
+ .with(issue.id, note.id, issue.external_author)
end
def should_not_email!
@@ -347,33 +365,19 @@ RSpec.describe NotificationService, :mailer do
let(:project) { issue.project }
let(:note) { create(:note, noteable: issue, project: project) }
- context 'a non-service-desk issue' do
+ context 'do not exist' do
it_should_not_email!
end
- context 'a service-desk issue' do
+ context 'do exist' do
+ let!(:issue_email_participant) { issue.issue_email_participants.create!(email: 'service.desk@example.com') }
+
before do
issue.update!(external_author: 'service.desk@example.com')
project.update!(service_desk_enabled: true)
end
it_should_email!
-
- context 'where the project has disabled the feature' do
- before do
- project.update!(service_desk_enabled: false)
- end
-
- it_should_not_email!
- end
-
- context 'when the support bot has unsubscribed' do
- before do
- issue.unsubscribe(User.support_bot, project)
- end
-
- it_should_not_email!
- end
end
end
@@ -881,8 +885,24 @@ RSpec.describe NotificationService, :mailer do
end
describe '#send_new_release_notifications', :deliver_mails_inline do
+ let(:release) { create(:release, author: current_user) }
+ let(:object) { release }
+ let(:action) { notification.send_new_release_notifications(release) }
+
+ context 'when release author is blocked' do
+ let(:current_user) { create(:user, :blocked) }
+
+ include_examples 'is not able to send notifications'
+ end
+
+ context 'when release author is a ghost' do
+ let(:current_user) { create(:user, :ghost) }
+
+ include_examples 'is not able to send notifications'
+ end
+
context 'when recipients for a new release exist' do
- let(:release) { create(:release) }
+ let(:current_user) { create(:user) }
it 'calls new_release_email for each relevant recipient' do
user_1 = create(:user)
@@ -1127,11 +1147,31 @@ RSpec.describe NotificationService, :mailer do
should_email(admin)
end
end
+
+ context 'when the author is not allowed to trigger notifications' do
+ let(:current_user) { nil }
+ let(:object) { issue }
+ let(:action) { notification.new_issue(issue, current_user) }
+
+ context 'because they are blocked' do
+ let(:current_user) { create(:user, :blocked) }
+
+ include_examples 'is not able to send notifications'
+ end
+
+ context 'because they are a ghost' do
+ let(:current_user) { create(:user, :ghost) }
+
+ include_examples 'is not able to send notifications'
+ end
+ end
end
describe '#new_mentions_in_issue' do
let(:notification_method) { :new_mentions_in_issue }
let(:mentionable) { issue }
+ let(:object) { mentionable }
+ let(:action) { send_notifications(@u_mentioned, current_user: current_user) }
include_examples 'notifications for new mentions'
@@ -1139,6 +1179,18 @@ RSpec.describe NotificationService, :mailer do
let(:notification_target) { issue }
let(:notification_trigger) { send_notifications(@u_watcher, @u_participant_mentioned, @u_custom_global, @u_mentioned) }
end
+
+ context 'where current_user is blocked' do
+ let(:current_user) { create(:user, :blocked) }
+
+ include_examples 'is not able to send notifications'
+ end
+
+ context 'where current_user is a ghost' do
+ let(:current_user) { create(:user, :ghost) }
+
+ include_examples 'is not able to send notifications'
+ end
end
describe '#reassigned_issue' do
@@ -1751,11 +1803,31 @@ RSpec.describe NotificationService, :mailer do
it { should_not_email(participant) }
end
end
+
+ context 'when the author is not allowed to trigger notifications' do
+ let(:current_user) { nil }
+ let(:object) { merge_request }
+ let(:action) { notification.new_merge_request(merge_request, current_user) }
+
+ context 'because they are blocked' do
+ let(:current_user) { create(:user, :blocked) }
+
+ it_behaves_like 'is not able to send notifications'
+ end
+
+ context 'because they are a ghost' do
+ let(:current_user) { create(:user, :ghost) }
+
+ it_behaves_like 'is not able to send notifications'
+ end
+ end
end
describe '#new_mentions_in_merge_request' do
let(:notification_method) { :new_mentions_in_merge_request }
let(:mentionable) { merge_request }
+ let(:object) { mentionable }
+ let(:action) { send_notifications(@u_mentioned, current_user: current_user) }
include_examples 'notifications for new mentions'
@@ -1763,6 +1835,18 @@ RSpec.describe NotificationService, :mailer do
let(:notification_target) { merge_request }
let(:notification_trigger) { send_notifications(@u_watcher, @u_participant_mentioned, @u_custom_global, @u_mentioned) }
end
+
+ context 'where current_user is blocked' do
+ let(:current_user) { create(:user, :blocked) }
+
+ include_examples 'is not able to send notifications'
+ end
+
+ context 'where current_user is a ghost' do
+ let(:current_user) { create(:user, :ghost) }
+
+ include_examples 'is not able to send notifications'
+ end
end
describe '#reassigned_merge_request' do
@@ -1867,6 +1951,42 @@ RSpec.describe NotificationService, :mailer do
end
end
+ describe '#change_in_merge_request_draft_status' do
+ let(:merge_request) { create(:merge_request, author: author, source_project: project) }
+
+ let_it_be(:current_user) { create(:user) }
+
+ it 'sends emails to relevant users only', :aggregate_failures do
+ notification.change_in_merge_request_draft_status(merge_request, current_user)
+
+ merge_request.reviewers.each { |reviewer| should_email(reviewer) }
+ merge_request.assignees.each { |assignee| should_email(assignee) }
+ should_email(merge_request.author)
+ should_email(@u_watcher)
+ should_email(@subscriber)
+ should_email(@watcher_and_subscriber)
+ should_email(@u_guest_watcher)
+ should_not_email(@u_participant_mentioned)
+ should_not_email(@u_guest_custom)
+ should_not_email(@u_custom_global)
+ should_not_email(@unsubscriber)
+ should_not_email(@u_participating)
+ should_not_email(@u_disabled)
+ should_not_email(@u_lazy_participant)
+ end
+
+ it_behaves_like 'participating notifications' do
+ let(:participant) { create(:user, username: 'user-participant') }
+ let(:issuable) { merge_request }
+ let(:notification_trigger) { notification.change_in_merge_request_draft_status(merge_request, @u_disabled) }
+ end
+
+ it_behaves_like 'project emails are disabled' do
+ let(:notification_target) { merge_request }
+ let(:notification_trigger) { notification.change_in_merge_request_draft_status(merge_request, @u_disabled) }
+ end
+ end
+
describe '#push_to_merge_request' do
before do
update_custom_notification(:push_to_merge_request, @u_guest_custom, resource: project)
@@ -2159,8 +2279,38 @@ RSpec.describe NotificationService, :mailer do
end
describe '#merge_when_pipeline_succeeds' do
+ before do
+ update_custom_notification(:merge_when_pipeline_succeeds, @u_guest_custom, resource: project)
+ update_custom_notification(:merge_when_pipeline_succeeds, @u_custom_global)
+ end
+
it 'send notification that merge will happen when pipeline succeeds' do
notification.merge_when_pipeline_succeeds(merge_request, assignee)
+
+ should_email(merge_request.author)
+ should_email(@u_watcher)
+ should_email(@subscriber)
+ should_email(@u_guest_custom)
+ should_email(@u_custom_global)
+ should_not_email(@unsubscriber)
+ should_not_email(@u_disabled)
+ end
+
+ it 'does not send notification if the custom event is disabled' do
+ update_custom_notification(:merge_when_pipeline_succeeds, @u_guest_custom, resource: project, value: false)
+ update_custom_notification(:merge_when_pipeline_succeeds, @u_custom_global, resource: nil, value: false)
+ notification.merge_when_pipeline_succeeds(merge_request, assignee)
+
+ should_not_email(@u_guest_custom)
+ should_not_email(@u_custom_global)
+ end
+
+ it 'sends notification to participants even if the custom event is disabled' do
+ update_custom_notification(:merge_when_pipeline_succeeds, merge_request.author, resource: project, value: false)
+ update_custom_notification(:merge_when_pipeline_succeeds, @u_watcher, resource: project, value: false)
+ update_custom_notification(:merge_when_pipeline_succeeds, @subscriber, resource: project, value: false)
+ notification.merge_when_pipeline_succeeds(merge_request, assignee)
+
should_email(merge_request.author)
should_email(@u_watcher)
should_email(@subscriber)
@@ -2694,7 +2844,7 @@ RSpec.describe NotificationService, :mailer do
end
it 'filters out guests when new merge request is created' do
- notification.new_merge_request(merge_request1, @u_disabled)
+ notification.new_merge_request(merge_request1, developer)
should_not_email(guest)
should_email(assignee)
diff --git a/spec/services/onboarding_progress_service_spec.rb b/spec/services/onboarding_progress_service_spec.rb
index 340face4ae8..ef4f4f0d822 100644
--- a/spec/services/onboarding_progress_service_spec.rb
+++ b/spec/services/onboarding_progress_service_spec.rb
@@ -3,9 +3,49 @@
require 'spec_helper'
RSpec.describe OnboardingProgressService do
+ describe '.async' do
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:action) { :git_pull }
+
+ subject(:execute_service) { described_class.async(namespace.id).execute(action: action) }
+
+ context 'when not onboarded' do
+ it 'does not schedule a worker' do
+ expect(Namespaces::OnboardingProgressWorker).not_to receive(:perform_async)
+
+ execute_service
+ end
+ end
+
+ context 'when onboarded' do
+ before do
+ OnboardingProgress.onboard(namespace)
+ end
+
+ context 'when action is already completed' do
+ before do
+ OnboardingProgress.register(namespace, action)
+ end
+
+ it 'does not schedule a worker' do
+ expect(Namespaces::OnboardingProgressWorker).not_to receive(:perform_async)
+
+ execute_service
+ end
+ end
+
+ context 'when action is not yet completed' do
+ it 'schedules a worker' do
+ expect(Namespaces::OnboardingProgressWorker).to receive(:perform_async)
+
+ execute_service
+ end
+ end
+ end
+ end
+
describe '#execute' do
- let(:namespace) { create(:namespace, parent: root_namespace) }
- let(:root_namespace) { nil }
+ let(:namespace) { create(:namespace) }
let(:action) { :namespace_action }
subject(:execute_service) { described_class.new(namespace).execute(action: :subscription_created) }
@@ -23,16 +63,16 @@ RSpec.describe OnboardingProgressService do
end
context 'when the namespace is not the root' do
- let(:root_namespace) { build(:namespace) }
+ let(:group) { create(:group, :nested) }
before do
- OnboardingProgress.onboard(root_namespace)
+ OnboardingProgress.onboard(group)
end
- it 'registers a namespace onboarding progress action for the root namespace' do
+ it 'does not register a namespace onboarding progress action' do
execute_service
- expect(OnboardingProgress.completed?(root_namespace, :subscription_created)).to eq(true)
+ expect(OnboardingProgress.completed?(group, :subscription_created)).to be(nil)
end
end
@@ -42,7 +82,7 @@ RSpec.describe OnboardingProgressService do
it 'does not register a namespace onboarding progress action' do
execute_service
- expect(OnboardingProgress.completed?(root_namespace, :subscription_created)).to be(nil)
+ expect(OnboardingProgress.completed?(namespace, :subscription_created)).to be(nil)
end
end
end
diff --git a/spec/services/packages/composer/create_package_service_spec.rb b/spec/services/packages/composer/create_package_service_spec.rb
index 4f1a46e7e45..526c7b4929b 100644
--- a/spec/services/packages/composer/create_package_service_spec.rb
+++ b/spec/services/packages/composer/create_package_service_spec.rb
@@ -28,6 +28,8 @@ RSpec.describe Packages::Composer::CreatePackageService do
let(:branch) { project.repository.find_branch('master') }
it 'creates the package' do
+ expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, nil)
+
expect { subject }
.to change { Packages::Package.composer.count }.by(1)
.and change { Packages::Composer::Metadatum.count }.by(1)
@@ -54,6 +56,8 @@ RSpec.describe Packages::Composer::CreatePackageService do
end
it 'creates the package' do
+ expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, nil)
+
expect { subject }
.to change { Packages::Package.composer.count }.by(1)
.and change { Packages::Composer::Metadatum.count }.by(1)
@@ -80,6 +84,8 @@ RSpec.describe Packages::Composer::CreatePackageService do
end
it 'does not create a new package' do
+ expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, nil)
+
expect { subject }
.to change { Packages::Package.composer.count }.by(0)
.and change { Packages::Composer::Metadatum.count }.by(0)
@@ -101,6 +107,8 @@ RSpec.describe Packages::Composer::CreatePackageService do
let!(:other_package) { create(:package, name: package_name, version: 'dev-master', project: other_project) }
it 'creates the package' do
+ expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, nil)
+
expect { subject }
.to change { Packages::Package.composer.count }.by(1)
.and change { Packages::Composer::Metadatum.count }.by(1)
diff --git a/spec/services/packages/create_event_service_spec.rb b/spec/services/packages/create_event_service_spec.rb
index f7bab0e5a9f..122f1e88ad0 100644
--- a/spec/services/packages/create_event_service_spec.rb
+++ b/spec/services/packages/create_event_service_spec.rb
@@ -57,18 +57,6 @@ RSpec.describe Packages::CreateEventService do
end
shared_examples 'redis package unique event creation' do |originator_type, expected_scope|
- context 'with feature flag disable' do
- before do
- stub_feature_flags(collect_package_events_redis: false)
- end
-
- it 'does not track the event' do
- expect(::Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
-
- subject
- end
- end
-
it 'tracks the event' do
expect(::Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(/package/, values: user.id)
@@ -77,18 +65,6 @@ RSpec.describe Packages::CreateEventService do
end
shared_examples 'redis package count event creation' do |originator_type, expected_scope|
- context 'with feature flag disabled' do
- before do
- stub_feature_flags(collect_package_events_redis: false)
- end
-
- it 'does not track the event' do
- expect(::Gitlab::UsageDataCounters::PackageEventCounter).not_to receive(:count)
-
- subject
- end
- end
-
it 'tracks the event' do
expect(::Gitlab::UsageDataCounters::PackageEventCounter).to receive(:count).at_least(:once)
diff --git a/spec/services/packages/create_temporary_package_service_spec.rb b/spec/services/packages/create_temporary_package_service_spec.rb
new file mode 100644
index 00000000000..4b8d37401d8
--- /dev/null
+++ b/spec/services/packages/create_temporary_package_service_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::CreateTemporaryPackageService do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:params) { {} }
+ let_it_be(:package_name) { 'my-package' }
+ let_it_be(:package_type) { 'rubygems' }
+
+ describe '#execute' do
+ subject { described_class.new(project, user, params).execute(package_type, name: package_name) }
+
+ let(:package) { Packages::Package.last }
+
+ it 'creates the package', :aggregate_failures do
+ expect { subject }.to change { Packages::Package.count }.by(1)
+
+ expect(package).to be_valid
+ expect(package).to be_processing
+ expect(package.name).to eq(package_name)
+ expect(package.version).to start_with(described_class::PACKAGE_VERSION)
+ expect(package.package_type).to eq(package_type)
+ end
+
+ it 'can create two packages in a row', :aggregate_failures do
+ expect { subject }.to change { Packages::Package.count }.by(1)
+
+ expect do
+ described_class.new(project, user, params).execute(package_type, name: package_name)
+ end.to change { Packages::Package.count }.by(1)
+
+ expect(package).to be_valid
+ expect(package).to be_processing
+ expect(package.name).to eq(package_name)
+ expect(package.version).to start_with(described_class::PACKAGE_VERSION)
+ expect(package.package_type).to eq(package_type)
+ end
+
+ it_behaves_like 'assigns the package creator'
+ it_behaves_like 'assigns build to package'
+ end
+end
diff --git a/spec/services/packages/debian/get_or_create_incoming_service_spec.rb b/spec/services/packages/debian/find_or_create_incoming_service_spec.rb
index ab99b091246..e1393c774b1 100644
--- a/spec/services/packages/debian/get_or_create_incoming_service_spec.rb
+++ b/spec/services/packages/debian/find_or_create_incoming_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Packages::Debian::GetOrCreateIncomingService do
+RSpec.describe Packages::Debian::FindOrCreateIncomingService do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
diff --git a/spec/services/packages/debian/find_or_create_package_service_spec.rb b/spec/services/packages/debian/find_or_create_package_service_spec.rb
new file mode 100644
index 00000000000..3582b1f1dc3
--- /dev/null
+++ b/spec/services/packages/debian/find_or_create_package_service_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::Debian::FindOrCreatePackageService do
+ let_it_be(:distribution) { create(:debian_project_distribution) }
+ let_it_be(:project) { distribution.project }
+ let_it_be(:user) { create(:user) }
+ let(:params) { { name: 'foo', version: '1.0+debian', distribution_name: distribution.codename } }
+
+ subject(:service) { described_class.new(project, user, params) }
+
+ describe '#execute' do
+ subject { service.execute }
+
+ let(:package) { subject.payload[:package] }
+
+ context 'run once' do
+ it 'creates a new package', :aggregate_failures do
+ expect { subject }.to change { ::Packages::Package.count }.by(1)
+ expect(subject).to be_success
+
+ expect(package).to be_valid
+ expect(package.project_id).to eq(project.id)
+ expect(package.creator_id).to eq(user.id)
+ expect(package.name).to eq('foo')
+ expect(package.version).to eq('1.0+debian')
+ expect(package).to be_debian
+ expect(package.debian_publication.distribution).to eq(distribution)
+ end
+ end
+
+ context 'run twice' do
+ let(:subject2) { service.execute }
+
+ let(:package2) { service.execute.payload[:package] }
+
+ it 'returns the same object' do
+ expect { subject }.to change { ::Packages::Package.count }.by(1)
+ expect { package2 }.not_to change { ::Packages::Package.count }
+
+ expect(package2.id).to eq(package.id)
+ end
+ end
+
+ context 'with non-existing distribution' do
+ let(:params) { { name: 'foo', version: '1.0+debian', distribution_name: 'not-existing' } }
+
+ it 'raises ActiveRecord::RecordNotFound' do
+ expect { package }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+ end
+end
diff --git a/spec/services/packages/maven/metadata/append_package_file_service_spec.rb b/spec/services/packages/maven/metadata/append_package_file_service_spec.rb
new file mode 100644
index 00000000000..c406ab93630
--- /dev/null
+++ b/spec/services/packages/maven/metadata/append_package_file_service_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Packages::Maven::Metadata::AppendPackageFileService do
+ let_it_be(:package) { create(:maven_package, version: nil) }
+
+ let(:service) { described_class.new(package: package, metadata_content: content) }
+ let(:content) { 'test' }
+
+ describe '#execute' do
+ subject { service.execute }
+
+ context 'with some content' do
+ it 'creates all the related package files', :aggregate_failures do
+ expect { subject }.to change { package.package_files.count }.by(5)
+ expect(subject).to be_success
+
+ expect_file(metadata_file_name, with_content: content, with_content_type: 'application/xml')
+ expect_file("#{metadata_file_name}.md5")
+ expect_file("#{metadata_file_name}.sha1")
+ expect_file("#{metadata_file_name}.sha256")
+ expect_file("#{metadata_file_name}.sha512")
+ end
+ end
+
+ context 'with nil content' do
+ let(:content) { nil }
+
+ it_behaves_like 'returning an error service response', message: 'metadata content is not set'
+ end
+
+ context 'with nil package' do
+ let(:package) { nil }
+
+ it_behaves_like 'returning an error service response', message: 'package is not set'
+ end
+
+ def expect_file(file_name, with_content: nil, with_content_type: '')
+ package_file = package.package_files.recent.with_file_name(file_name).first
+
+ expect(package_file.file).to be_present
+ expect(package_file.file_name).to eq(file_name)
+ expect(package_file.size).to be > 0
+ expect(package_file.file_md5).to be_present
+ expect(package_file.file_sha1).to be_present
+ expect(package_file.file_sha256).to be_present
+ expect(package_file.file.content_type).to eq(with_content_type)
+
+ if with_content
+ expect(package_file.file.read).to eq(with_content)
+ end
+ end
+
+ def metadata_file_name
+ ::Packages::Maven::Metadata.filename
+ end
+ end
+end
diff --git a/spec/services/packages/maven/metadata/create_plugins_xml_service_spec.rb b/spec/services/packages/maven/metadata/create_plugins_xml_service_spec.rb
new file mode 100644
index 00000000000..6fc1087940d
--- /dev/null
+++ b/spec/services/packages/maven/metadata/create_plugins_xml_service_spec.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Packages::Maven::Metadata::CreatePluginsXmlService do
+ let_it_be(:group_id) { 'my/test' }
+ let_it_be(:package) { create(:maven_package, name: group_id, version: nil) }
+
+ let(:plugins_in_database) { %w[one-maven-plugin two three-maven-plugin] }
+ let(:plugins_in_xml) { %w[one-maven-plugin two three-maven-plugin] }
+ let(:service) { described_class.new(metadata_content: metadata_xml, package: package) }
+
+ describe '#execute' do
+ subject { service.execute }
+
+ before do
+ next unless package
+
+ plugins_in_database.each do |plugin|
+ create(
+ :maven_package,
+ name: "#{group_id}/#{plugin}",
+ version: '1.0.0',
+ project: package.project,
+ maven_metadatum_attributes: {
+ app_group: group_id.tr('/', '.'),
+ app_name: plugin,
+ app_version: '1.0.0'
+ }
+ )
+ end
+ end
+
+ shared_examples 'returning an xml with plugins from the database' do
+ it 'returns an metadata versions xml with versions in the database', :aggregate_failures do
+ expect(subject).to be_success
+ expect(subject.payload[:changes_exist]).to eq(true)
+ expect(subject.payload[:empty_versions]).to eq(false)
+ expect(plugins_from(subject.payload[:metadata_content])).to match_array(plugins_in_database)
+ end
+ end
+
+ shared_examples 'returning no changes' do
+ it 'returns no changes', :aggregate_failures do
+ expect(subject).to be_success
+ expect(subject.payload).to eq(changes_exist: false, empty_versions: false)
+ end
+ end
+
+ context 'with same plugins on both sides' do
+ it_behaves_like 'returning no changes'
+ end
+
+ context 'with more plugins' do
+ let(:additional_plugins) { %w[four-maven-plugin five] }
+
+ context 'in database' do
+ let(:plugins_in_database) { plugins_in_xml + additional_plugins }
+
+ # we can't distinguish that the additional plugin are actually maven plugins
+ it_behaves_like 'returning no changes'
+ end
+
+ context 'in xml' do
+ let(:plugins_in_xml) { plugins_in_database + additional_plugins }
+
+ it_behaves_like 'returning an xml with plugins from the database'
+ end
+ end
+
+ context 'with no versions in the database' do
+ let(:plugins_in_database) { [] }
+
+ it 'returns a success', :aggregate_failures do
+ result = subject
+
+ expect(result).to be_success
+ expect(result.payload).to eq(changes_exist: true, empty_plugins: true)
+ end
+ end
+
+ context 'with an incomplete metadata content' do
+ let(:metadata_xml) { '<metadata></metadata>' }
+
+ it_behaves_like 'returning an error service response', message: 'metadata_content is invalid'
+ end
+
+ context 'with an invalid metadata content' do
+ let(:metadata_xml) { '<meta></metadata>' }
+
+ it_behaves_like 'returning an error service response', message: 'metadata_content is invalid'
+ end
+
+ it_behaves_like 'handling metadata content pointing to a file for the create xml service'
+
+ it_behaves_like 'handling invalid parameters for create xml service'
+ end
+
+ def metadata_xml
+ Nokogiri::XML::Builder.new do |xml|
+ xml.metadata do
+ xml.plugins do
+ plugins_in_xml.each do |plugin|
+ xml.plugin do
+ xml.name(plugin)
+ xml.prefix(prefix_from(plugin))
+ xml.artifactId(plugin)
+ end
+ end
+ end
+ end
+ end.to_xml
+ end
+
+ def prefix_from(artifact_id)
+ artifact_id.gsub(/-?maven-?/, '')
+ .gsub(/-?plugin-?/, '')
+ end
+
+ def plugins_from(xml_content)
+ doc = Nokogiri::XML(xml_content)
+ doc.xpath('//metadata/plugins/plugin/artifactId').map(&:content)
+ end
+end
diff --git a/spec/services/packages/maven/metadata/create_versions_xml_service_spec.rb b/spec/services/packages/maven/metadata/create_versions_xml_service_spec.rb
new file mode 100644
index 00000000000..39c6feb5d12
--- /dev/null
+++ b/spec/services/packages/maven/metadata/create_versions_xml_service_spec.rb
@@ -0,0 +1,227 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Packages::Maven::Metadata::CreateVersionsXmlService do
+ let_it_be(:package) { create(:maven_package, version: nil) }
+
+ let(:versions_in_database) { %w[1.3 2.0-SNAPSHOT 1.6 1.4 1.5-SNAPSHOT] }
+ let(:versions_in_xml) { %w[1.3 2.0-SNAPSHOT 1.6 1.4 1.5-SNAPSHOT] }
+ let(:version_latest) { nil }
+ let(:version_release) { '1.4' }
+ let(:service) { described_class.new(metadata_content: metadata_xml, package: package) }
+
+ describe '#execute' do
+ subject { service.execute }
+
+ before do
+ next unless package
+
+ versions_in_database.each do |version|
+ create(:maven_package, name: package.name, version: version, project: package.project)
+ end
+ end
+
+ shared_examples 'returning an xml with versions in the database' do
+ it 'returns an metadata versions xml with versions in the database', :aggregate_failures do
+ result = subject
+
+ expect(result).to be_success
+ expect(versions_from(result.payload[:metadata_content])).to match_array(versions_in_database)
+ end
+ end
+
+ shared_examples 'returning an xml with' do |release:, latest:|
+ it 'returns an xml with the updated release and latest versions', :aggregate_failures do
+ result = subject
+
+ expect(result).to be_success
+ expect(result.payload[:changes_exist]).to be_truthy
+ xml = result.payload[:metadata_content]
+ expect(release_from(xml)).to eq(release)
+ expect(latest_from(xml)).to eq(latest)
+ end
+ end
+
+ context 'with same versions in both sides' do
+ it 'returns no changes', :aggregate_failures do
+ result = subject
+
+ expect(result).to be_success
+ expect(result.payload).to eq(changes_exist: false, empty_versions: false)
+ end
+ end
+
+ context 'with more versions' do
+ let(:additional_versions) { %w[5.5 5.6 5.7-SNAPSHOT] }
+
+ context 'in the xml side' do
+ let(:versions_in_xml) { versions_in_database + additional_versions }
+
+ it_behaves_like 'returning an xml with versions in the database'
+ end
+
+ context 'in the database side' do
+ let(:versions_in_database) { versions_in_xml + additional_versions }
+
+ it_behaves_like 'returning an xml with versions in the database'
+ end
+ end
+
+ context 'with completely different versions' do
+ let(:versions_in_database) { %w[1.0 1.1 1.2] }
+ let(:versions_in_xml) { %w[2.0 2.1 2.2] }
+
+ it_behaves_like 'returning an xml with versions in the database'
+ end
+
+ context 'with no versions in the database' do
+ let(:versions_in_database) { [] }
+
+ it 'returns a success', :aggregate_failures do
+ result = subject
+
+ expect(result).to be_success
+ expect(result.payload).to eq(changes_exist: true, empty_versions: true)
+ end
+
+ context 'with an xml without a release version' do
+ let(:version_release) { nil }
+
+ it 'returns a success', :aggregate_failures do
+ result = subject
+
+ expect(result).to be_success
+ expect(result.payload).to eq(changes_exist: true, empty_versions: true)
+ end
+ end
+ end
+
+ context 'with differences in both sides' do
+ let(:shared_versions) { %w[1.3 2.0-SNAPSHOT 1.6 1.4 1.5-SNAPSHOT] }
+ let(:additional_versions_in_xml) { %w[5.5 5.6 5.7-SNAPSHOT] }
+ let(:versions_in_xml) { shared_versions + additional_versions_in_xml }
+ let(:additional_versions_in_database) { %w[6.5 6.6 6.7-SNAPSHOT] }
+ let(:versions_in_database) { shared_versions + additional_versions_in_database }
+
+ it_behaves_like 'returning an xml with versions in the database'
+ end
+
+ context 'with a new release and latest from the database' do
+ let(:versions_in_database) { versions_in_xml + %w[4.1 4.2-SNAPSHOT] }
+
+ it_behaves_like 'returning an xml with', release: '4.1', latest: nil
+
+ context 'with a latest in the xml' do
+ let(:version_latest) { '1.6' }
+
+ it_behaves_like 'returning an xml with', release: '4.1', latest: '4.2-SNAPSHOT'
+ end
+ end
+
+ context 'with release and latest not existing in the database' do
+ let(:version_release) { '7.0' }
+ let(:version_latest) { '8.0-SNAPSHOT' }
+
+ it_behaves_like 'returning an xml with', release: '1.4', latest: '1.5-SNAPSHOT'
+ end
+
+ context 'with added versions in the database side no more recent than release' do
+ let(:versions_in_database) { versions_in_xml + %w[4.1 4.2-SNAPSHOT] }
+
+ before do
+ ::Packages::Package.find_by(name: package.name, version: '4.1').update!(created_at: 2.weeks.ago)
+ ::Packages::Package.find_by(name: package.name, version: '4.2-SNAPSHOT').update!(created_at: 2.weeks.ago)
+ end
+
+ it_behaves_like 'returning an xml with', release: '1.4', latest: nil
+
+ context 'with a latest in the xml' do
+ let(:version_latest) { '1.6' }
+
+ it_behaves_like 'returning an xml with', release: '1.4', latest: '1.5-SNAPSHOT'
+ end
+ end
+
+ context 'only snapshot versions are in the database' do
+ let(:versions_in_database) { %w[4.2-SNAPSHOT] }
+
+ it_behaves_like 'returning an xml with', release: nil, latest: nil
+
+ it 'returns an xml without any release element' do
+ result = subject
+
+ xml_doc = Nokogiri::XML(result.payload[:metadata_content])
+ expect(xml_doc.xpath('//metadata/versioning/release')).to be_empty
+ end
+ end
+
+ context 'last updated timestamp' do
+ let(:versions_in_database) { versions_in_xml + %w[4.1 4.2-SNAPSHOT] }
+
+ it 'updates the last updated timestamp' do
+ original = last_updated_from(metadata_xml)
+
+ result = subject
+
+ expect(result).to be_success
+ expect(original).not_to eq(last_updated_from(result.payload[:metadata_content]))
+ end
+ end
+
+ context 'with an incomplete metadata content' do
+ let(:metadata_xml) { '<metadata></metadata>' }
+
+ it_behaves_like 'returning an error service response', message: 'metadata_content is invalid'
+ end
+
+ context 'with an invalid metadata content' do
+ let(:metadata_xml) { '<meta></metadata>' }
+
+ it_behaves_like 'returning an error service response', message: 'metadata_content is invalid'
+ end
+
+ it_behaves_like 'handling metadata content pointing to a file for the create xml service'
+
+ it_behaves_like 'handling invalid parameters for create xml service'
+ end
+
+ def metadata_xml
+ Nokogiri::XML::Builder.new do |xml|
+ xml.metadata do
+ xml.groupId(package.maven_metadatum.app_group)
+ xml.artifactId(package.maven_metadatum.app_name)
+ xml.versioning do
+ xml.release(version_release) if version_release
+ xml.latest(version_latest) if version_latest
+ xml.lastUpdated('20210113130531')
+ xml.versions do
+ versions_in_xml.each do |version|
+ xml.version(version)
+ end
+ end
+ end
+ end
+ end.to_xml
+ end
+
+ def versions_from(xml_content)
+ doc = Nokogiri::XML(xml_content)
+ doc.xpath('//metadata/versioning/versions/version').map(&:content)
+ end
+
+ def release_from(xml_content)
+ doc = Nokogiri::XML(xml_content)
+ doc.xpath('//metadata/versioning/release').first&.content
+ end
+
+ def latest_from(xml_content)
+ doc = Nokogiri::XML(xml_content)
+ doc.xpath('//metadata/versioning/latest').first&.content
+ end
+
+ def last_updated_from(xml_content)
+ doc = Nokogiri::XML(xml_content)
+ doc.xpath('//metadata/versioning/lastUpdated').first.content
+ end
+end
diff --git a/spec/services/packages/maven/metadata/sync_service_spec.rb b/spec/services/packages/maven/metadata/sync_service_spec.rb
new file mode 100644
index 00000000000..f5634159e6d
--- /dev/null
+++ b/spec/services/packages/maven/metadata/sync_service_spec.rb
@@ -0,0 +1,259 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Packages::Maven::Metadata::SyncService do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be_with_reload(:versionless_package_for_versions) { create(:maven_package, name: 'test', version: nil, project: project) }
+ let_it_be_with_reload(:metadata_file_for_versions) { create(:package_file, :xml, package: versionless_package_for_versions) }
+
+ let(:service) { described_class.new(container: project, current_user: user, params: { package_name: versionless_package_for_versions.name }) }
+
+ describe '#execute' do
+ let(:create_versions_xml_service_double) { double(::Packages::Maven::Metadata::CreateVersionsXmlService, execute: create_versions_xml_service_response) }
+ let(:append_package_file_service_double) { double(::Packages::Maven::Metadata::AppendPackageFileService, execute: append_package_file_service_response) }
+
+ let(:create_versions_xml_service_response) { ServiceResponse.success(payload: { changes_exist: true, empty_versions: false, metadata_content: 'test' }) }
+ let(:append_package_file_service_response) { ServiceResponse.success(message: 'New metadata package files created') }
+
+ subject { service.execute }
+
+ before do
+ allow(::Packages::Maven::Metadata::CreateVersionsXmlService)
+ .to receive(:new).with(metadata_content: an_instance_of(ObjectStorage::Concern::OpenFile), package: versionless_package_for_versions).and_return(create_versions_xml_service_double)
+ allow(::Packages::Maven::Metadata::AppendPackageFileService)
+ .to receive(:new).with(metadata_content: an_instance_of(String), package: versionless_package_for_versions).and_return(append_package_file_service_double)
+ end
+
+ context 'permissions' do
+ where(:role, :expected_result) do
+ :anonymous | :rejected
+ :developer | :rejected
+ :maintainer | :accepted
+ end
+
+ with_them do
+ if params[:role] == :anonymous
+ let_it_be(:user) { nil }
+ end
+
+ before do
+ project.send("add_#{role}", user) unless role == :anonymous
+ end
+
+ if params[:expected_result] == :rejected
+ it_behaves_like 'returning an error service response', message: 'Not allowed'
+ else
+ it_behaves_like 'returning a success service response', message: 'New metadata package files created'
+ end
+ end
+ end
+
+ context 'with a maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ context 'with a jar package' do
+ before do
+ expect(::Packages::Maven::Metadata::CreatePluginsXmlService).not_to receive(:new)
+ end
+
+ context 'with no changes' do
+ let(:create_versions_xml_service_response) { ServiceResponse.success(payload: { changes_exist: false }) }
+
+ before do
+ expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new)
+ end
+
+ it_behaves_like 'returning a success service response', message: 'No changes for versions xml'
+ end
+
+ context 'with changes' do
+ let(:create_versions_xml_service_response) { ServiceResponse.success(payload: { changes_exist: true, empty_versions: false, metadata_content: 'new metadata' }) }
+
+ it_behaves_like 'returning a success service response', message: 'New metadata package files created'
+
+ context 'with empty versions' do
+ let(:create_versions_xml_service_response) { ServiceResponse.success(payload: { changes_exist: true, empty_versions: true }) }
+
+ before do
+ expect(service.send(:versionless_package_for_versions)).to receive(:destroy!)
+ expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new)
+ end
+
+ it_behaves_like 'returning a success service response', message: 'Versionless package for versions destroyed'
+ end
+ end
+
+ context 'with a too big maven metadata file for versions' do
+ before do
+ metadata_file_for_versions.update!(size: 100.megabytes)
+ end
+
+ it_behaves_like 'returning an error service response', message: 'Metadata file for versions is too big'
+ end
+
+ context 'an error from the create versions xml service' do
+ let(:create_versions_xml_service_response) { ServiceResponse.error(message: 'metadata_content is invalid') }
+
+ before do
+ expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new)
+ end
+
+ it_behaves_like 'returning an error service response', message: 'metadata_content is invalid'
+ end
+
+ context 'an error from the append package file service' do
+ let(:append_package_file_service_response) { ServiceResponse.error(message: 'metadata content is not set') }
+
+ it_behaves_like 'returning an error service response', message: 'metadata content is not set'
+ end
+
+ context 'without a package name' do
+ let(:service) { described_class.new(container: project, current_user: user, params: { package_name: nil }) }
+
+ before do
+ expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new)
+ expect(::Packages::Maven::Metadata::CreateVersionsXmlService).not_to receive(:new)
+ end
+
+ it_behaves_like 'returning an error service response', message: 'Blank package name'
+ end
+
+ context 'without a versionless package for version' do
+ before do
+ versionless_package_for_versions.update!(version: '2.2.2')
+ expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new)
+ expect(::Packages::Maven::Metadata::CreateVersionsXmlService).not_to receive(:new)
+ end
+
+ it_behaves_like 'returning an error service response', message: 'Non existing versionless package'
+ end
+
+ context 'without a metadata package file for versions' do
+ before do
+ versionless_package_for_versions.package_files.update_all(file_name: 'test.txt')
+ expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new)
+ expect(::Packages::Maven::Metadata::CreateVersionsXmlService).not_to receive(:new)
+ end
+
+ it_behaves_like 'returning an error service response', message: 'Non existing metadata file for versions'
+ end
+
+ context 'without a project' do
+ let(:service) { described_class.new(container: nil, current_user: user, params: { package_name: versionless_package_for_versions.name }) }
+
+ before do
+ expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new)
+ expect(::Packages::Maven::Metadata::CreateVersionsXmlService).not_to receive(:new)
+ end
+
+ it_behaves_like 'returning an error service response', message: 'Not allowed'
+ end
+ end
+
+ context 'with a maven plugin package' do
+ let_it_be(:versionless_package_name_for_plugins) { versionless_package_for_versions.maven_metadatum.app_group.tr('.', '/') }
+ let_it_be_with_reload(:versionless_package_for_plugins) { create(:maven_package, name: versionless_package_name_for_plugins, version: nil, project: project) }
+ let_it_be_with_reload(:metadata_file_for_plugins) { create(:package_file, :xml, package: versionless_package_for_plugins) }
+
+ let(:create_plugins_xml_service_double) { double(::Packages::Maven::Metadata::CreatePluginsXmlService, execute: create_plugins_xml_service_response) }
+ let(:create_plugins_xml_service_response) { ServiceResponse.success(payload: { changes_exist: false }) }
+
+ before do
+ allow(::Packages::Maven::Metadata::CreatePluginsXmlService)
+ .to receive(:new).with(metadata_content: an_instance_of(ObjectStorage::Concern::OpenFile), package: versionless_package_for_plugins).and_return(create_plugins_xml_service_double)
+ allow(::Packages::Maven::Metadata::AppendPackageFileService)
+ .to receive(:new).with(metadata_content: an_instance_of(String), package: versionless_package_for_plugins).and_return(append_package_file_service_double)
+ end
+
+ context 'with no changes' do
+ let(:create_versions_xml_service_response) { ServiceResponse.success(payload: { changes_exist: false }) }
+
+ before do
+ expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new)
+ end
+
+ it_behaves_like 'returning a success service response', message: 'No changes for versions xml'
+ end
+
+ context 'with changes in the versions xml' do
+ let(:create_versions_xml_service_response) { ServiceResponse.success(payload: { changes_exist: true, empty_versions: false, metadata_content: 'new metadata' }) }
+
+ it_behaves_like 'returning a success service response', message: 'New metadata package files created'
+
+ context 'with changes in the plugin xml' do
+ let(:create_plugins_xml_service_response) { ServiceResponse.success(payload: { changes_exist: true, empty_plugins: false, metadata_content: 'new metadata' }) }
+
+ it_behaves_like 'returning a success service response', message: 'New metadata package files created'
+ end
+
+ context 'with empty versions' do
+ let(:create_versions_xml_service_response) { ServiceResponse.success(payload: { changes_exist: true, empty_versions: true }) }
+ let(:create_plugins_xml_service_response) { ServiceResponse.success(payload: { changes_exist: true, empty_plugins: true }) }
+
+ before do
+ expect(service.send(:versionless_package_for_versions)).to receive(:destroy!)
+ expect(service.send(:metadata_package_file_for_plugins).package).to receive(:destroy!)
+ expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new)
+ end
+
+ it_behaves_like 'returning a success service response', message: 'Versionless package for versions destroyed'
+ end
+
+ context 'with a too big maven metadata file for versions' do
+ before do
+ metadata_file_for_plugins.update!(size: 100.megabytes)
+ end
+
+ it_behaves_like 'returning an error service response', message: 'Metadata file for plugins is too big'
+ end
+
+ context 'an error from the create versions xml service' do
+ let(:create_plugins_xml_service_response) { ServiceResponse.error(message: 'metadata_content is invalid') }
+
+ before do
+ expect(::Packages::Maven::Metadata::CreateVersionsXmlService).not_to receive(:new)
+ expect(::Packages::Maven::Metadata::AppendPackageFileService).not_to receive(:new)
+ end
+
+ it_behaves_like 'returning an error service response', message: 'metadata_content is invalid'
+ end
+
+ context 'an error from the append package file service' do
+ let(:create_plugins_xml_service_response) { ServiceResponse.success(payload: { changes_exist: true, empty_plugins: false, metadata_content: 'new metadata' }) }
+ let(:append_package_file_service_response) { ServiceResponse.error(message: 'metadata content is not set') }
+
+ before do
+ expect(::Packages::Maven::Metadata::CreateVersionsXmlService).not_to receive(:new)
+ end
+
+ it_behaves_like 'returning an error service response', message: 'metadata content is not set'
+ end
+
+ context 'without a versionless package for plugins' do
+ before do
+ versionless_package_for_plugins.package_files.update_all(file_name: 'test.txt')
+ expect(::Packages::Maven::Metadata::CreatePluginsXmlService).not_to receive(:new)
+ end
+
+ it_behaves_like 'returning a success service response', message: 'New metadata package files created'
+ end
+
+ context 'without a metadata package file for plugins' do
+ before do
+ versionless_package_for_plugins.package_files.update_all(file_name: 'test.txt')
+ expect(::Packages::Maven::Metadata::CreatePluginsXmlService).not_to receive(:new)
+ end
+
+ it_behaves_like 'returning a success service response', message: 'New metadata package files created'
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/packages/npm/create_package_service_spec.rb b/spec/services/packages/npm/create_package_service_spec.rb
index 10fce6c1651..ba5729eaf59 100644
--- a/spec/services/packages/npm/create_package_service_spec.rb
+++ b/spec/services/packages/npm/create_package_service_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe Packages::Npm::CreatePackageService do
end
let(:override) { {} }
- let(:package_name) { "@#{namespace.path}/my-app".freeze }
+ let(:package_name) { "@#{namespace.path}/my-app" }
subject { described_class.new(project, user, params).execute }
@@ -42,29 +42,35 @@ RSpec.describe Packages::Npm::CreatePackageService do
it { expect(subject.name).to eq(package_name) }
it { expect(subject.version).to eq(version) }
+
+ context 'with build info' do
+ let(:job) { create(:ci_build, user: user) }
+ let(:params) { super().merge(build: job) }
+
+ it_behaves_like 'assigns build to package'
+ it_behaves_like 'assigns status to package'
+
+ it 'creates a package file build info' do
+ expect { subject }.to change { Packages::PackageFileBuildInfo.count }.by(1)
+ end
+ end
end
describe '#execute' do
context 'scoped package' do
it_behaves_like 'valid package'
+ end
- context 'with build info' do
- let(:job) { create(:ci_build, user: user) }
- let(:params) { super().merge(build: job) }
-
- it_behaves_like 'assigns build to package'
- it_behaves_like 'assigns status to package'
+ context 'scoped package not following the naming convention' do
+ let(:package_name) { '@any-scope/package' }
- it 'creates a package file build info' do
- expect { subject }.to change { Packages::PackageFileBuildInfo.count }.by(1)
- end
- end
+ it_behaves_like 'valid package'
end
- context 'invalid package name' do
- let(:package_name) { "@#{namespace.path}/my-group/my-app".freeze }
+ context 'unscoped package' do
+ let(:package_name) { 'unscoped-package' }
- it { expect { subject }.to raise_error(ActiveRecord::RecordInvalid) }
+ it_behaves_like 'valid package'
end
context 'package already exists' do
@@ -84,11 +90,18 @@ RSpec.describe Packages::Npm::CreatePackageService do
it { expect(subject[:message]).to be 'File is too large.' }
end
- context 'with incorrect namespace' do
- let(:package_name) { '@my_other_namespace/my-app' }
-
- it 'raises a RecordInvalid error' do
- expect { subject }.to raise_error(ActiveRecord::RecordInvalid)
+ [
+ '@inv@lid_scope/package',
+ '@scope/sub/group',
+ '@scope/../../package',
+ '@scope%2e%2e%2fpackage'
+ ].each do |invalid_package_name|
+ context "with invalid name #{invalid_package_name}" do
+ let(:package_name) { invalid_package_name }
+
+ it 'raises a RecordInvalid error' do
+ expect { subject }.to raise_error(ActiveRecord::RecordInvalid)
+ end
end
end
diff --git a/spec/services/packages/nuget/create_package_service_spec.rb b/spec/services/packages/nuget/create_package_service_spec.rb
deleted file mode 100644
index e338ac36fc3..00000000000
--- a/spec/services/packages/nuget/create_package_service_spec.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe Packages::Nuget::CreatePackageService do
- let_it_be(:project) { create(:project) }
- let_it_be(:user) { create(:user) }
- let_it_be(:params) { {} }
-
- describe '#execute' do
- subject { described_class.new(project, user, params).execute }
-
- let(:package) { Packages::Package.last }
-
- it 'creates the package' do
- expect { subject }.to change { Packages::Package.count }.by(1)
-
- expect(package).to be_valid
- expect(package.name).to eq(Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME)
- expect(package.version).to start_with(Packages::Nuget::CreatePackageService::PACKAGE_VERSION)
- expect(package.package_type).to eq('nuget')
- end
-
- it 'can create two packages in a row' do
- expect { subject }.to change { Packages::Package.count }.by(1)
- expect { described_class.new(project, user, params).execute }.to change { Packages::Package.count }.by(1)
-
- expect(package).to be_valid
- expect(package.name).to eq(Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME)
- expect(package.version).to start_with(Packages::Nuget::CreatePackageService::PACKAGE_VERSION)
- expect(package.package_type).to eq('nuget')
- end
-
- it_behaves_like 'assigns the package creator'
- it_behaves_like 'assigns build to package'
- it_behaves_like 'assigns status to package'
- end
-end
diff --git a/spec/services/packages/nuget/update_package_from_metadata_service_spec.rb b/spec/services/packages/nuget/update_package_from_metadata_service_spec.rb
index 92b493ed376..c1cce46a54c 100644
--- a/spec/services/packages/nuget/update_package_from_metadata_service_spec.rb
+++ b/spec/services/packages/nuget/update_package_from_metadata_service_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe Packages::Nuget::UpdatePackageFromMetadataService, :clean_gitlab_redis_shared_state do
include ExclusiveLeaseHelpers
- let(:package) { create(:nuget_package) }
+ let(:package) { create(:nuget_package, :processing) }
let(:package_file) { package.package_files.first }
let(:service) { described_class.new(package_file) }
let(:package_name) { 'DummyProject.DummyPackage' }
@@ -60,6 +60,7 @@ RSpec.describe Packages::Nuget::UpdatePackageFromMetadataService, :clean_gitlab_
.to change { ::Packages::Package.count }.by(0)
.and change { Packages::DependencyLink.count }.by(0)
expect(package_file.reload.file_name).not_to eq(package_file_name)
+ expect(package_file.package).to be_processing
expect(package_file.package.reload.name).not_to eq(package_name)
expect(package_file.package.version).not_to eq(package_version)
end
@@ -78,6 +79,7 @@ RSpec.describe Packages::Nuget::UpdatePackageFromMetadataService, :clean_gitlab_
expect(package.reload.name).to eq(package_name)
expect(package.version).to eq(package_version)
+ expect(package).to be_default
expect(package_file.reload.file_name).to eq(package_file_name)
# hard reset needed to properly reload package_file.file
expect(Packages::PackageFile.find(package_file.id).file.size).not_to eq 0
@@ -184,6 +186,7 @@ RSpec.describe Packages::Nuget::UpdatePackageFromMetadataService, :clean_gitlab_
expect(package.reload.name).to eq(package_name)
expect(package.version).to eq(package_version)
+ expect(package).to be_default
expect(package_file.reload.file_name).to eq(package_file_name)
# hard reset needed to properly reload package_file.file
expect(Packages::PackageFile.find(package_file.id).file.size).not_to eq 0
diff --git a/spec/services/packages/rubygems/dependency_resolver_service_spec.rb b/spec/services/packages/rubygems/dependency_resolver_service_spec.rb
new file mode 100644
index 00000000000..206bffe53f8
--- /dev/null
+++ b/spec/services/packages/rubygems/dependency_resolver_service_spec.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Packages::Rubygems::DependencyResolverService do
+ let_it_be(:project) { create(:project, :private) }
+ let_it_be(:package) { create(:package, project: project) }
+ let_it_be(:user) { create(:user) }
+ let(:gem_name) { package.name }
+ let(:service) { described_class.new(project, user, gem_name: gem_name) }
+
+ describe '#execute' do
+ subject { service.execute }
+
+ context 'user without access' do
+ it 'returns a service error' do
+ expect(subject.error?).to be(true)
+ expect(subject.message).to eq('forbidden')
+ end
+ end
+
+ context 'user with access' do
+ before do
+ project.add_developer(user)
+ end
+
+ context 'when no package is found' do
+ let(:gem_name) { nil }
+
+ it 'returns a service error', :aggregate_failures do
+ expect(subject.error?).to be(true)
+ expect(subject.message).to eq("#{gem_name} not found")
+ end
+ end
+
+ context 'package without dependencies' do
+ it 'returns an empty dependencies array' do
+ expected_result = [{
+ name: package.name,
+ number: package.version,
+ platform: described_class::DEFAULT_PLATFORM,
+ dependencies: []
+ }]
+
+ expect(subject.payload).to eq(expected_result)
+ end
+ end
+
+ context 'package with dependencies' do
+ let(:dependency_link) { create(:packages_dependency_link, :rubygems, package: package)}
+ let(:dependency_link2) { create(:packages_dependency_link, :rubygems, package: package)}
+ let(:dependency_link3) { create(:packages_dependency_link, :rubygems, package: package)}
+
+ it 'returns a set of dependencies' do
+ expected_result = [{
+ name: package.name,
+ number: package.version,
+ platform: described_class::DEFAULT_PLATFORM,
+ dependencies: [
+ [dependency_link.dependency.name, dependency_link.dependency.version_pattern],
+ [dependency_link2.dependency.name, dependency_link2.dependency.version_pattern],
+ [dependency_link3.dependency.name, dependency_link3.dependency.version_pattern]
+ ]
+ }]
+
+ expect(subject.payload).to eq(expected_result)
+ end
+ end
+
+ context 'package with multiple versions' do
+ let(:dependency_link) { create(:packages_dependency_link, :rubygems, package: package)}
+ let(:dependency_link2) { create(:packages_dependency_link, :rubygems, package: package)}
+ let(:dependency_link3) { create(:packages_dependency_link, :rubygems, package: package)}
+ let(:package2) { create(:package, project: project, name: package.name, version: '9.9.9') }
+ let(:dependency_link4) { create(:packages_dependency_link, :rubygems, package: package2)}
+
+ it 'returns a set of dependencies' do
+ expected_result = [{
+ name: package.name,
+ number: package.version,
+ platform: described_class::DEFAULT_PLATFORM,
+ dependencies: [
+ [dependency_link.dependency.name, dependency_link.dependency.version_pattern],
+ [dependency_link2.dependency.name, dependency_link2.dependency.version_pattern],
+ [dependency_link3.dependency.name, dependency_link3.dependency.version_pattern]
+ ]
+ }, {
+ name: package2.name,
+ number: package2.version,
+ platform: described_class::DEFAULT_PLATFORM,
+ dependencies: [
+ [dependency_link4.dependency.name, dependency_link4.dependency.version_pattern]
+ ]
+ }]
+
+ expect(subject.payload).to eq(expected_result)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/pages/legacy_storage_lease_spec.rb b/spec/services/pages/legacy_storage_lease_spec.rb
index c022da6f47f..092dce093ff 100644
--- a/spec/services/pages/legacy_storage_lease_spec.rb
+++ b/spec/services/pages/legacy_storage_lease_spec.rb
@@ -47,14 +47,6 @@ RSpec.describe ::Pages::LegacyStorageLease do
expect(service.execute).to eq(nil)
end
-
- it 'runs guarded method if feature flag is disabled' do
- stub_feature_flags(pages_use_legacy_storage_lease: false)
-
- expect(service).to receive(:execute_unsafe).and_call_original
-
- expect(service.execute).to eq(true)
- end
end
context 'when another service holds the lease for the different project' do
diff --git a/spec/services/projects/alerting/notify_service_spec.rb b/spec/services/projects/alerting/notify_service_spec.rb
index 4e366fce0d9..c272ce13132 100644
--- a/spec/services/projects/alerting/notify_service_spec.rb
+++ b/spec/services/projects/alerting/notify_service_spec.rb
@@ -119,6 +119,7 @@ RSpec.describe Projects::Alerting::NotifyService do
end
it_behaves_like 'does not an create alert management alert'
+ it_behaves_like 'creates single system note based on the source of the alert'
context 'auto_close_enabled setting enabled' do
let(:auto_close_enabled) { true }
@@ -131,6 +132,8 @@ RSpec.describe Projects::Alerting::NotifyService do
expect(alert.ended_at).to eql(ended_at)
end
+ it_behaves_like 'creates status-change system note for an auto-resolved alert'
+
context 'related issue exists' do
let(:alert) { create(:alert_management_alert, :with_issue, project: project, fingerprint: fingerprint_sha) }
let(:issue) { alert.issue }
@@ -209,10 +212,7 @@ RSpec.describe Projects::Alerting::NotifyService do
)
end
- it 'creates a system note corresponding to alert creation' do
- expect { subject }.to change(Note, :count).by(1)
- expect(Note.last.note).to include(source)
- end
+ it_behaves_like 'creates single system note based on the source of the alert'
end
end
diff --git a/spec/services/projects/branches_by_mode_service_spec.rb b/spec/services/projects/branches_by_mode_service_spec.rb
index 9199c3e0b3a..e8bcda8a9c4 100644
--- a/spec/services/projects/branches_by_mode_service_spec.rb
+++ b/spec/services/projects/branches_by_mode_service_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe Projects::BranchesByModeService do
branches, prev_page, next_page = subject
- expect(branches.size).to eq(10)
+ expect(branches.size).to eq(11)
expect(next_page).to be_nil
expect(prev_page).to eq("/#{project.full_path}/-/branches/all?offset=2&page=3")
end
@@ -99,7 +99,7 @@ RSpec.describe Projects::BranchesByModeService do
it 'returns branches after the specified branch' do
branches, prev_page, next_page = subject
- expect(branches.size).to eq(14)
+ expect(branches.size).to eq(15)
expect(next_page).to be_nil
expect(prev_page).to eq("/#{project.full_path}/-/branches/all?offset=3&page=4&sort=name_asc")
end
diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb
index f7da6f75141..306d87eefb8 100644
--- a/spec/services/projects/create_service_spec.rb
+++ b/spec/services/projects/create_service_spec.rb
@@ -349,27 +349,38 @@ RSpec.describe Projects::CreateService, '#execute' do
context 'default visibility level' do
let(:group) { create(:group, :private) }
- before do
- stub_application_setting(default_project_visibility: Gitlab::VisibilityLevel::INTERNAL)
- group.add_developer(user)
+ using RSpec::Parameterized::TableSyntax
- opts.merge!(
- visibility: 'private',
- name: 'test',
- namespace: group,
- path: 'foo'
- )
+ where(:case_name, :group_level, :project_level) do
+ [
+ ['in public group', Gitlab::VisibilityLevel::PUBLIC, Gitlab::VisibilityLevel::INTERNAL],
+ ['in internal group', Gitlab::VisibilityLevel::INTERNAL, Gitlab::VisibilityLevel::INTERNAL],
+ ['in private group', Gitlab::VisibilityLevel::PRIVATE, Gitlab::VisibilityLevel::PRIVATE]
+ ]
end
- it 'creates a private project' do
- project = create_project(user, opts)
+ with_them do
+ before do
+ stub_application_setting(default_project_visibility: Gitlab::VisibilityLevel::INTERNAL)
+ group.add_developer(user)
+ group.update!(visibility_level: group_level)
- expect(project).to respond_to(:errors)
+ opts.merge!(
+ name: 'test',
+ namespace: group,
+ path: 'foo'
+ )
+ end
- expect(project.errors.any?).to be(false)
- expect(project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
- expect(project.saved?).to be(true)
- expect(project.valid?).to be(true)
+ it 'creates project with correct visibility level', :aggregate_failures do
+ project = create_project(user, opts)
+
+ expect(project).to respond_to(:errors)
+ expect(project.errors).to be_blank
+ expect(project.visibility_level).to eq(project_level)
+ expect(project).to be_saved
+ expect(project).to be_valid
+ end
end
end
diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb
index 75d1c98923a..5410e784cc0 100644
--- a/spec/services/projects/destroy_service_spec.rb
+++ b/spec/services/projects/destroy_service_spec.rb
@@ -31,9 +31,34 @@ RSpec.describe Projects::DestroyService, :aggregate_failures do
end
shared_examples 'deleting the project with pipeline and build' do
- context 'with pipeline and build', :sidekiq_inline do # which has optimistic locking
+ context 'with pipeline and build related records', :sidekiq_inline do # which has optimistic locking
let!(:pipeline) { create(:ci_pipeline, project: project) }
- let!(:build) { create(:ci_build, :artifacts, pipeline: pipeline) }
+ let!(:build) { create(:ci_build, :artifacts, :with_runner_session, pipeline: pipeline) }
+ let!(:trace_chunks) { create(:ci_build_trace_chunk, build: build) }
+ let!(:job_variables) { create(:ci_job_variable, job: build) }
+ let!(:report_result) { create(:ci_build_report_result, build: build) }
+ let!(:pending_state) { create(:ci_build_pending_state, build: build) }
+
+ it 'deletes build related records' do
+ expect { destroy_project(project, user, {}) }.to change { Ci::Build.count }.by(-1)
+ .and change { Ci::BuildTraceChunk.count }.by(-1)
+ .and change { Ci::JobArtifact.count }.by(-2)
+ .and change { Ci::JobVariable.count }.by(-1)
+ .and change { Ci::BuildPendingState.count }.by(-1)
+ .and change { Ci::BuildReportResult.count }.by(-1)
+ .and change { Ci::BuildRunnerSession.count }.by(-1)
+ end
+
+ it 'avoids N+1 queries', skip: 'skipped until fixed in https://gitlab.com/gitlab-org/gitlab/-/issues/24644' do
+ recorder = ActiveRecord::QueryRecorder.new { destroy_project(project, user, {}) }
+
+ project = create(:project, :repository, namespace: user.namespace)
+ pipeline = create(:ci_pipeline, project: project)
+ builds = create_list(:ci_build, 3, :artifacts, pipeline: pipeline)
+ create_list(:ci_build_trace_chunk, 3, build: builds[0])
+
+ expect { destroy_project(project, project.owner, {}) }.not_to exceed_query_limit(recorder)
+ end
it_behaves_like 'deleting the project'
end
@@ -60,357 +85,343 @@ RSpec.describe Projects::DestroyService, :aggregate_failures do
end
end
- shared_examples 'project destroy' do
- it_behaves_like 'deleting the project'
+ it_behaves_like 'deleting the project'
- it 'invalidates personal_project_count cache' do
- expect(user).to receive(:invalidate_personal_projects_count)
+ it 'invalidates personal_project_count cache' do
+ expect(user).to receive(:invalidate_personal_projects_count)
- destroy_project(project, user, {})
+ destroy_project(project, user, {})
+ end
+
+ it 'performs cancel for project ci pipelines' do
+ expect(::Ci::AbortProjectPipelinesService).to receive_message_chain(:new, :execute).with(project)
+
+ destroy_project(project, user, {})
+ end
+
+ context 'when project has remote mirrors' do
+ let!(:project) do
+ create(:project, :repository, namespace: user.namespace).tap do |project|
+ project.remote_mirrors.create!(url: 'http://test.com')
+ end
end
- it 'performs cancel for project ci pipelines' do
- expect(::Ci::AbortProjectPipelinesService).to receive_message_chain(:new, :execute).with(project)
+ it 'destroys them' do
+ expect(RemoteMirror.count).to eq(1)
destroy_project(project, user, {})
+
+ expect(RemoteMirror.count).to eq(0)
end
+ end
- context 'when project has remote mirrors' do
- let!(:project) do
- create(:project, :repository, namespace: user.namespace).tap do |project|
- project.remote_mirrors.create!(url: 'http://test.com')
- end
+ context 'when project has exports' do
+ let!(:project_with_export) do
+ create(:project, :repository, namespace: user.namespace).tap do |project|
+ create(:import_export_upload,
+ project: project,
+ export_file: fixture_file_upload('spec/fixtures/project_export.tar.gz'))
end
+ end
- it 'destroys them' do
- expect(RemoteMirror.count).to eq(1)
-
- destroy_project(project, user, {})
+ it 'destroys project and export' do
+ expect do
+ destroy_project(project_with_export, user, {})
+ end.to change(ImportExportUpload, :count).by(-1)
- expect(RemoteMirror.count).to eq(0)
- end
+ expect(Project.all).not_to include(project_with_export)
end
+ end
- context 'when project has exports' do
- let!(:project_with_export) do
- create(:project, :repository, namespace: user.namespace).tap do |project|
- create(:import_export_upload,
- project: project,
- export_file: fixture_file_upload('spec/fixtures/project_export.tar.gz'))
- end
- end
+ context 'Sidekiq fake' do
+ before do
+ # Dont run sidekiq to check if renamed repository exists
+ Sidekiq::Testing.fake! { destroy_project(project, user, {}) }
+ end
- it 'destroys project and export' do
- expect do
- destroy_project(project_with_export, user, {})
- end.to change(ImportExportUpload, :count).by(-1)
+ it { expect(Project.all).not_to include(project) }
- expect(Project.all).not_to include(project_with_export)
- end
+ it do
+ expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_falsey
end
- context 'Sidekiq fake' do
- before do
- # Dont run sidekiq to check if renamed repository exists
- Sidekiq::Testing.fake! { destroy_project(project, user, {}) }
- end
+ it do
+ expect(project.gitlab_shell.repository_exists?(project.repository_storage, remove_path + '.git')).to be_truthy
+ end
+ end
- it { expect(Project.all).not_to include(project) }
+ context 'when flushing caches fail due to Git errors' do
+ before do
+ allow(project.repository).to receive(:before_delete).and_raise(::Gitlab::Git::CommandError)
+ allow(Gitlab::GitLogger).to receive(:warn).with(
+ class: Repositories::DestroyService.name,
+ container_id: project.id,
+ disk_path: project.disk_path,
+ message: 'Gitlab::Git::CommandError').and_call_original
+ end
- it do
- expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_falsey
- end
+ it_behaves_like 'deleting the project'
+ end
- it do
- expect(project.gitlab_shell.repository_exists?(project.repository_storage, remove_path + '.git')).to be_truthy
- end
+ context 'when flushing caches fail due to Redis' do
+ before do
+ new_user = create(:user)
+ project.team.add_user(new_user, Gitlab::Access::DEVELOPER)
+ allow_any_instance_of(described_class).to receive(:flush_caches).and_raise(::Redis::CannotConnectError)
end
- context 'when flushing caches fail due to Git errors' do
- before do
- allow(project.repository).to receive(:before_delete).and_raise(::Gitlab::Git::CommandError)
- allow(Gitlab::GitLogger).to receive(:warn).with(
- class: Repositories::DestroyService.name,
- container_id: project.id,
- disk_path: project.disk_path,
- message: 'Gitlab::Git::CommandError').and_call_original
+ it 'keeps project team intact upon an error' do
+ perform_enqueued_jobs do
+ destroy_project(project, user, {})
+ rescue ::Redis::CannotConnectError
end
- it_behaves_like 'deleting the project'
+ expect(project.team.members.count).to eq 2
end
+ end
+
+ context 'with async_execute', :sidekiq_inline do
+ let(:async) { true }
- context 'when flushing caches fail due to Redis' do
+ context 'async delete of project with private issue visibility' do
before do
- new_user = create(:user)
- project.team.add_user(new_user, Gitlab::Access::DEVELOPER)
- allow_any_instance_of(described_class).to receive(:flush_caches).and_raise(::Redis::CannotConnectError)
+ project.project_feature.update_attribute("issues_access_level", ProjectFeature::PRIVATE)
end
- it 'keeps project team intact upon an error' do
- perform_enqueued_jobs do
- destroy_project(project, user, {})
- rescue ::Redis::CannotConnectError
- end
-
- expect(project.team.members.count).to eq 2
- end
+ it_behaves_like 'deleting the project'
end
- context 'with async_execute', :sidekiq_inline do
- let(:async) { true }
+ it_behaves_like 'deleting the project with pipeline and build'
- context 'async delete of project with private issue visibility' do
+ context 'errors' do
+ context 'when `remove_legacy_registry_tags` fails' do
before do
- project.project_feature.update_attribute("issues_access_level", ProjectFeature::PRIVATE)
+ expect_any_instance_of(described_class)
+ .to receive(:remove_legacy_registry_tags).and_return(false)
end
- it_behaves_like 'deleting the project'
+ it_behaves_like 'handles errors thrown during async destroy', "Failed to remove some tags"
end
- it_behaves_like 'deleting the project with pipeline and build'
-
- context 'errors' do
- context 'when `remove_legacy_registry_tags` fails' do
- before do
- expect_any_instance_of(described_class)
- .to receive(:remove_legacy_registry_tags).and_return(false)
- end
-
- it_behaves_like 'handles errors thrown during async destroy', "Failed to remove some tags"
+ context 'when `remove_repository` fails' do
+ before do
+ expect_any_instance_of(described_class)
+ .to receive(:remove_repository).and_return(false)
end
- context 'when `remove_repository` fails' do
- before do
- expect_any_instance_of(described_class)
- .to receive(:remove_repository).and_return(false)
- end
+ it_behaves_like 'handles errors thrown during async destroy', "Failed to remove project repository"
+ end
- it_behaves_like 'handles errors thrown during async destroy', "Failed to remove project repository"
+ context 'when `execute` raises expected error' do
+ before do
+ expect_any_instance_of(Project)
+ .to receive(:destroy!).and_raise(StandardError.new("Other error message"))
end
- context 'when `execute` raises expected error' do
- before do
- expect_any_instance_of(Project)
- .to receive(:destroy!).and_raise(StandardError.new("Other error message"))
- end
+ it_behaves_like 'handles errors thrown during async destroy', "Other error message"
+ end
- it_behaves_like 'handles errors thrown during async destroy', "Other error message"
+ context 'when `execute` raises unexpected error' do
+ before do
+ expect_any_instance_of(Project)
+ .to receive(:destroy!).and_raise(Exception.new('Other error message'))
end
- context 'when `execute` raises unexpected error' do
- before do
- expect_any_instance_of(Project)
- .to receive(:destroy!).and_raise(Exception.new('Other error message'))
- end
+ it 'allows error to bubble up and rolls back project deletion' do
+ expect do
+ destroy_project(project, user, {})
+ end.to raise_error(Exception, 'Other error message')
- it 'allows error to bubble up and rolls back project deletion' do
- expect do
- destroy_project(project, user, {})
- end.to raise_error(Exception, 'Other error message')
-
- expect(project.reload.pending_delete).to be(false)
- expect(project.delete_error).to include("Other error message")
- end
+ expect(project.reload.pending_delete).to be(false)
+ expect(project.delete_error).to include("Other error message")
end
end
end
+ end
- describe 'container registry' do
- context 'when there are regular container repositories' do
- let(:container_repository) { create(:container_repository) }
+ describe 'container registry' do
+ context 'when there are regular container repositories' do
+ let(:container_repository) { create(:container_repository) }
- before do
- stub_container_registry_tags(repository: project.full_path + '/image',
- tags: ['tag'])
- project.container_repositories << container_repository
- end
+ before do
+ stub_container_registry_tags(repository: project.full_path + '/image',
+ tags: ['tag'])
+ project.container_repositories << container_repository
+ end
- context 'when image repository deletion succeeds' do
- it 'removes tags' do
- expect_any_instance_of(ContainerRepository)
- .to receive(:delete_tags!).and_return(true)
+ context 'when image repository deletion succeeds' do
+ it 'removes tags' do
+ expect_any_instance_of(ContainerRepository)
+ .to receive(:delete_tags!).and_return(true)
- destroy_project(project, user)
- end
+ destroy_project(project, user)
end
+ end
- context 'when image repository deletion fails' do
- it 'raises an exception' do
- expect_any_instance_of(ContainerRepository)
- .to receive(:delete_tags!).and_raise(RuntimeError)
+ context 'when image repository deletion fails' do
+ it 'raises an exception' do
+ expect_any_instance_of(ContainerRepository)
+ .to receive(:delete_tags!).and_raise(RuntimeError)
- expect(destroy_project(project, user)).to be false
- end
+ expect(destroy_project(project, user)).to be false
end
+ end
- context 'when registry is disabled' do
- before do
- stub_container_registry_config(enabled: false)
- end
+ context 'when registry is disabled' do
+ before do
+ stub_container_registry_config(enabled: false)
+ end
- it 'does not attempting to remove any tags' do
- expect(Projects::ContainerRepository::DestroyService).not_to receive(:new)
+ it 'does not attempting to remove any tags' do
+ expect(Projects::ContainerRepository::DestroyService).not_to receive(:new)
- destroy_project(project, user)
- end
+ destroy_project(project, user)
end
end
+ end
- context 'when there are tags for legacy root repository' do
- before do
- stub_container_registry_tags(repository: project.full_path,
- tags: ['tag'])
- end
+ context 'when there are tags for legacy root repository' do
+ before do
+ stub_container_registry_tags(repository: project.full_path,
+ tags: ['tag'])
+ end
- context 'when image repository tags deletion succeeds' do
- it 'removes tags' do
- expect_any_instance_of(ContainerRepository)
- .to receive(:delete_tags!).and_return(true)
+ context 'when image repository tags deletion succeeds' do
+ it 'removes tags' do
+ expect_any_instance_of(ContainerRepository)
+ .to receive(:delete_tags!).and_return(true)
- destroy_project(project, user)
- end
+ destroy_project(project, user)
end
+ end
- context 'when image repository tags deletion fails' do
- it 'raises an exception' do
- expect_any_instance_of(ContainerRepository)
- .to receive(:delete_tags!).and_return(false)
+ context 'when image repository tags deletion fails' do
+ it 'raises an exception' do
+ expect_any_instance_of(ContainerRepository)
+ .to receive(:delete_tags!).and_return(false)
- expect(destroy_project(project, user)).to be false
- end
+ expect(destroy_project(project, user)).to be false
end
end
end
+ end
- context 'for a forked project with LFS objects' do
- let(:forked_project) { fork_project(project, user) }
+ context 'for a forked project with LFS objects' do
+ let(:forked_project) { fork_project(project, user) }
- before do
- project.lfs_objects << create(:lfs_object)
- forked_project.reload
- end
+ before do
+ project.lfs_objects << create(:lfs_object)
+ forked_project.reload
+ end
- it 'destroys the fork' do
- expect { destroy_project(forked_project, user) }
- .not_to raise_error
- end
+ it 'destroys the fork' do
+ expect { destroy_project(forked_project, user) }
+ .not_to raise_error
end
+ end
- context 'as the root of a fork network' do
- let!(:fork_1) { fork_project(project, user) }
- let!(:fork_2) { fork_project(project, user) }
+ context 'as the root of a fork network' do
+ let!(:fork_1) { fork_project(project, user) }
+ let!(:fork_2) { fork_project(project, user) }
- it 'updates the fork network with the project name' do
- fork_network = project.fork_network
+ it 'updates the fork network with the project name' do
+ fork_network = project.fork_network
- destroy_project(project, user)
+ destroy_project(project, user)
- fork_network.reload
+ fork_network.reload
- expect(fork_network.deleted_root_project_name).to eq(project.full_name)
- expect(fork_network.root_project).to be_nil
- end
+ expect(fork_network.deleted_root_project_name).to eq(project.full_name)
+ expect(fork_network.root_project).to be_nil
end
+ end
- context 'repository +deleted path removal' do
- context 'regular phase' do
- it 'schedules +deleted removal of existing repos' do
- service = described_class.new(project, user, {})
- allow(service).to receive(:schedule_stale_repos_removal)
+ context 'repository +deleted path removal' do
+ context 'regular phase' do
+ it 'schedules +deleted removal of existing repos' do
+ service = described_class.new(project, user, {})
+ allow(service).to receive(:schedule_stale_repos_removal)
- expect(Repositories::ShellDestroyService).to receive(:new).and_call_original
- expect(GitlabShellWorker).to receive(:perform_in)
- .with(5.minutes, :remove_repository, project.repository_storage, removal_path(project.disk_path))
+ expect(Repositories::ShellDestroyService).to receive(:new).and_call_original
+ expect(GitlabShellWorker).to receive(:perform_in)
+ .with(5.minutes, :remove_repository, project.repository_storage, removal_path(project.disk_path))
- service.execute
- end
+ service.execute
end
+ end
- context 'stale cleanup' do
- let(:async) { true }
+ context 'stale cleanup' do
+ let(:async) { true }
- it 'schedules +deleted wiki and repo removal' do
- allow(ProjectDestroyWorker).to receive(:perform_async)
+ it 'schedules +deleted wiki and repo removal' do
+ allow(ProjectDestroyWorker).to receive(:perform_async)
- expect(Repositories::ShellDestroyService).to receive(:new).with(project.repository).and_call_original
- expect(GitlabShellWorker).to receive(:perform_in)
- .with(10.minutes, :remove_repository, project.repository_storage, removal_path(project.disk_path))
+ expect(Repositories::ShellDestroyService).to receive(:new).with(project.repository).and_call_original
+ expect(GitlabShellWorker).to receive(:perform_in)
+ .with(10.minutes, :remove_repository, project.repository_storage, removal_path(project.disk_path))
- expect(Repositories::ShellDestroyService).to receive(:new).with(project.wiki.repository).and_call_original
- expect(GitlabShellWorker).to receive(:perform_in)
- .with(10.minutes, :remove_repository, project.repository_storage, removal_path(project.wiki.disk_path))
+ expect(Repositories::ShellDestroyService).to receive(:new).with(project.wiki.repository).and_call_original
+ expect(GitlabShellWorker).to receive(:perform_in)
+ .with(10.minutes, :remove_repository, project.repository_storage, removal_path(project.wiki.disk_path))
- destroy_project(project, user, {})
- end
+ destroy_project(project, user, {})
end
end
+ end
- context 'snippets' do
- let!(:snippet1) { create(:project_snippet, project: project, author: user) }
- let!(:snippet2) { create(:project_snippet, project: project, author: user) }
-
- it 'does not include snippets when deleting in batches' do
- expect(project).to receive(:destroy_dependent_associations_in_batches).with({ exclude: [:container_repositories, :snippets] })
+ context 'snippets' do
+ let!(:snippet1) { create(:project_snippet, project: project, author: user) }
+ let!(:snippet2) { create(:project_snippet, project: project, author: user) }
- destroy_project(project, user)
- end
+ it 'does not include snippets when deleting in batches' do
+ expect(project).to receive(:destroy_dependent_associations_in_batches).with({ exclude: [:container_repositories, :snippets] })
- it 'calls the bulk snippet destroy service' do
- expect(project.snippets.count).to eq 2
+ destroy_project(project, user)
+ end
- expect(Snippets::BulkDestroyService).to receive(:new)
- .with(user, project.snippets).and_call_original
+ it 'calls the bulk snippet destroy service' do
+ expect(project.snippets.count).to eq 2
- expect do
- destroy_project(project, user)
- end.to change(Snippet, :count).by(-2)
- end
+ expect(Snippets::BulkDestroyService).to receive(:new)
+ .with(user, project.snippets).and_call_original
- context 'when an error is raised deleting snippets' do
- it 'does not delete project' do
- allow_next_instance_of(Snippets::BulkDestroyService) do |instance|
- allow(instance).to receive(:execute).and_return(ServiceResponse.error(message: 'foo'))
- end
-
- expect(destroy_project(project, user)).to be_falsey
- expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_truthy
- end
- end
+ expect do
+ destroy_project(project, user)
+ end.to change(Snippet, :count).by(-2)
end
- context 'error while destroying', :sidekiq_inline do
- let!(:pipeline) { create(:ci_pipeline, project: project) }
- let!(:builds) { create_list(:ci_build, 2, :artifacts, pipeline: pipeline) }
- let!(:build_trace) { create(:ci_build_trace_chunk, build: builds[0]) }
-
- it 'deletes on retry' do
- # We can expect this to timeout for very large projects
- # TODO: remove allow_next_instance_of: https://gitlab.com/gitlab-org/gitlab/-/issues/220440
- allow_any_instance_of(Ci::Build).to receive(:destroy).and_raise('boom')
- destroy_project(project, user, {})
-
- allow_any_instance_of(Ci::Build).to receive(:destroy).and_call_original
- destroy_project(project, user, {})
+ context 'when an error is raised deleting snippets' do
+ it 'does not delete project' do
+ allow_next_instance_of(Snippets::BulkDestroyService) do |instance|
+ allow(instance).to receive(:execute).and_return(ServiceResponse.error(message: 'foo'))
+ end
- expect(Project.unscoped.all).not_to include(project)
- expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_falsey
- expect(project.gitlab_shell.repository_exists?(project.repository_storage, remove_path + '.git')).to be_falsey
- expect(project.all_pipelines).to be_empty
- expect(project.builds).to be_empty
+ expect(destroy_project(project, user)).to be_falsey
+ expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_truthy
end
end
end
- context 'when project_transactionless_destroy enabled' do
- it_behaves_like 'project destroy'
- end
+ context 'error while destroying', :sidekiq_inline do
+ let!(:pipeline) { create(:ci_pipeline, project: project) }
+ let!(:builds) { create_list(:ci_build, 2, :artifacts, pipeline: pipeline) }
+ let!(:build_trace) { create(:ci_build_trace_chunk, build: builds[0]) }
- context 'when project_transactionless_destroy disabled', :sidekiq_inline do
- before do
- stub_feature_flags(project_transactionless_destroy: false)
- end
+ it 'deletes on retry' do
+ # We can expect this to timeout for very large projects
+ # TODO: remove allow_next_instance_of: https://gitlab.com/gitlab-org/gitlab/-/issues/220440
+ allow_any_instance_of(Ci::Build).to receive(:destroy).and_raise('boom')
+ destroy_project(project, user, {})
+
+ allow_any_instance_of(Ci::Build).to receive(:destroy).and_call_original
+ destroy_project(project, user, {})
- it_behaves_like 'project destroy'
+ expect(Project.unscoped.all).not_to include(project)
+ expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_falsey
+ expect(project.gitlab_shell.repository_exists?(project.repository_storage, remove_path + '.git')).to be_falsey
+ expect(project.all_pipelines).to be_empty
+ expect(project.builds).to be_empty
+ end
end
def destroy_project(project, user, params = {})
diff --git a/spec/services/projects/schedule_bulk_repository_shard_moves_service_spec.rb b/spec/services/projects/schedule_bulk_repository_shard_moves_service_spec.rb
index 15c9d1e5925..2dc4a56368b 100644
--- a/spec/services/projects/schedule_bulk_repository_shard_moves_service_spec.rb
+++ b/spec/services/projects/schedule_bulk_repository_shard_moves_service_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Projects::ScheduleBulkRepositoryShardMovesService do
it_behaves_like 'moves repository shard in bulk' do
let_it_be_with_reload(:container) { create(:project, :repository).tap { |project| project.track_project_repository } }
- let(:move_service_klass) { ProjectRepositoryStorageMove }
- let(:bulk_worker_klass) { ::ProjectScheduleBulkRepositoryShardMovesWorker }
+ let(:move_service_klass) { Projects::RepositoryStorageMove }
+ let(:bulk_worker_klass) { ::Projects::ScheduleBulkRepositoryShardMovesWorker }
end
end
diff --git a/spec/services/projects/update_pages_configuration_service_spec.rb b/spec/services/projects/update_pages_configuration_service_spec.rb
index 294de813e02..9ef66a10f0d 100644
--- a/spec/services/projects/update_pages_configuration_service_spec.rb
+++ b/spec/services/projects/update_pages_configuration_service_spec.rb
@@ -26,11 +26,18 @@ RSpec.describe Projects::UpdatePagesConfigurationService do
context 'when configuration changes' do
it 'updates the config and reloads the daemon' do
- allow(service).to receive(:update_file).and_call_original
-
expect(service).to receive(:update_file).with(file.path, an_instance_of(String))
.and_call_original
- expect(service).to receive(:reload_daemon).and_call_original
+ allow(service).to receive(:update_file).with(File.join(::Settings.pages.path, '.update'),
+ an_instance_of(String)).and_call_original
+
+ expect(subject).to include(status: :success)
+ end
+
+ it "doesn't update configuration files if updates on legacy storage are disabled" do
+ stub_feature_flags(pages_update_legacy_storage: false)
+
+ expect(service).not_to receive(:update_file)
expect(subject).to include(status: :success)
end
@@ -42,8 +49,8 @@ RSpec.describe Projects::UpdatePagesConfigurationService do
service.execute
end
- it 'does not update the .update file' do
- expect(service).not_to receive(:reload_daemon)
+ it 'does not update anything' do
+ expect(service).not_to receive(:update_file)
expect(subject).to include(status: :success)
end
diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb
index 6bf2876f640..b735f4b6bc2 100644
--- a/spec/services/projects/update_pages_service_spec.rb
+++ b/spec/services/projects/update_pages_service_spec.rb
@@ -335,6 +335,41 @@ RSpec.describe Projects::UpdatePagesService do
end
end
+ context 'when retrying the job' do
+ let!(:older_deploy_job) do
+ create(:generic_commit_status, :failed, pipeline: pipeline,
+ ref: build.ref,
+ stage: 'deploy',
+ name: 'pages:deploy')
+ end
+
+ before do
+ create(:ci_job_artifact, :correct_checksum, file: file, job: build)
+ create(:ci_job_artifact, file_type: :metadata, file_format: :gzip, file: metadata, job: build)
+ build.reload
+ end
+
+ it 'marks older pages:deploy jobs retried' do
+ expect(execute).to eq(:success)
+
+ expect(older_deploy_job.reload).to be_retried
+ end
+
+ context 'when FF ci_fix_commit_status_retried is disabled' do
+ before do
+ stub_feature_flags(ci_fix_commit_status_retried: false)
+ end
+
+ it 'does not mark older pages:deploy jobs retried' do
+ expect(execute).to eq(:success)
+
+ expect(older_deploy_job.reload).not_to be_retried
+ end
+ end
+ end
+
+ private
+
def deploy_status
GenericCommitStatus.find_by(name: 'pages:deploy')
end
diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb
index a59b6adf346..b9e909e8615 100644
--- a/spec/services/projects/update_service_spec.rb
+++ b/spec/services/projects/update_service_spec.rb
@@ -551,7 +551,7 @@ RSpec.describe Projects::UpdateService do
expect(project).to be_repository_read_only
expect(project.repository_storage_moves.last).to have_attributes(
- state: ::ProjectRepositoryStorageMove.state_machines[:state].states[:scheduled].value,
+ state: ::Projects::RepositoryStorageMove.state_machines[:state].states[:scheduled].value,
source_storage_name: 'default',
destination_storage_name: 'test_second_storage'
)
diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb
index 1a102b125f6..bf35e72a037 100644
--- a/spec/services/quick_actions/interpret_service_spec.rb
+++ b/spec/services/quick_actions/interpret_service_spec.rb
@@ -1949,6 +1949,100 @@ RSpec.describe QuickActions::InterpretService do
end
end
end
+
+ context 'invite_email command' do
+ let_it_be(:issuable) { issue }
+
+ it_behaves_like 'empty command', "No email participants were added. Either none were provided, or they already exist." do
+ let(:content) { '/invite_email' }
+ end
+
+ context 'with existing email participant' do
+ let(:content) { '/invite_email a@gitlab.com' }
+
+ before do
+ issuable.issue_email_participants.create!(email: "a@gitlab.com")
+ end
+
+ it_behaves_like 'empty command', "No email participants were added. Either none were provided, or they already exist."
+ end
+
+ context 'with new email participants' do
+ let(:content) { '/invite_email a@gitlab.com b@gitlab.com' }
+
+ subject(:add_emails) { service.execute(content, issuable) }
+
+ it 'returns message' do
+ _, _, message = add_emails
+
+ expect(message).to eq('Added a@gitlab.com and b@gitlab.com.')
+ end
+
+ it 'adds 2 participants' do
+ expect { add_emails }.to change { issue.issue_email_participants.count }.by(2)
+ end
+
+ context 'with mixed case email' do
+ let(:content) { '/invite_email FirstLast@GitLab.com' }
+
+ it 'returns correctly cased message' do
+ _, _, message = add_emails
+
+ expect(message).to eq('Added FirstLast@GitLab.com.')
+ end
+ end
+
+ context 'with invalid email' do
+ let(:content) { '/invite_email a@gitlab.com bad_email' }
+
+ it 'only adds valid emails' do
+ expect { add_emails }.to change { issue.issue_email_participants.count }.by(1)
+ end
+ end
+
+ context 'with existing email' do
+ let(:content) { '/invite_email a@gitlab.com existing@gitlab.com' }
+
+ it 'only adds new emails' do
+ issue.issue_email_participants.create!(email: 'existing@gitlab.com')
+
+ expect { add_emails }.to change { issue.issue_email_participants.count }.by(1)
+ end
+
+ it 'only adds new (case insensitive) emails' do
+ issue.issue_email_participants.create!(email: 'EXISTING@gitlab.com')
+
+ expect { add_emails }.to change { issue.issue_email_participants.count }.by(1)
+ end
+ end
+
+ context 'with duplicate email' do
+ let(:content) { '/invite_email a@gitlab.com a@gitlab.com' }
+
+ it 'only adds unique new emails' do
+ expect { add_emails }.to change { issue.issue_email_participants.count }.by(1)
+ end
+ end
+
+ context 'with more than 6 emails' do
+ let(:content) { '/invite_email a@gitlab.com b@gitlab.com c@gitlab.com d@gitlab.com e@gitlab.com f@gitlab.com g@gitlab.com' }
+
+ it 'only adds 6 new emails' do
+ expect { add_emails }.to change { issue.issue_email_participants.count }.by(6)
+ end
+ end
+
+ context 'with feature flag disabled' do
+ before do
+ stub_feature_flags(issue_email_participants: false)
+ end
+
+ it 'does not add any participants' do
+ expect { add_emails }.not_to change { issue.issue_email_participants.count }
+ end
+ end
+ end
+ end
end
describe '#explain' do
diff --git a/spec/services/repositories/changelog_service_spec.rb b/spec/services/repositories/changelog_service_spec.rb
index a545b0f070a..dab38445ccf 100644
--- a/spec/services/repositories/changelog_service_spec.rb
+++ b/spec/services/repositories/changelog_service_spec.rb
@@ -4,48 +4,64 @@ require 'spec_helper'
RSpec.describe Repositories::ChangelogService do
describe '#execute' do
- it 'generates and commits a changelog section' do
- project = create(:project, :empty_repo)
- creator = project.creator
- author1 = create(:user)
- author2 = create(:user)
-
- project.add_maintainer(author1)
- project.add_maintainer(author2)
-
- mr1 = create(:merge_request, :merged, target_project: project)
- mr2 = create(:merge_request, :merged, target_project: project)
-
- # The range of commits ignores the first commit, but includes the last
- # commit. To ensure both the commits below are included, we must create an
- # extra commit.
- #
- # In the real world, the start commit of the range will be the last commit
- # of the previous release, so ignoring that is expected and desired.
- sha1 = create_commit(
+ let!(:project) { create(:project, :empty_repo) }
+ let!(:creator) { project.creator }
+ let!(:author1) { create(:user) }
+ let!(:author2) { create(:user) }
+ let!(:mr1) { create(:merge_request, :merged, target_project: project) }
+ let!(:mr2) { create(:merge_request, :merged, target_project: project) }
+
+ # The range of commits ignores the first commit, but includes the last
+ # commit. To ensure both the commits below are included, we must create an
+ # extra commit.
+ #
+ # In the real world, the start commit of the range will be the last commit
+ # of the previous release, so ignoring that is expected and desired.
+ let!(:sha1) do
+ create_commit(
project,
creator,
commit_message: 'Initial commit',
actions: [{ action: 'create', content: 'test', file_path: 'README.md' }]
)
+ end
+
+ let!(:sha2) do
+ project.add_maintainer(author1)
- sha2 = create_commit(
+ create_commit(
project,
author1,
commit_message: "Title 1\n\nChangelog: feature",
actions: [{ action: 'create', content: 'foo', file_path: 'a.txt' }]
)
+ end
+
+ let!(:sha3) do
+ project.add_maintainer(author2)
- sha3 = create_commit(
+ create_commit(
project,
author2,
commit_message: "Title 2\n\nChangelog: feature",
actions: [{ action: 'create', content: 'bar', file_path: 'b.txt' }]
)
+ end
- commit1 = project.commit(sha2)
- commit2 = project.commit(sha3)
+ let!(:sha4) do
+ create_commit(
+ project,
+ author2,
+ commit_message: "Title 3\n\nChangelog: feature",
+ actions: [{ action: 'create', content: 'bar', file_path: 'c.txt' }]
+ )
+ end
+ let!(:commit1) { project.commit(sha2) }
+ let!(:commit2) { project.commit(sha3) }
+ let!(:commit3) { project.commit(sha4) }
+
+ it 'generates and commits a changelog section' do
allow(MergeRequestDiffCommit)
.to receive(:oldest_merge_request_id_per_commit)
.with(project.id, [commit2.id, commit1.id])
@@ -54,16 +70,60 @@ RSpec.describe Repositories::ChangelogService do
{ sha: sha3, merge_request_id: mr2.id }
])
- recorder = ActiveRecord::QueryRecorder.new do
- described_class
- .new(project, creator, version: '1.0.0', from: sha1, to: sha3)
- .execute
- end
+ service = described_class
+ .new(project, creator, version: '1.0.0', from: sha1, to: sha3)
+
+ recorder = ActiveRecord::QueryRecorder.new { service.execute }
+ changelog = project.repository.blob_at('master', 'CHANGELOG.md')&.data
+
+ expect(recorder.count).to eq(11)
+ expect(changelog).to include('Title 1', 'Title 2')
+ end
+
+ it "ignores a commit when it's both added and reverted in the same range" do
+ create_commit(
+ project,
+ author2,
+ commit_message: "Title 4\n\nThis reverts commit #{sha4}",
+ actions: [{ action: 'create', content: 'bar', file_path: 'd.txt' }]
+ )
+
+ described_class
+ .new(project, creator, version: '1.0.0', from: sha1)
+ .execute
changelog = project.repository.blob_at('master', 'CHANGELOG.md')&.data
- expect(recorder.count).to eq(10)
expect(changelog).to include('Title 1', 'Title 2')
+ expect(changelog).not_to include('Title 3', 'Title 4')
+ end
+
+ it 'includes a revert commit when it has a trailer' do
+ create_commit(
+ project,
+ author2,
+ commit_message: "Title 4\n\nThis reverts commit #{sha4}\n\nChangelog: added",
+ actions: [{ action: 'create', content: 'bar', file_path: 'd.txt' }]
+ )
+
+ described_class
+ .new(project, creator, version: '1.0.0', from: sha1)
+ .execute
+
+ changelog = project.repository.blob_at('master', 'CHANGELOG.md')&.data
+
+ expect(changelog).to include('Title 1', 'Title 2', 'Title 4')
+ expect(changelog).not_to include('Title 3')
+ end
+
+ it 'uses the target branch when "to" is unspecified' do
+ described_class
+ .new(project, creator, version: '1.0.0', from: sha1)
+ .execute
+
+ changelog = project.repository.blob_at('master', 'CHANGELOG.md')&.data
+
+ expect(changelog).to include('Title 1', 'Title 2', 'Title 3')
end
end
diff --git a/spec/services/snippets/schedule_bulk_repository_shard_moves_service_spec.rb b/spec/services/snippets/schedule_bulk_repository_shard_moves_service_spec.rb
index 764c7f94a46..9286d73ed4a 100644
--- a/spec/services/snippets/schedule_bulk_repository_shard_moves_service_spec.rb
+++ b/spec/services/snippets/schedule_bulk_repository_shard_moves_service_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Snippets::ScheduleBulkRepositoryShardMovesService do
it_behaves_like 'moves repository shard in bulk' do
let_it_be_with_reload(:container) { create(:snippet, :repository) }
- let(:move_service_klass) { SnippetRepositoryStorageMove }
- let(:bulk_worker_klass) { ::SnippetScheduleBulkRepositoryShardMovesWorker }
+ let(:move_service_klass) { Snippets::RepositoryStorageMove }
+ let(:bulk_worker_klass) { ::Snippets::ScheduleBulkRepositoryShardMovesWorker }
end
end
diff --git a/spec/services/system_hooks_service_spec.rb b/spec/services/system_hooks_service_spec.rb
index 1ec5237370f..446325e5f71 100644
--- a/spec/services/system_hooks_service_spec.rb
+++ b/spec/services/system_hooks_service_spec.rb
@@ -149,9 +149,6 @@ RSpec.describe SystemHooksService do
it { expect(event_name(project, :rename)).to eq "project_rename" }
it { expect(event_name(project, :transfer)).to eq "project_transfer" }
it { expect(event_name(project, :update)).to eq "project_update" }
- it { expect(event_name(project_member, :create)).to eq "user_add_to_team" }
- it { expect(event_name(project_member, :destroy)).to eq "user_remove_from_team" }
- it { expect(event_name(project_member, :update)).to eq "user_update_for_team" }
it { expect(event_name(key, :create)).to eq 'key_create' }
it { expect(event_name(key, :destroy)).to eq 'key_destroy' }
end
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index df4880dfa13..54cef164f1c 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -779,4 +779,17 @@ RSpec.describe SystemNoteService do
described_class.change_incident_severity(incident, author)
end
end
+
+ describe '.log_resolving_alert' do
+ let(:alert) { build(:alert_management_alert) }
+ let(:monitoring_tool) { 'Prometheus' }
+
+ it 'calls AlertManagementService' do
+ expect_next_instance_of(SystemNotes::AlertManagementService) do |service|
+ expect(service).to receive(:log_resolving_alert).with(monitoring_tool)
+ end
+
+ described_class.log_resolving_alert(alert, monitoring_tool)
+ end
+ end
end
diff --git a/spec/services/system_notes/alert_management_service_spec.rb b/spec/services/system_notes/alert_management_service_spec.rb
index 4ebaa54534c..fc71799d8c5 100644
--- a/spec/services/system_notes/alert_management_service_spec.rb
+++ b/spec/services/system_notes/alert_management_service_spec.rb
@@ -59,4 +59,17 @@ RSpec.describe ::SystemNotes::AlertManagementService do
expect(subject.note).to eq("changed the status to **Resolved** by closing issue #{issue.to_reference(project)}")
end
end
+
+ describe '#log_resolving_alert' do
+ subject { described_class.new(noteable: noteable, project: project).log_resolving_alert('Some Service') }
+
+ it_behaves_like 'a system note' do
+ let(:author) { User.alert_bot }
+ let(:action) { 'new_alert_added' }
+ end
+
+ it 'has the appropriate message' do
+ expect(subject.note).to eq('logged a resolving alert from **Some Service**')
+ end
+ end
end
diff --git a/spec/services/system_notes/merge_requests_service_spec.rb b/spec/services/system_notes/merge_requests_service_spec.rb
index 2131f3d3bdf..58d2489f878 100644
--- a/spec/services/system_notes/merge_requests_service_spec.rb
+++ b/spec/services/system_notes/merge_requests_service_spec.rb
@@ -189,7 +189,7 @@ RSpec.describe ::SystemNotes::MergeRequestsService do
subject { service.change_branch('target', 'delete', old_branch, new_branch) }
it 'sets the note text' do
- expect(subject.note).to eq "changed automatically target branch to `#{new_branch}` because `#{old_branch}` was deleted"
+ expect(subject.note).to eq "deleted the `#{old_branch}` branch. This merge request now targets the `#{new_branch}` branch"
end
end
diff --git a/spec/services/users/dismiss_user_callout_service_spec.rb b/spec/services/users/dismiss_user_callout_service_spec.rb
new file mode 100644
index 00000000000..22f84a939f7
--- /dev/null
+++ b/spec/services/users/dismiss_user_callout_service_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Users::DismissUserCalloutService do
+ let(:user) { create(:user) }
+
+ let(:service) do
+ described_class.new(
+ container: nil, current_user: user, params: { feature_name: UserCallout.feature_names.each_key.first }
+ )
+ end
+
+ describe '#execute' do
+ subject(:execute) { service.execute }
+
+ it 'returns a user callout' do
+ expect(execute).to be_an_instance_of(UserCallout)
+ end
+
+ it 'sets the dismisse_at attribute to current time' do
+ freeze_time do
+ expect(execute).to have_attributes(dismissed_at: Time.current)
+ end
+ end
+ end
+end
diff --git a/spec/services/users/refresh_authorized_projects_service_spec.rb b/spec/services/users/refresh_authorized_projects_service_spec.rb
index cc01b22f9d2..1e74ff3d9eb 100644
--- a/spec/services/users/refresh_authorized_projects_service_spec.rb
+++ b/spec/services/users/refresh_authorized_projects_service_spec.rb
@@ -152,9 +152,13 @@ RSpec.describe Users::RefreshAuthorizedProjectsService do
expect(Gitlab::AppJsonLogger).to(
receive(:info)
.with(event: 'authorized_projects_refresh',
+ user_id: user.id,
'authorized_projects_refresh.source': source,
- 'authorized_projects_refresh.rows_deleted': 0,
- 'authorized_projects_refresh.rows_added': 1))
+ 'authorized_projects_refresh.rows_deleted_count': 0,
+ 'authorized_projects_refresh.rows_added_count': 1,
+ 'authorized_projects_refresh.rows_deleted_slice': [],
+ 'authorized_projects_refresh.rows_added_slice': [[user.id, project.id, Gitlab::Access::MAINTAINER]])
+ )
service.update_authorizations([], [[user.id, project.id, Gitlab::Access::MAINTAINER]])
end
diff --git a/spec/spam/concerns/has_spam_action_response_fields_spec.rb b/spec/spam/concerns/has_spam_action_response_fields_spec.rb
new file mode 100644
index 00000000000..4d5f8d9d431
--- /dev/null
+++ b/spec/spam/concerns/has_spam_action_response_fields_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Spam::Concerns::HasSpamActionResponseFields do
+ subject do
+ klazz = Class.new
+ klazz.include described_class
+ klazz.new
+ end
+
+ describe '#with_spam_action_response_fields' do
+ let(:spam_log) { double(:spam_log, id: 1) }
+ let(:spammable) { double(:spammable, spam?: true, render_recaptcha?: true, spam_log: spam_log) }
+ let(:recaptcha_site_key) { 'abc123' }
+
+ before do
+ allow(Gitlab::CurrentSettings).to receive(:recaptcha_site_key) { recaptcha_site_key }
+ end
+
+ it 'merges in spam action fields from spammable' do
+ result = subject.send(:with_spam_action_response_fields, spammable) do
+ { other_field: true }
+ end
+ expect(result)
+ .to eq({
+ spam: true,
+ needs_captcha_response: true,
+ spam_log_id: 1,
+ captcha_site_key: recaptcha_site_key,
+ other_field: true
+ })
+ end
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 64c1479a412..60a8fb8cb9f 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -180,6 +180,8 @@ RSpec.configure do |config|
end
if ENV['FLAKY_RSPEC_GENERATE_REPORT']
+ require_relative '../tooling/rspec_flaky/listener'
+
config.reporter.register_listener(
RspecFlaky::Listener.new,
:example_passed,
@@ -244,14 +246,21 @@ RSpec.configure do |config|
stub_feature_flags(unified_diff_components: false)
+ # Disable this feature flag as we iterate and
+ # refactor filtered search to use gitlab ui
+ # components to meet feature parody. More details found
+ # https://gitlab.com/groups/gitlab-org/-/epics/5501
+ stub_feature_flags(boards_filtered_search: false)
+
+ # The following `vue_issues_list` stub can be removed once the
+ # Vue issues page has feature parity with the current Haml page
+ stub_feature_flags(vue_issues_list: false)
+
allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged)
else
unstub_all_feature_flags
end
- # Enable Marginalia feature for all specs in the test suite.
- Gitlab::Marginalia.enabled = true
-
# Stub these calls due to being expensive operations
# It can be reenabled for specific tests via:
#
diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb
index db198ac9808..be2b41d6997 100644
--- a/spec/support/capybara.rb
+++ b/spec/support/capybara.rb
@@ -79,8 +79,30 @@ Capybara.register_driver :chrome do |app|
)
end
+Capybara.register_driver :firefox do |app|
+ capabilities = Selenium::WebDriver::Remote::Capabilities.firefox(
+ log: {
+ level: :trace
+ }
+ )
+
+ options = Selenium::WebDriver::Firefox::Options.new(log_level: :trace)
+
+ options.add_argument("--window-size=#{CAPYBARA_WINDOW_SIZE.join(',')}")
+
+ # Run headless by default unless WEBDRIVER_HEADLESS specified
+ options.add_argument("--headless") unless ENV['WEBDRIVER_HEADLESS'] =~ /^(false|no|0)$/i
+
+ Capybara::Selenium::Driver.new(
+ app,
+ browser: :firefox,
+ desired_capabilities: capabilities,
+ options: options
+ )
+end
+
Capybara.server = :puma_via_workhorse
-Capybara.javascript_driver = :chrome
+Capybara.javascript_driver = ENV.fetch('WEBDRIVER', :chrome).to_sym
Capybara.default_max_wait_time = timeout
Capybara.ignore_hidden_elements = true
Capybara.default_normalize_ws = true
diff --git a/spec/support/gitlab_experiment.rb b/spec/support/gitlab_experiment.rb
index 45ae9958c52..bd0c88f8049 100644
--- a/spec/support/gitlab_experiment.rb
+++ b/spec/support/gitlab_experiment.rb
@@ -2,15 +2,31 @@
# Require the provided spec helper and matchers.
require 'gitlab/experiment/rspec'
+require_relative 'stub_snowplow'
# This is a temporary fix until we have a larger discussion around the
# challenges raised in https://gitlab.com/gitlab-org/gitlab/-/issues/300104
-class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/NamespacedClass
+require Rails.root.join('app', 'experiments', 'application_experiment')
+class ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
def initialize(...)
super(...)
Feature.persist_used!(feature_flag_name)
end
+
+ def should_track?
+ true
+ end
end
-# Disable all caching for experiments in tests.
-Gitlab::Experiment::Configuration.cache = nil
+RSpec.configure do |config|
+ config.include StubSnowplow, :experiment
+
+ # Disable all caching for experiments in tests.
+ config.before do
+ allow(Gitlab::Experiment::Configuration).to receive(:cache).and_return(nil)
+ end
+
+ config.before(:each, :experiment) do
+ stub_snowplow
+ end
+end
diff --git a/spec/support/graphql/resolver_factories.rb b/spec/support/graphql/resolver_factories.rb
new file mode 100644
index 00000000000..8188f17cc43
--- /dev/null
+++ b/spec/support/graphql/resolver_factories.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Graphql
+ module ResolverFactories
+ def new_resolver(resolved_value = 'Resolved value', method: :resolve)
+ case method
+ when :resolve
+ simple_resolver(resolved_value)
+ when :find_object
+ find_object_resolver(resolved_value)
+ else
+ raise "Cannot build a resolver for #{method}"
+ end
+ end
+
+ private
+
+ def simple_resolver(resolved_value = 'Resolved value')
+ Class.new(Resolvers::BaseResolver) do
+ define_method :resolve do |**_args|
+ resolved_value
+ end
+ end
+ end
+
+ def find_object_resolver(resolved_value = 'Found object')
+ Class.new(Resolvers::BaseResolver) do
+ include ::Gitlab::Graphql::Authorize::AuthorizeResource
+
+ def resolve(**args)
+ authorized_find!(**args)
+ end
+
+ define_method :find_object do |**_args|
+ resolved_value
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/helpers/cycle_analytics_helpers.rb b/spec/support/helpers/cycle_analytics_helpers.rb
index a90cbbf3bd3..14041ad0ac6 100644
--- a/spec/support/helpers/cycle_analytics_helpers.rb
+++ b/spec/support/helpers/cycle_analytics_helpers.rb
@@ -15,7 +15,7 @@ module CycleAnalyticsHelpers
end
def toggle_dropdown(field)
- page.within("[data-testid='#{field}']") do
+ page.within("[data-testid*='#{field}']") do
find('.dropdown-toggle').click
wait_for_requests
@@ -26,7 +26,7 @@ module CycleAnalyticsHelpers
def select_dropdown_option_by_value(name, value, elem = '.dropdown-item')
toggle_dropdown name
- page.find("[data-testid='#{name}'] .dropdown-menu").find("#{elem}[value='#{value}']").click
+ page.find("[data-testid*='#{name}'] .dropdown-menu").find("#{elem}[value='#{value}']").click
end
def create_commit_referencing_issue(issue, branch_name: generate(:branch))
diff --git a/spec/support/helpers/database/database_helpers.rb b/spec/support/helpers/database/database_helpers.rb
index b8d7ea3662f..db093bcef85 100644
--- a/spec/support/helpers/database/database_helpers.rb
+++ b/spec/support/helpers/database/database_helpers.rb
@@ -5,11 +5,65 @@ module Database
# In order to directly work with views using factories,
# we can swapout the view for a table of identical structure.
def swapout_view_for_table(view)
- ActiveRecord::Base.connection.execute(<<~SQL)
+ ActiveRecord::Base.connection.execute(<<~SQL.squish)
CREATE TABLE #{view}_copy (LIKE #{view});
DROP VIEW #{view};
ALTER TABLE #{view}_copy RENAME TO #{view};
SQL
end
+
+ # Set statement timeout temporarily.
+ # Useful when testing query timeouts.
+ #
+ # Note that this method cannot restore the timeout if a query
+ # was canceled due to e.g. a statement timeout.
+ # Refrain from using this transaction in these situations.
+ #
+ # @param timeout - Statement timeout in seconds
+ #
+ # Example:
+ #
+ # with_statement_timeout(0.1) do
+ # model.select('pg_sleep(0.11)')
+ # end
+ def with_statement_timeout(timeout)
+ # Force a positive value and a minimum of 1ms for very small values.
+ timeout = (timeout * 1000).abs.ceil
+
+ raise ArgumentError, 'Using a timeout of `0` means to disable statement timeout.' if timeout == 0
+
+ previous_timeout = ActiveRecord::Base.connection
+ .exec_query('SHOW statement_timeout')[0].fetch('statement_timeout')
+
+ set_statement_timeout("#{timeout}ms")
+
+ yield
+ ensure
+ begin
+ set_statement_timeout(previous_timeout)
+ rescue ActiveRecord::StatementInvalid
+ # After a transaction was canceled/aborted due to e.g. a statement
+ # timeout commands are ignored and will raise in PG::InFailedSqlTransaction.
+ # We can safely ignore this error because the statement timeout was set
+ # for the currrent transaction which will be closed anyway.
+ end
+ end
+
+ # Set statement timeout for the current transaction.
+ #
+ # Note, that it does not restore the previous statement timeout.
+ # Use `with_statement_timeout` instead.
+ #
+ # @param timeout - Statement timeout in seconds
+ #
+ # Example:
+ #
+ # set_statement_timeout(0.1)
+ # model.select('pg_sleep(0.11)')
+ def set_statement_timeout(timeout)
+ ActiveRecord::Base.connection.execute(
+ format(%(SET LOCAL statement_timeout = '%s'), timeout)
+ )
+ end
end
end
diff --git a/spec/support/helpers/dependency_proxy_helpers.rb b/spec/support/helpers/dependency_proxy_helpers.rb
index ebb849628bf..0d8f56906e3 100644
--- a/spec/support/helpers/dependency_proxy_helpers.rb
+++ b/spec/support/helpers/dependency_proxy_helpers.rb
@@ -18,11 +18,11 @@ module DependencyProxyHelpers
.to_return(status: status, body: body || manifest, headers: headers)
end
- def stub_manifest_head(image, tag, status: 200, body: nil, digest: '123456')
+ def stub_manifest_head(image, tag, status: 200, body: nil, headers: {})
manifest_url = registry.manifest_url(image, tag)
stub_full_request(manifest_url, method: :head)
- .to_return(status: status, body: body, headers: { 'docker-content-digest' => digest } )
+ .to_return(status: status, body: body, headers: headers )
end
def stub_blob_download(image, blob_sha, status = 200, body = '123456')
diff --git a/spec/support/helpers/design_management_test_helpers.rb b/spec/support/helpers/design_management_test_helpers.rb
index db217250b17..be723a47521 100644
--- a/spec/support/helpers/design_management_test_helpers.rb
+++ b/spec/support/helpers/design_management_test_helpers.rb
@@ -35,7 +35,7 @@ module DesignManagementTestHelpers
def act_on_designs(designs, &block)
issue = designs.first.issue
- version = build(:design_version, :empty, issue: issue).tap { |v| v.save!(validate: false) }
+ version = build(:design_version, designs_count: 0, issue: issue).tap { |v| v.save!(validate: false) }
designs.each do |d|
yield.create!(design: d, version: version)
end
diff --git a/spec/support/helpers/features/releases_helpers.rb b/spec/support/helpers/features/releases_helpers.rb
index 44087f71cfa..9cce9c4882d 100644
--- a/spec/support/helpers/features/releases_helpers.rb
+++ b/spec/support/helpers/features/releases_helpers.rb
@@ -1,9 +1,6 @@
# frozen_string_literal: true
-# These helpers fill fields on the "New Release" and
-# "Edit Release" pages. They use the keyboard to navigate
-# from one field to the next and assume that when
-# they are called, the field to be filled out is already focused.
+# These helpers fill fields on the "New Release" and "Edit Release" pages.
#
# Usage:
# describe "..." do
@@ -18,97 +15,65 @@ module Spec
module Helpers
module Features
module ReleasesHelpers
- # Returns the element that currently has keyboard focus.
- # Reminder that this returns a Selenium::WebDriver::Element
- # _not_ a Capybara::Node::Element
- def focused_element
- page.driver.browser.switch_to.active_element
- end
-
- def fill_tag_name(tag_name, and_tab: true)
- expect(focused_element).to eq(find_field('Tag name').native)
+ def select_new_tag_name(tag_name)
+ page.within '[data-testid="tag-name-field"]' do
+ find('button').click
- focused_element.send_keys(tag_name)
+ wait_for_all_requests
- focused_element.send_keys(:tab) if and_tab
- end
+ find('input[aria-label="Search or create tag"]').set(tag_name)
- def select_create_from(branch_name, and_tab: true)
- expect(focused_element).to eq(find('[data-testid="create-from-field"] button').native)
+ wait_for_all_requests
- focused_element.send_keys(:enter)
+ click_button("Create tag #{tag_name}")
+ end
+ end
- # Wait for the dropdown to be rendered
- page.find('.ref-selector .dropdown-menu')
+ def select_create_from(branch_name)
+ page.within '[data-testid="create-from-field"]' do
+ find('button').click
- # Pressing Enter in the search box shouldn't submit the form
- focused_element.send_keys(branch_name, :enter)
+ wait_for_all_requests
- # Wait for the search to return
- page.find('.ref-selector .dropdown-item', text: branch_name, match: :first)
+ find('input[aria-label="Search branches, tags, and commits"]').set(branch_name)
- focused_element.send_keys(:arrow_down, :enter)
+ wait_for_all_requests
- focused_element.send_keys(:tab) if and_tab
+ click_button("#{branch_name}")
+ end
end
- def fill_release_title(release_title, and_tab: true)
- expect(focused_element).to eq(find_field('Release title').native)
-
- focused_element.send_keys(release_title)
-
- focused_element.send_keys(:tab) if and_tab
+ def fill_release_title(release_title)
+ fill_in('Release title', with: release_title)
end
- def select_milestone(milestone_title, and_tab: true)
- expect(focused_element).to eq(find('[data-testid="milestones-field"] button').native)
-
- focused_element.send_keys(:enter)
+ def select_milestone(milestone_title)
+ page.within '[data-testid="milestones-field"]' do
+ find('button').click
- # Wait for the dropdown to be rendered
- page.find('.milestone-combobox .dropdown-menu')
+ wait_for_all_requests
- # Clear any existing input
- focused_element.attribute('value').length.times { focused_element.send_keys(:backspace) }
+ find('input[aria-label="Search Milestones"]').set(milestone_title)
- # Pressing Enter in the search box shouldn't submit the form
- focused_element.send_keys(milestone_title, :enter)
+ wait_for_all_requests
- # Wait for the search to return
- page.find('.milestone-combobox .dropdown-item', text: milestone_title, match: :first)
-
- focused_element.send_keys(:arrow_down, :arrow_down, :enter)
-
- focused_element.send_keys(:tab) if and_tab
+ find('button', text: milestone_title, match: :first).click
+ end
end
- def fill_release_notes(release_notes, and_tab: true)
- expect(focused_element).to eq(find_field('Release notes').native)
-
- focused_element.send_keys(release_notes)
-
- # Tab past the links at the bottom of the editor
- focused_element.send_keys(:tab, :tab, :tab) if and_tab
+ def fill_release_notes(release_notes)
+ fill_in('Release notes', with: release_notes)
end
- def fill_asset_link(link, and_tab: true)
- expect(focused_element['id']).to start_with('asset-url-')
-
- focused_element.send_keys(link[:url], :tab, link[:title], :tab, link[:type])
-
- # Tab past the "Remove asset link" button
- focused_element.send_keys(:tab, :tab) if and_tab
+ def fill_asset_link(link)
+ all('input[name="asset-url"]').last.set(link[:url])
+ all('input[name="asset-link-name"]').last.set(link[:title])
+ all('select[name="asset-type"]').last.find("option[value=\"#{link[:type]}\"").select_option
end
# Click "Add another link" and tab back to the beginning of the new row
def add_another_asset_link
- expect(focused_element).to eq(find_button('Add another link').native)
-
- focused_element.send_keys(:enter,
- [:shift, :tab],
- [:shift, :tab],
- [:shift, :tab],
- [:shift, :tab])
+ click_button('Add another link')
end
end
end
diff --git a/spec/support/helpers/gpg_helpers.rb b/spec/support/helpers/gpg_helpers.rb
index 389e5818dbe..813c6176317 100644
--- a/spec/support/helpers/gpg_helpers.rb
+++ b/spec/support/helpers/gpg_helpers.rb
@@ -279,6 +279,10 @@ module GpgHelpers
KEY
end
+ def primary_keyid2
+ fingerprint2[-16..-1]
+ end
+
def fingerprint2
'C447A6F6BFD9CEF8FB371785571625A930241179'
end
diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb
index 46d0c13dc18..75d9508f470 100644
--- a/spec/support/helpers/graphql_helpers.rb
+++ b/spec/support/helpers/graphql_helpers.rb
@@ -16,32 +16,130 @@ module GraphqlHelpers
underscored_field_name.to_s.camelize(:lower)
end
- # Run a loader's named resolver in a way that closely mimics the framework.
+ def self.deep_fieldnamerize(map)
+ map.to_h do |k, v|
+ [fieldnamerize(k), v.is_a?(Hash) ? deep_fieldnamerize(v) : v]
+ end
+ end
+
+ # Run this resolver exactly as it would be called in the framework. This
+ # includes all authorization hooks, all argument processing and all result
+ # wrapping.
+ # see: GraphqlHelpers#resolve_field
+ def resolve(
+ resolver_class, # [Class[<= BaseResolver]] The resolver at test.
+ obj: nil, # [Any] The BaseObject#object for the resolver (available as `#object` in the resolver).
+ args: {}, # [Hash] The arguments to the resolver (using client names).
+ ctx: {}, # [#to_h] The current context values.
+ schema: GitlabSchema, # [GraphQL::Schema] Schema to use during execution.
+ parent: :not_given, # A GraphQL query node to be passed as the `:parent` extra.
+ lookahead: :not_given # A GraphQL lookahead object to be passed as the `:lookahead` extra.
+ )
+ # All resolution goes through fields, so we need to create one here that
+ # uses our resolver. Thankfully, apart from the field name, resolvers
+ # contain all the configuration needed to define one.
+ field_options = resolver_class.field_options.merge(
+ owner: resolver_parent,
+ name: 'field_value'
+ )
+ field = ::Types::BaseField.new(**field_options)
+
+ # All mutations accept a single `:input` argument. Wrap arguments here.
+ # See the unwrapping below in GraphqlHelpers#resolve_field
+ args = { input: args } if resolver_class <= ::Mutations::BaseMutation && !args.key?(:input)
+
+ resolve_field(field, obj,
+ args: args,
+ ctx: ctx,
+ schema: schema,
+ object_type: resolver_parent,
+ extras: { parent: parent, lookahead: lookahead })
+ end
+
+ # Resolve the value of a field on an object.
+ #
+ # Use this method to test individual fields within type specs.
+ #
+ # e.g.
+ #
+ # issue = create(:issue)
+ # user = issue.author
+ # project = issue.project
+ #
+ # resolve_field(:author, issue, current_user: user, object_type: ::Types::IssueType)
+ # resolve_field(:issue, project, args: { iid: issue.iid }, current_user: user, object_type: ::Types::ProjectType)
+ #
+ # The `object_type` defaults to the `described_class`, so when called from type specs,
+ # the above can be written as:
#
- # First the `ready?` method is called. If it turns out that the resolver is not
- # ready, then the early return is returned instead.
+ # # In project_type_spec.rb
+ # resolve_field(:author, issue, current_user: user)
#
- # Then the resolve method is called.
- def resolve(resolver_class, args: {}, lookahead: :not_given, parent: :not_given, **resolver_args)
- args = aliased_args(resolver_class, args)
- args[:parent] = parent unless parent == :not_given
- args[:lookahead] = lookahead unless lookahead == :not_given
- resolver = resolver_instance(resolver_class, **resolver_args)
- ready, early_return = sync_all { resolver.ready?(**args) }
+ # # In issue_type_spec.rb
+ # resolve_field(:issue, project, args: { iid: issue.iid }, current_user: user)
+ #
+ # NB: Arguments are passed from the client's perspective. If there is an argument
+ # `foo` aliased as `bar`, then we would pass `args: { bar: the_value }`, and
+ # types are checked before resolution.
+ def resolve_field(
+ field, # An instance of `BaseField`, or the name of a field on the current described_class
+ object, # The current object of the `BaseObject` this field 'belongs' to
+ args: {}, # Field arguments (keys will be fieldnamerized)
+ ctx: {}, # Context values (important ones are :current_user)
+ extras: {}, # Stub values for field extras (parent and lookahead)
+ current_user: :not_given, # The current user (specified explicitly, overrides ctx[:current_user])
+ schema: GitlabSchema, # A specific schema instance
+ object_type: described_class # The `BaseObject` type this field belongs to
+ )
+ field = to_base_field(field, object_type)
+ ctx[:current_user] = current_user unless current_user == :not_given
+ query = GraphQL::Query.new(schema, context: ctx.to_h)
+ extras[:lookahead] = negative_lookahead if extras[:lookahead] == :not_given && field.extras.include?(:lookahead)
+
+ query_ctx = query.context
+
+ mock_extras(query_ctx, **extras)
+
+ parent = object_type.authorized_new(object, query_ctx)
+ raise UnauthorizedObject unless parent
+
+ # TODO: This will need to change when we move to the interpreter:
+ # At that point, arguments will be a plain ruby hash rather than
+ # an Arguments object
+ # see: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27536
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/210556
+ arguments = field.to_graphql.arguments_class.new(
+ GraphqlHelpers.deep_fieldnamerize(args),
+ context: query_ctx,
+ defaults_used: []
+ )
+
+ # we enable the request store so we can track gitaly calls.
+ ::Gitlab::WithRequestStore.with_request_store do
+ # TODO: This will need to change when we move to the interpreter - at that
+ # point we will call `field#resolve`
+
+ # Unwrap the arguments to mutations. This pairs with the wrapping in GraphqlHelpers#resolve
+ # If arguments are not wrapped first, then arguments processing will raise.
+ # If arguments are not unwrapped here, then the resolve method of the mutation will raise argument errors.
+ arguments = arguments.to_kwargs[:input] if field.resolver && field.resolver <= ::Mutations::BaseMutation
- return early_return unless ready
+ field.resolve_field(parent, arguments, query_ctx)
+ end
+ end
- resolver.resolve(**args)
+ def mock_extras(context, parent: :not_given, lookahead: :not_given)
+ allow(context).to receive(:parent).and_return(parent) unless parent == :not_given
+ allow(context).to receive(:lookahead).and_return(lookahead) unless lookahead == :not_given
end
- # TODO: Remove this method entirely when GraphqlHelpers uses real resolve_field
- # see: https://gitlab.com/gitlab-org/gitlab/-/issues/287791
- def aliased_args(resolver, args)
- definitions = resolver.arguments
+ # a synthetic BaseObject type to be used in resolver specs. See `GraphqlHelpers#resolve`
+ def resolver_parent
+ @resolver_parent ||= fresh_object_type('ResolverParent')
+ end
- args.transform_keys do |k|
- definitions[GraphqlHelpers.fieldnamerize(k)]&.keyword || k
- end
+ def fresh_object_type(name = 'Object')
+ Class.new(::Types::BaseObject) { graphql_name name }
end
def resolver_instance(resolver_class, obj: nil, ctx: {}, field: nil, schema: GitlabSchema)
@@ -124,9 +222,9 @@ module GraphqlHelpers
lazy_vals.is_a?(Array) ? lazy_vals.map { |val| sync(val) } : sync(lazy_vals)
end
- def graphql_query_for(name, attributes = {}, fields = nil)
+ def graphql_query_for(name, args = {}, selection = nil)
type = GitlabSchema.types['Query'].fields[GraphqlHelpers.fieldnamerize(name)]&.type
- wrap_query(query_graphql_field(name, attributes, fields, type))
+ wrap_query(query_graphql_field(name, args, selection, type))
end
def wrap_query(query)
@@ -171,25 +269,6 @@ module GraphqlHelpers
::Gitlab::Utils::MergeHash.merge(Array.wrap(variables).map(&:to_h)).to_json
end
- def resolve_field(name, object, args = {}, current_user: nil)
- q = GraphQL::Query.new(GitlabSchema)
- context = GraphQL::Query::Context.new(query: q, object: object, values: { current_user: current_user })
- allow(context).to receive(:parent).and_return(nil)
- field = described_class.fields.fetch(GraphqlHelpers.fieldnamerize(name))
- instance = described_class.authorized_new(object, context)
- raise UnauthorizedObject unless instance
-
- field.resolve_field(instance, args, context)
- end
-
- def simple_resolver(resolved_value = 'Resolved value')
- Class.new(Resolvers::BaseResolver) do
- define_method :resolve do |**_args|
- resolved_value
- end
- end
- end
-
# Recursively convert a Hash with Ruby-style keys to GraphQL fieldname-style keys
#
# prepare_input_for_mutation({ 'my_key' => 1 })
@@ -558,24 +637,26 @@ module GraphqlHelpers
end
end
- def execute_query(query_type)
- schema = Class.new(GraphQL::Schema) do
- use GraphQL::Pagination::Connections
- use Gitlab::Graphql::Authorize
- use Gitlab::Graphql::Pagination::Connections
-
- lazy_resolve ::Gitlab::Graphql::Lazy, :force
-
- query(query_type)
- end
+ # assumes query_string to be let-bound in the current context
+ def execute_query(query_type, schema: empty_schema, graphql: query_string)
+ schema.query(query_type)
schema.execute(
- query_string,
+ graphql,
context: { current_user: user },
variables: {}
)
end
+ def empty_schema
+ Class.new(GraphQL::Schema) do
+ use GraphQL::Pagination::Connections
+ use Gitlab::Graphql::Pagination::Connections
+
+ lazy_resolve ::Gitlab::Graphql::Lazy, :force
+ end
+ end
+
# A lookahead that selects everything
def positive_lookahead
double(selects?: true).tap do |selection|
@@ -589,6 +670,23 @@ module GraphqlHelpers
allow(selection).to receive(:selection).and_return(selection)
end
end
+
+ private
+
+ def to_base_field(name_or_field, object_type)
+ case name_or_field
+ when ::Types::BaseField
+ name_or_field
+ else
+ field_by_name(name_or_field, object_type)
+ end
+ end
+
+ def field_by_name(name, object_type)
+ name = ::GraphqlHelpers.fieldnamerize(name)
+
+ object_type.fields[name] || (raise ArgumentError, "Unknown field #{name} for #{described_class.graphql_name}")
+ end
end
# This warms our schema, doing this as part of loading the helpers to avoid
diff --git a/spec/support/helpers/javascript_fixtures_helpers.rb b/spec/support/helpers/javascript_fixtures_helpers.rb
index 2224af88ab9..09425c3742a 100644
--- a/spec/support/helpers/javascript_fixtures_helpers.rb
+++ b/spec/support/helpers/javascript_fixtures_helpers.rb
@@ -12,6 +12,7 @@ module JavaScriptFixturesHelpers
included do |base|
base.around do |example|
# pick an arbitrary date from the past, so tests are not time dependent
+ # Also see spec/frontend/__helpers__/fake_date/jest.js
Timecop.freeze(Time.utc(2015, 7, 3, 10)) { example.run }
raise NoMethodError.new('You need to set `response` for the fixture generator! This will automatically happen with `type: :controller` or `type: :request`.', 'response') unless respond_to?(:response)
diff --git a/spec/support/helpers/notification_helpers.rb b/spec/support/helpers/notification_helpers.rb
index aee57b452fe..6066f4ec3bf 100644
--- a/spec/support/helpers/notification_helpers.rb
+++ b/spec/support/helpers/notification_helpers.rb
@@ -3,10 +3,10 @@
module NotificationHelpers
extend self
- def send_notifications(*new_mentions)
+ def send_notifications(*new_mentions, current_user: @u_disabled)
mentionable.description = new_mentions.map(&:to_reference).join(' ')
- notification.send(notification_method, mentionable, new_mentions, @u_disabled)
+ notification.send(notification_method, mentionable, new_mentions, current_user)
end
def create_global_setting_for(user, level)
diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb
index 0d0ac171baa..56177d445d6 100644
--- a/spec/support/helpers/stub_object_storage.rb
+++ b/spec/support/helpers/stub_object_storage.rb
@@ -114,7 +114,7 @@ module StubObjectStorage
end
def stub_object_storage_multipart_init(endpoint, upload_id = "upload_id")
- stub_request(:post, %r{\A#{endpoint}tmp/uploads/[a-z0-9-]*\?uploads\z})
+ stub_request(:post, %r{\A#{endpoint}tmp/uploads/[%A-Za-z0-9-]*\?uploads\z})
.to_return status: 200, body: <<-EOS.strip_heredoc
<?xml version="1.0" encoding="UTF-8"?>
<InitiateMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb
index 2d71662b0eb..266c0e18ccd 100644
--- a/spec/support/helpers/test_env.rb
+++ b/spec/support/helpers/test_env.rb
@@ -77,7 +77,8 @@ module TestEnv
'sha-starting-with-large-number' => '8426165',
'invalid-utf8-diff-paths' => '99e4853',
'compare-with-merge-head-source' => 'f20a03d',
- 'compare-with-merge-head-target' => '2f1e176'
+ 'compare-with-merge-head-target' => '2f1e176',
+ 'trailers' => 'f0a5ed6'
}.freeze
# gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily
@@ -172,8 +173,13 @@ module TestEnv
Gitlab::SetupHelper::Gitaly.create_configuration(gitaly_dir, { 'default' => repos_path }, force: true)
Gitlab::SetupHelper::Gitaly.create_configuration(
gitaly_dir,
- { 'default' => repos_path }, force: true,
- options: { gitaly_socket: "gitaly2.socket", config_filename: "gitaly2.config.toml" }
+ { 'default' => repos_path },
+ force: true,
+ options: {
+ internal_socket_dir: File.join(gitaly_dir, "internal_gitaly2"),
+ gitaly_socket: "gitaly2.socket",
+ config_filename: "gitaly2.config.toml"
+ }
)
Gitlab::SetupHelper::Praefect.create_configuration(gitaly_dir, { 'praefect' => repos_path }, force: true)
end
@@ -186,7 +192,17 @@ module TestEnv
end
def gitaly_dir
- File.dirname(gitaly_socket_path)
+ socket_path = gitaly_socket_path
+ socket_path = File.expand_path(gitaly_socket_path) if expand_path?
+
+ File.dirname(socket_path)
+ end
+
+ # Linux fails with "bind: invalid argument" if a UNIX socket path exceeds 108 characters:
+ # https://github.com/golang/go/issues/6895. We use absolute paths in CI to ensure
+ # that changes in the current working directory don't affect GRPC reconnections.
+ def expand_path?
+ !!ENV['CI']
end
def start_gitaly(gitaly_dir)
diff --git a/spec/support/matchers/background_migrations_matchers.rb b/spec/support/matchers/background_migrations_matchers.rb
index 0144a044f6c..08bbbcc7438 100644
--- a/spec/support/matchers/background_migrations_matchers.rb
+++ b/spec/support/matchers/background_migrations_matchers.rb
@@ -1,7 +1,17 @@
# frozen_string_literal: true
+RSpec::Matchers.define :be_background_migration_with_arguments do |arguments|
+ define_method :matches? do |migration|
+ expect do
+ Gitlab::BackgroundMigration.perform(migration, arguments)
+ end.not_to raise_error
+ end
+end
+
RSpec::Matchers.define :be_scheduled_delayed_migration do |delay, *expected|
- match do |migration|
+ define_method :matches? do |migration|
+ expect(migration).to be_background_migration_with_arguments(expected)
+
BackgroundMigrationWorker.jobs.any? do |job|
job['args'] == [migration, expected] &&
job['at'].to_i == (delay.to_i + Time.now.to_i)
@@ -16,7 +26,9 @@ RSpec::Matchers.define :be_scheduled_delayed_migration do |delay, *expected|
end
RSpec::Matchers.define :be_scheduled_migration do |*expected|
- match do |migration|
+ define_method :matches? do |migration|
+ expect(migration).to be_background_migration_with_arguments(expected)
+
BackgroundMigrationWorker.jobs.any? do |job|
args = job['args'].size == 1 ? [BackgroundMigrationWorker.jobs[0]['args'][0], []] : job['args']
args == [migration, expected]
@@ -29,7 +41,9 @@ RSpec::Matchers.define :be_scheduled_migration do |*expected|
end
RSpec::Matchers.define :be_scheduled_migration_with_multiple_args do |*expected|
- match do |migration|
+ define_method :matches? do |migration|
+ expect(migration).to be_background_migration_with_arguments(expected)
+
BackgroundMigrationWorker.jobs.any? do |job|
args = job['args'].size == 1 ? [BackgroundMigrationWorker.jobs[0]['args'][0], []] : job['args']
args[0] == migration && compare_args(args, expected)
diff --git a/spec/support/matchers/email_matcher.rb b/spec/support/matchers/email_matcher.rb
new file mode 100644
index 00000000000..36cf3e0e871
--- /dev/null
+++ b/spec/support/matchers/email_matcher.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+RSpec::Matchers.define :have_text_part_content do |expected|
+ match do |actual|
+ @actual = actual.text_part.body.to_s
+ expect(@actual).to include(expected)
+ end
+
+ diffable
+end
+
+RSpec::Matchers.define :have_html_part_content do |expected|
+ match do |actual|
+ @actual = actual.html_part.body.to_s
+ expect(@actual).to include(expected)
+ end
+
+ diffable
+end
diff --git a/spec/support/matchers/graphql_matchers.rb b/spec/support/matchers/graphql_matchers.rb
index 8c4ba387a74..565c21e0f85 100644
--- a/spec/support/matchers/graphql_matchers.rb
+++ b/spec/support/matchers/graphql_matchers.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+RSpec::Matchers.define_negated_matcher :be_nullable, :be_non_null
+
RSpec::Matchers.define :require_graphql_authorizations do |*expected|
match do |klass|
permissions = if klass.respond_to?(:required_permissions)
@@ -90,7 +92,7 @@ RSpec::Matchers.define :have_graphql_arguments do |*expected|
@names ||= Array.wrap(expected).map { |name| GraphqlHelpers.fieldnamerize(name) }
if field.type.try(:ancestors)&.include?(GraphQL::Types::Relay::BaseConnection)
- @names | %w(after before first last)
+ @names | %w[after before first last]
else
@names
end
@@ -103,9 +105,10 @@ RSpec::Matchers.define :have_graphql_arguments do |*expected|
end
failure_message do |field|
- names = expected_names(field)
+ names = expected_names(field).inspect
+ args = field.arguments.keys.inspect
- "expected that #{field.name} would have the following fields: #{names.inspect}, but it has #{field.arguments.keys.inspect}."
+ "expected that #{field.name} would have the following arguments: #{names}, but it has #{args}."
end
end
diff --git a/spec/support/services/issues/move_and_clone_services_shared_examples.rb b/spec/support/services/issues/move_and_clone_services_shared_examples.rb
new file mode 100644
index 00000000000..2b2e90c0461
--- /dev/null
+++ b/spec/support/services/issues/move_and_clone_services_shared_examples.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'copy or reset relative position' do
+ before do
+ # ensure we have a relative position and it is known
+ old_issue.update!(relative_position: 1000)
+ end
+
+ context 'when moved to a project within same group hierarchy' do
+ it 'does not reset the relative_position' do
+ expect(subject.relative_position).to eq(1000)
+ end
+ end
+
+ context 'when moved to a project in a different group hierarchy' do
+ let_it_be(:new_project) { create(:project, group: create(:group)) }
+
+ it 'does reset the relative_position' do
+ expect(subject.relative_position).to be_nil
+ end
+ end
+end
diff --git a/spec/support/services/service_response_shared_examples.rb b/spec/support/services/service_response_shared_examples.rb
new file mode 100644
index 00000000000..186627347fb
--- /dev/null
+++ b/spec/support/services/service_response_shared_examples.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'returning an error service response' do |message: nil|
+ it 'returns an error service response' do
+ result = subject
+
+ expect(result).to be_error
+
+ if message
+ expect(result.message).to eq(message)
+ end
+ end
+end
+
+RSpec.shared_examples 'returning a success service response' do |message: nil|
+ it 'returns a success service response' do
+ result = subject
+
+ expect(result).to be_success
+
+ if message
+ expect(result.message).to eq(message)
+ end
+ end
+end
diff --git a/spec/support/shared_contexts/features/error_tracking_shared_context.rb b/spec/support/shared_contexts/features/error_tracking_shared_context.rb
index 1f4eb3a6df9..f04111e0ce0 100644
--- a/spec/support/shared_contexts/features/error_tracking_shared_context.rb
+++ b/spec/support/shared_contexts/features/error_tracking_shared_context.rb
@@ -9,7 +9,7 @@ RSpec.shared_context 'sentry error tracking context feature' do
let_it_be(:issue_response) { Gitlab::Json.parse(issue_response_body) }
let_it_be(:event_response_body) { fixture_file('sentry/issue_latest_event_sample_response.json') }
let_it_be(:event_response) { Gitlab::Json.parse(event_response_body) }
- let(:sentry_api_urls) { Sentry::ApiUrls.new(project_error_tracking_settings.api_url) }
+ let(:sentry_api_urls) { ErrorTracking::SentryClient::ApiUrls.new(project_error_tracking_settings.api_url) }
let(:issue_id) { issue_response['id'] }
let(:issue_seen) { 1.year.ago.utc }
let(:formatted_issue_seen) { issue_seen.strftime("%Y-%m-%d %-l:%M:%S%p %Z") }
diff --git a/spec/support/shared_contexts/issuable/merge_request_shared_context.rb b/spec/support/shared_contexts/issuable/merge_request_shared_context.rb
index 0fee170a35d..debcd9a3054 100644
--- a/spec/support/shared_contexts/issuable/merge_request_shared_context.rb
+++ b/spec/support/shared_contexts/issuable/merge_request_shared_context.rb
@@ -1,63 +1,23 @@
# frozen_string_literal: true
-RSpec.shared_context 'open merge request show action' do
+RSpec.shared_context 'merge request show action' do
include Spec::Support::Helpers::Features::MergeRequestHelpers
- let(:user) { create(:user) }
- let(:project) { create(:project, :public, :repository) }
- let(:note) { create(:note_on_merge_request, project: project, noteable: open_merge_request) }
-
- let(:open_merge_request) do
- create(:merge_request, :opened, source_project: project, author: user)
- end
-
- before do
- assign(:project, project)
- assign(:merge_request, open_merge_request)
- assign(:note, note)
- assign(:noteable, open_merge_request)
- assign(:notes, [])
- assign(:pipelines, Ci::Pipeline.none)
- assign(:issuable_sidebar, serialize_issuable_sidebar(user, project, open_merge_request))
-
- preload_view_requirements(open_merge_request, note)
-
- sign_in(user)
- end
-end
-
-RSpec.shared_context 'closed merge request show action' do
- include Devise::Test::ControllerHelpers
- include ProjectForksHelper
- include Spec::Support::Helpers::Features::MergeRequestHelpers
-
- let(:user) { create(:user) }
- let(:project) { create(:project, :public, :repository) }
- let(:forked_project) { fork_project(project, user, repository: true) }
- let(:unlink_project) { Projects::UnlinkForkService.new(forked_project, user) }
- let(:note) { create(:note_on_merge_request, project: project, noteable: closed_merge_request) }
-
- let(:closed_merge_request) do
- create(:closed_merge_request,
- source_project: forked_project,
- target_project: project,
- author: user)
- end
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:merge_request) { create(:merge_request, :opened, source_project: project, author: user) }
+ let_it_be(:note) { create(:note_on_merge_request, project: project, noteable: merge_request) }
before do
+ allow(view).to receive(:experiment_enabled?).and_return(false)
+ allow(view).to receive(:current_user).and_return(user)
assign(:project, project)
- assign(:merge_request, closed_merge_request)
- assign(:commits_count, 0)
+ assign(:merge_request, merge_request)
assign(:note, note)
- assign(:noteable, closed_merge_request)
- assign(:notes, [])
- assign(:pipelines, Ci::Pipeline.none)
- assign(:issuable_sidebar, serialize_issuable_sidebar(user, project, closed_merge_request))
-
- preload_view_requirements(closed_merge_request, note)
+ assign(:noteable, merge_request)
+ assign(:pipelines, [])
+ assign(:issuable_sidebar, serialize_issuable_sidebar(user, project, merge_request))
- allow(view).to receive_messages(current_user: user,
- can?: true,
- current_application_settings: Gitlab::CurrentSettings.current_application_settings)
+ preload_view_requirements(merge_request, note)
end
end
diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb
index 3fd4f2698e9..671c0cdf79c 100644
--- a/spec/support/shared_contexts/navbar_structure_context.rb
+++ b/spec/support/shared_contexts/navbar_structure_context.rb
@@ -5,7 +5,7 @@ RSpec.shared_context 'project navbar structure' do
{
nav_item: _('Analytics'),
nav_sub_items: [
- _('CI / CD'),
+ _('CI/CD'),
(_('Code Review') if Gitlab.ee?),
(_('Merge Request') if Gitlab.ee?),
_('Repository'),
@@ -63,7 +63,7 @@ RSpec.shared_context 'project navbar structure' do
nav_sub_items: []
},
{
- nav_item: _('CI / CD'),
+ nav_item: _('CI/CD'),
nav_sub_items: [
_('Pipelines'),
s_('Pipelines|Editor'),
@@ -111,7 +111,7 @@ RSpec.shared_context 'project navbar structure' do
_('Webhooks'),
_('Access Tokens'),
_('Repository'),
- _('CI / CD'),
+ _('CI/CD'),
_('Operations')
].compact
}
@@ -124,7 +124,8 @@ RSpec.shared_context 'group navbar structure' do
{
nav_item: _('Analytics'),
nav_sub_items: [
- _('Contribution')
+ _('Contribution'),
+ _('DevOps Adoption')
]
}
end
@@ -137,7 +138,7 @@ RSpec.shared_context 'group navbar structure' do
_('Integrations'),
_('Projects'),
_('Repository'),
- _('CI / CD'),
+ _('CI/CD'),
_('Packages & Registries'),
_('Webhooks')
]
diff --git a/spec/support/shared_contexts/policies/group_policy_shared_context.rb b/spec/support/shared_contexts/policies/group_policy_shared_context.rb
index e7bc1450601..b0d7274269b 100644
--- a/spec/support/shared_contexts/policies/group_policy_shared_context.rb
+++ b/spec/support/shared_contexts/policies/group_policy_shared_context.rb
@@ -18,12 +18,12 @@ RSpec.shared_context 'GroupPolicy context' do
]
end
- let(:read_group_permissions) { %i[read_label read_list read_milestone read_board] }
+ let(:read_group_permissions) { %i[read_label read_issue_board_list read_milestone read_issue_board] }
let(:reporter_permissions) do
%i[
admin_label
- admin_board
+ admin_issue_board
read_container_image
read_metrics_dashboard_annotation
read_prometheus
diff --git a/spec/support/shared_contexts/policies/project_policy_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_shared_context.rb
index 3016494ac8d..266c8d5ee84 100644
--- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb
+++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb
@@ -16,8 +16,8 @@ RSpec.shared_context 'ProjectPolicy context' do
let(:base_guest_permissions) do
%i[
award_emoji create_issue create_merge_request_in create_note
- create_project read_board read_issue read_issue_iid read_issue_link
- read_label read_list read_milestone read_note read_project
+ create_project read_issue_board read_issue read_issue_iid read_issue_link
+ read_label read_issue_board_list read_milestone read_note read_project
read_project_for_iids read_project_member read_release read_snippet
read_wiki upload_file
]
@@ -25,7 +25,7 @@ RSpec.shared_context 'ProjectPolicy context' do
let(:base_reporter_permissions) do
%i[
- admin_issue admin_issue_link admin_label admin_list create_snippet
+ admin_issue admin_issue_link admin_label admin_issue_board_list create_snippet
download_code download_wiki_code fork_project metrics_dashboard
read_build read_commit_status read_confidential_issues
read_container_image read_deployment read_environment read_merge_request
diff --git a/spec/support/shared_contexts/policies/project_policy_table_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_table_shared_context.rb
index 8c9a60fa703..fbd82fbbe31 100644
--- a/spec/support/shared_contexts/policies/project_policy_table_shared_context.rb
+++ b/spec/support/shared_contexts/policies/project_policy_table_shared_context.rb
@@ -356,7 +356,7 @@ RSpec.shared_context 'ProjectPolicyTable context' do
:private | :anonymous | 0
end
- # :snippet_level, :project_level, :feature_access_level, :membership, :expected_count
+ # :snippet_level, :project_level, :feature_access_level, :membership, :admin_mode, :expected_count
def permission_table_for_project_snippet_access
:public | :public | :enabled | :admin | true | 1
:public | :public | :enabled | :admin | false | 1
diff --git a/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb b/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb
index 60a29d78084..815108be447 100644
--- a/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb
+++ b/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb
@@ -5,8 +5,9 @@ RSpec.shared_context 'npm api setup' do
include HttpBasicAuthHelpers
let_it_be(:user, reload: true) { create(:user) }
- let_it_be(:group) { create(:group) }
- let_it_be(:project, reload: true) { create(:project, :public, namespace: group) }
+ let_it_be(:group) { create(:group, name: 'test-group') }
+ let_it_be(:namespace) { group }
+ let_it_be(:project, reload: true) { create(:project, :public, namespace: namespace) }
let_it_be(:package, reload: true) { create(:npm_package, project: project, name: "@#{group.path}/scoped_package") }
let_it_be(:token) { create(:oauth_access_token, scopes: 'api', resource_owner: user) }
let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
@@ -22,6 +23,10 @@ RSpec.shared_context 'set package name from package name type' do
case package_name_type
when :scoped_naming_convention
"@#{group.path}/scoped-package"
+ when :scoped_no_naming_convention
+ '@any-scope/scoped-package'
+ when :unscoped
+ 'unscoped-package'
when :non_existing
'non-existing-package'
end
diff --git a/spec/support/shared_contexts/security_and_compliance_permissions_shared_context.rb b/spec/support/shared_contexts/security_and_compliance_permissions_shared_context.rb
new file mode 100644
index 00000000000..dc5195e4b01
--- /dev/null
+++ b/spec/support/shared_contexts/security_and_compliance_permissions_shared_context.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+RSpec.shared_context '"Security & Compliance" permissions' do
+ let(:project_instance) { an_instance_of(Project) }
+ let(:user_instance) { an_instance_of(User) }
+ let(:before_request_defined) { false }
+ let(:valid_request) {}
+
+ def self.before_request(&block)
+ return unless block
+
+ let(:before_request_call) { instance_exec(&block) }
+ let(:before_request_defined) { true }
+ end
+
+ before do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(user_instance, :access_security_and_compliance, project_instance).and_return(true)
+ end
+
+ context 'when the "Security & Compliance" feature is disabled' do
+ subject { response }
+
+ before do
+ before_request_call if before_request_defined
+
+ allow(Ability).to receive(:allowed?).with(user_instance, :access_security_and_compliance, project_instance).and_return(false)
+ valid_request
+ end
+
+ it { is_expected.to have_gitlab_http_status(:not_found) }
+ end
+end
diff --git a/spec/support/shared_examples/alert_notification_service_shared_examples.rb b/spec/support/shared_examples/alert_notification_service_shared_examples.rb
index 7bd6df8c608..fc935effe0e 100644
--- a/spec/support/shared_examples/alert_notification_service_shared_examples.rb
+++ b/spec/support/shared_examples/alert_notification_service_shared_examples.rb
@@ -27,3 +27,18 @@ RSpec.shared_examples 'Alert Notification Service sends no notifications' do |ht
end
end
end
+
+RSpec.shared_examples 'creates status-change system note for an auto-resolved alert' do
+ it 'has 2 new system notes' do
+ expect { subject }.to change(Note, :count).by(2)
+ expect(Note.last.note).to include('Resolved')
+ end
+end
+
+# Requires `source` to be defined
+RSpec.shared_examples 'creates single system note based on the source of the alert' do
+ it 'has one new system note' do
+ expect { subject }.to change(Note, :count).by(1)
+ expect(Note.last.note).to include(source)
+ end
+end
diff --git a/spec/support/shared_examples/banzai/filters/emoji_shared_examples.rb b/spec/support/shared_examples/banzai/filters/emoji_shared_examples.rb
new file mode 100644
index 00000000000..da305f5ccaa
--- /dev/null
+++ b/spec/support/shared_examples/banzai/filters/emoji_shared_examples.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'emoji filter' do
+ it 'keeps whitespace intact' do
+ doc = filter("This deserves a #{emoji_name}, big time.")
+
+ expect(doc.to_html).to match(/^This deserves a <gl-emoji.+>, big time\.\z/)
+ end
+
+ it 'does not match emoji in a string' do
+ doc = filter("'2a00:a4c0#{emoji_name}:1'")
+
+ expect(doc.css('gl-emoji')).to be_empty
+ end
+
+ it 'ignores non existent/unsupported emoji' do
+ exp = '<p>:foo:</p>'
+ doc = filter(exp)
+
+ expect(doc.to_html).to eq(exp)
+ end
+
+ it 'matches with adjacent text' do
+ doc = filter("#{emoji_name.delete(':')} (#{emoji_name})")
+
+ expect(doc.css('gl-emoji').size).to eq 1
+ end
+
+ it 'does not match emoji in a pre tag' do
+ doc = filter("<p><pre>#{emoji_name}</pre></p>")
+
+ expect(doc.css('img')).to be_empty
+ end
+
+ it 'does not match emoji in code tag' do
+ doc = filter("<p><code>#{emoji_name} wow</code></p>")
+
+ expect(doc.css('img')).to be_empty
+ end
+
+ it 'does not match emoji in tt tag' do
+ doc = filter("<p><tt>#{emoji_name} yes!</tt></p>")
+
+ expect(doc.css('img')).to be_empty
+ end
+end
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 7f49d20c83e..9c8006ce4f1 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
@@ -9,6 +9,8 @@ RSpec.shared_examples 'multiple issue boards' do
login_as(user)
+ stub_feature_flags(board_new_list: false)
+
visit boards_path
wait_for_requests
end
diff --git a/spec/support/shared_examples/controllers/create_notes_rate_limit_shared_examples.rb b/spec/support/shared_examples/controllers/create_notes_rate_limit_shared_examples.rb
new file mode 100644
index 00000000000..74a98c20383
--- /dev/null
+++ b/spec/support/shared_examples/controllers/create_notes_rate_limit_shared_examples.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+#
+# Requires a context containing:
+# - user
+# - params
+# - request_full_path
+
+RSpec.shared_examples 'request exceeding rate limit' do
+ before do
+ stub_application_setting(notes_create_limit: 2)
+ 2.times { post :create, params: params }
+ end
+
+ it 'prevents from creating more notes', :request_store do
+ expect { post :create, params: params }
+ .to change { Note.count }.by(0)
+
+ expect(response).to have_gitlab_http_status(:too_many_requests)
+ expect(response.body).to eq(_('This endpoint has been requested too many times. Try again later.'))
+ end
+
+ it 'logs the event in auth.log' do
+ attributes = {
+ message: 'Application_Rate_Limiter_Request',
+ env: :notes_create_request_limit,
+ remote_ip: '0.0.0.0',
+ request_method: 'POST',
+ path: request_full_path,
+ user_id: user.id,
+ username: user.username
+ }
+
+ expect(Gitlab::AuthLogger).to receive(:error).with(attributes).once
+ post :create, params: params
+ end
+
+ it 'allows user in allow-list to create notes, even if the case is different' do
+ user.update_attribute(:username, user.username.titleize)
+ stub_application_setting(notes_create_limit_allowlist: ["#{user.username.downcase}"])
+
+ post :create, params: params
+ expect(response).to have_gitlab_http_status(:found)
+ end
+end
diff --git a/spec/support/shared_examples/controllers/snippet_blob_shared_examples.rb b/spec/support/shared_examples/controllers/snippet_blob_shared_examples.rb
index c3e8f807afb..62aaec85162 100644
--- a/spec/support/shared_examples/controllers/snippet_blob_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/snippet_blob_shared_examples.rb
@@ -17,6 +17,38 @@ RSpec.shared_examples 'raw snippet blob' do
end
end
+ context 'Content Disposition' do
+ context 'when the disposition is inline' do
+ let(:inline) { true }
+
+ it 'returns inline in the content disposition header' do
+ subject
+
+ expect(response.header['Content-Disposition']).to eq('inline')
+ end
+ end
+
+ context 'when the disposition is attachment' do
+ let(:inline) { false }
+
+ it 'returns attachment plus the filename in the content disposition header' do
+ subject
+
+ expect(response.header['Content-Disposition']).to match "attachment; filename=\"#{filepath}\""
+ end
+
+ context 'when the feature flag attachment_with_filename is disabled' do
+ it 'returns just attachment in the disposition header' do
+ stub_feature_flags(attachment_with_filename: false)
+
+ subject
+
+ expect(response.header['Content-Disposition']).to eq 'attachment'
+ end
+ end
+ end
+ end
+
context 'with invalid file path' do
let(:filepath) { 'doesnotexist' }
diff --git a/spec/support/shared_examples/features/comment_and_close_button_shared_examples.rb b/spec/support/shared_examples/features/comment_and_close_button_shared_examples.rb
deleted file mode 100644
index 4ee2840ed9f..00000000000
--- a/spec/support/shared_examples/features/comment_and_close_button_shared_examples.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.shared_examples 'page with comment and close button' do |button_text|
- context 'when remove_comment_close_reopen feature flag is enabled' do
- before do
- stub_feature_flags(remove_comment_close_reopen: true)
- setup
- end
-
- it "does not show #{button_text} button" do
- within '.note-form-actions' do
- expect(page).not_to have_button(button_text)
- end
- end
- end
-
- context 'when remove_comment_close_reopen feature flag is disabled' do
- before do
- stub_feature_flags(remove_comment_close_reopen: false)
- setup
- end
-
- it "shows #{button_text} button" do
- within '.note-form-actions' do
- expect(page).to have_button(button_text)
- end
- end
- end
-end
diff --git a/spec/support/shared_examples/features/discussion_comments_shared_example.rb b/spec/support/shared_examples/features/discussion_comments_shared_example.rb
index 6bebd59ed70..86ba2821c78 100644
--- a/spec/support/shared_examples/features/discussion_comments_shared_example.rb
+++ b/spec/support/shared_examples/features/discussion_comments_shared_example.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-RSpec.shared_examples 'thread comments' do |resource_name|
+RSpec.shared_examples 'thread comments for commit and snippet' do |resource_name|
let(:form_selector) { '.js-main-target-form' }
let(:dropdown_selector) { "#{form_selector} .comment-type-dropdown" }
let(:toggle_selector) { "#{dropdown_selector} .dropdown-toggle" }
@@ -24,23 +24,6 @@ RSpec.shared_examples 'thread comments' do |resource_name|
expect(new_comment).not_to have_selector '.discussion'
end
- if resource_name == 'issue'
- it "clicking 'Comment & close #{resource_name}' will post a comment and close the #{resource_name}" do
- find("#{form_selector} .note-textarea").send_keys(comment)
-
- click_button 'Comment & close issue'
-
- wait_for_all_requests
-
- expect(page).to have_content(comment)
- expect(page).to have_content "@#{user.username} closed"
-
- new_comment = all(comments_selector).last
-
- expect(new_comment).not_to have_selector '.discussion'
- end
- end
-
describe 'when the toggle is clicked' do
before do
find("#{form_selector} .note-textarea").send_keys(comment)
@@ -110,33 +93,172 @@ RSpec.shared_examples 'thread comments' do |resource_name|
end
it 'updates the submit button text and closes the dropdown' do
- button = find(submit_selector)
+ expect(find(submit_selector).value).to eq 'Start thread'
- # on issues page, the submit input is a <button>, on other pages it is <input>
- if button.tag_name == 'button'
- expect(find(submit_selector)).to have_content 'Start thread'
- else
- expect(find(submit_selector).value).to eq 'Start thread'
+ expect(page).not_to have_selector menu_selector
+ end
+
+ describe 'creating a thread' do
+ before do
+ find(submit_selector).click
+ wait_for_requests
+
+ find(comments_selector, match: :first)
end
- expect(page).not_to have_selector menu_selector
+ def submit_reply(text)
+ find("#{comments_selector} .js-vue-discussion-reply").click
+ find("#{comments_selector} .note-textarea").send_keys(text)
+
+ find("#{comments_selector} .js-comment-button").click
+ wait_for_requests
+ end
+
+ it 'clicking "Start thread" will post a thread' do
+ expect(page).to have_content(comment)
+
+ new_comment = all(comments_selector).last
+
+ expect(new_comment).to have_selector('.discussion')
+ end
end
- if resource_name =~ /(issue|merge request)/
- it 'updates the close button text' do
- expect(find(close_selector)).to have_content "Start thread & close #{resource_name}"
+ describe 'when opening the menu' do
+ before do
+ find(toggle_selector).click
+ end
+
+ it 'has "Start thread" selected' do
+ find("#{menu_selector} li", match: :first)
+ items = all("#{menu_selector} li")
+
+ expect(items.first).to have_content 'Comment'
+ expect(items.first).not_to have_selector '[data-testid="check-icon"]'
+ expect(items.first['class']).not_to match 'droplab-item-selected'
+
+ expect(items.last).to have_content 'Start thread'
+ expect(items.last).to have_selector '[data-testid="check-icon"]'
+ expect(items.last['class']).to match 'droplab-item-selected'
end
- it 'typing does not change the close button text' do
- find("#{form_selector} .note-textarea").send_keys('b')
+ describe 'when selecting "Comment"' do
+ before do
+ find("#{menu_selector} li", match: :first).click
+ end
+
+ it 'updates the submit button text and closes the dropdown' do
+ button = find(submit_selector)
+
+ expect(button.value).to eq 'Comment'
+
+ expect(page).not_to have_selector menu_selector
+ end
- expect(find(close_selector)).to have_content "Start thread & close #{resource_name}"
+ it 'has "Comment" selected when opening the menu', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/196825' do
+ find(toggle_selector).click
+
+ find("#{menu_selector} li", match: :first)
+ items = all("#{menu_selector} li")
+
+ aggregate_failures do
+ expect(items.first).to have_content 'Comment'
+ expect(items.first).to have_selector '[data-testid="check-icon"]'
+ expect(items.first['class']).to match 'droplab-item-selected'
+
+ expect(items.last).to have_content 'Start thread'
+ expect(items.last).not_to have_selector '[data-testid="check-icon"]'
+ expect(items.last['class']).not_to match 'droplab-item-selected'
+ end
+ end
end
end
+ end
+ end
+end
+
+RSpec.shared_examples 'thread comments for issue, epic and merge request' do |resource_name|
+ let(:form_selector) { '.js-main-target-form' }
+ let(:dropdown_selector) { "#{form_selector} [data-testid='comment-button']" }
+ let(:submit_button_selector) { "#{dropdown_selector} .split-content-button" }
+ let(:toggle_selector) { "#{dropdown_selector} .dropdown-toggle-split" }
+ let(:menu_selector) { "#{dropdown_selector} .dropdown-menu" }
+ let(:close_selector) { "#{form_selector} .btn-comment-and-close" }
+ let(:comments_selector) { '.timeline > .note.timeline-entry' }
+ let(:comment) { 'My comment' }
+
+ it 'clicking "Comment" will post a comment' do
+ expect(page).to have_selector toggle_selector
+
+ find("#{form_selector} .note-textarea").send_keys(comment)
+
+ find(submit_button_selector).click
+
+ expect(page).to have_content(comment)
+
+ new_comment = all(comments_selector).last
+
+ expect(new_comment).not_to have_selector '.discussion'
+ end
+
+ if resource_name == 'issue'
+ it "clicking 'Comment & close #{resource_name}' will post a comment and close the #{resource_name}" do
+ find("#{form_selector} .note-textarea").send_keys(comment)
+
+ click_button 'Comment & close issue'
+
+ wait_for_all_requests
+
+ expect(page).to have_content(comment)
+ expect(page).to have_content "@#{user.username} closed"
+
+ new_comment = all(comments_selector).last
+
+ expect(new_comment).not_to have_selector '.discussion'
+ end
+ end
+
+ describe 'when the toggle is clicked' do
+ before do
+ find("#{form_selector} .note-textarea").send_keys(comment)
+
+ find(toggle_selector).click
+ end
+
+ it 'has a "Comment" item (selected by default) and "Start thread" item' do
+ expect(page).to have_selector menu_selector
+
+ find("#{menu_selector} li", match: :first)
+ items = all("#{menu_selector} li")
+
+ expect(page).to have_selector("#{dropdown_selector}[data-track-label='comment_button']")
+
+ expect(items.first).to have_content 'Comment'
+ expect(items.first).to have_content "Add a general comment to this #{resource_name}."
+
+ expect(items.last).to have_content 'Start thread'
+ expect(items.last).to have_content "Discuss a specific suggestion or question#{' that needs to be resolved' if resource_name == 'merge request'}."
+ end
+
+ it 'closes the menu when clicking the toggle or body' do
+ find(toggle_selector).click
+
+ expect(page).not_to have_selector menu_selector
+
+ find(toggle_selector).click
+ find("#{form_selector} .note-textarea").click
+
+ expect(page).not_to have_selector menu_selector
+ end
+
+ describe 'when selecting "Start thread"' do
+ before do
+ find("#{menu_selector} li", match: :first)
+ all("#{menu_selector} li").last.click
+ end
describe 'creating a thread' do
before do
- find(submit_selector).click
+ find(submit_button_selector).click
wait_for_requests
find(comments_selector, match: :first)
@@ -146,6 +268,7 @@ RSpec.shared_examples 'thread comments' do |resource_name|
find("#{comments_selector} .js-vue-discussion-reply").click
find("#{comments_selector} .note-textarea").send_keys(text)
+ # .js-comment-button here refers to the reply button in note_form.vue
find("#{comments_selector} .js-comment-button").click
wait_for_requests
end
@@ -228,13 +351,11 @@ RSpec.shared_examples 'thread comments' do |resource_name|
find("#{menu_selector} li", match: :first)
items = all("#{menu_selector} li")
+ expect(page).to have_selector("#{dropdown_selector}[data-track-label='start_thread_button']")
+
expect(items.first).to have_content 'Comment'
- expect(items.first).not_to have_selector '[data-testid="check-icon"]'
- expect(items.first['class']).not_to match 'droplab-item-selected'
expect(items.last).to have_content 'Start thread'
- expect(items.last).to have_selector '[data-testid="check-icon"]'
- expect(items.last['class']).to match 'droplab-item-selected'
end
describe 'when selecting "Comment"' do
@@ -243,14 +364,9 @@ RSpec.shared_examples 'thread comments' do |resource_name|
end
it 'updates the submit button text and closes the dropdown' do
- button = find(submit_selector)
+ button = find(submit_button_selector)
- # on issues page, the submit input is a <button>, on other pages it is <input>
- if button.tag_name == 'button'
- expect(button).to have_content 'Comment'
- else
- expect(button.value).to eq 'Comment'
- end
+ expect(button).to have_content 'Comment'
expect(page).not_to have_selector menu_selector
end
@@ -267,21 +383,17 @@ RSpec.shared_examples 'thread comments' do |resource_name|
end
end
- it 'has "Comment" selected when opening the menu', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/196825' do
+ it 'has "Comment" selected when opening the menu' do
find(toggle_selector).click
find("#{menu_selector} li", match: :first)
items = all("#{menu_selector} li")
- aggregate_failures do
- expect(items.first).to have_content 'Comment'
- expect(items.first).to have_selector '[data-testid="check-icon"]'
- expect(items.first['class']).to match 'droplab-item-selected'
+ expect(page).to have_selector("#{dropdown_selector}[data-track-label='comment_button']")
- expect(items.last).to have_content 'Start thread'
- expect(items.last).not_to have_selector '[data-testid="check-icon"]'
- expect(items.last['class']).not_to match 'droplab-item-selected'
- end
+ expect(items.first).to have_content 'Comment'
+
+ expect(items.last).to have_content 'Start thread'
end
end
end
diff --git a/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb b/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb
index 3fec1a56c0c..7a32f61d4fa 100644
--- a/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb
+++ b/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb
@@ -1,11 +1,7 @@
# frozen_string_literal: true
RSpec.shared_examples 'issuable invite members experiments' do
- context 'when invite_members_version_a experiment is enabled' do
- before do
- stub_experiment_for_subject(invite_members_version_a: true)
- end
-
+ context 'when a privileged user can invite' do
it 'shows a link for inviting members and follows through to the members page' do
project.add_maintainer(user)
visit issuable_path
@@ -51,9 +47,9 @@ RSpec.shared_examples 'issuable invite members experiments' do
end
end
- context 'when no invite members experiments are enabled' do
+ context 'when invite_members_version_b experiment is disabled' do
it 'shows author in assignee dropdown and no invite link' do
- project.add_maintainer(user)
+ project.add_developer(user)
visit issuable_path
find('.block.assignee .edit-link').click
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 25203fa3182..00d3bd08218 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
@@ -3,7 +3,13 @@
RSpec.shared_examples 'it uploads and commit a new text file' do
it 'uploads and commit a new text file', :js do
find('.add-to-tree').click
- click_link('Upload file')
+
+ page.within('.dropdown-menu') do
+ click_link('Upload file')
+
+ wait_for_requests
+ end
+
drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'))
page.within('#modal-upload-blob') do
@@ -29,7 +35,13 @@ end
RSpec.shared_examples 'it uploads and commit a new image file' do
it 'uploads and commit a new image file', :js do
find('.add-to-tree').click
- click_link('Upload file')
+
+ page.within('.dropdown-menu') do
+ click_link('Upload file')
+
+ wait_for_requests
+ end
+
drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'logo_sample.svg'))
page.within('#modal-upload-blob') do
@@ -82,3 +94,21 @@ RSpec.shared_examples 'it uploads and commit a new file to a forked project' do
expect(page).to have_content('Sed ut perspiciatis unde omnis')
end
end
+
+RSpec.shared_examples 'uploads and commits a new text file via "upload file" button' do
+ it 'uploads and commits a new text file via "upload file" button', :js do
+ find('[data-testid="upload-file-button"]').click
+
+ attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true)
+
+ page.within('#details-modal-upload-blob') do
+ fill_in(:commit_message, with: 'New commit message')
+ end
+
+ click_button('Upload file')
+
+ expect(page).to have_content('New commit message')
+ expect(page).to have_content('Lorem ipsum dolor sit amet')
+ expect(page).to have_content('Sed ut perspiciatis unde omnis')
+ end
+end
diff --git a/spec/support/shared_examples/features/variable_list_shared_examples.rb b/spec/support/shared_examples/features/variable_list_shared_examples.rb
index e0d169c6868..2fd88b610e9 100644
--- a/spec/support/shared_examples/features/variable_list_shared_examples.rb
+++ b/spec/support/shared_examples/features/variable_list_shared_examples.rb
@@ -2,7 +2,7 @@
RSpec.shared_examples 'variable list' do
it 'shows a list of variables' do
- page.within('.ci-variable-table') do
+ page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq(variable.key)
end
end
@@ -16,7 +16,7 @@ RSpec.shared_examples 'variable list' do
wait_for_requests
- page.within('.ci-variable-table') do
+ page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq('key')
end
end
@@ -30,7 +30,7 @@ RSpec.shared_examples 'variable list' do
wait_for_requests
- page.within('.ci-variable-table') do
+ page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq('key')
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Protected"] svg[data-testid="mobile-issue-close-icon"]')).to be_present
end
@@ -45,14 +45,14 @@ RSpec.shared_examples 'variable list' do
wait_for_requests
- page.within('.ci-variable-table') do
+ page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq('key')
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Masked"] svg[data-testid="close-icon"]')).to be_present
end
end
it 'reveals and hides variables' do
- page.within('.ci-variable-table') do
+ page.within('[data-testid="ci-variable-table"]') do
expect(first('.js-ci-variable-row td[data-label="Key"]').text).to eq(variable.key)
expect(page).to have_content('*' * 17)
@@ -72,7 +72,7 @@ RSpec.shared_examples 'variable list' do
it 'deletes a variable' do
expect(page).to have_selector('.js-ci-variable-row', count: 1)
- page.within('.ci-variable-table') do
+ page.within('[data-testid="ci-variable-table"]') do
click_button('Edit')
end
@@ -86,7 +86,7 @@ RSpec.shared_examples 'variable list' do
end
it 'edits a variable' do
- page.within('.ci-variable-table') do
+ page.within('[data-testid="ci-variable-table"]') do
click_button('Edit')
end
@@ -102,7 +102,7 @@ RSpec.shared_examples 'variable list' do
end
it 'edits a variable to be unmasked' do
- page.within('.ci-variable-table') do
+ page.within('[data-testid="ci-variable-table"]') do
click_button('Edit')
end
@@ -115,13 +115,13 @@ RSpec.shared_examples 'variable list' do
wait_for_requests
- page.within('.ci-variable-table') do
+ page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Masked"] svg[data-testid="close-icon"]')).to be_present
end
end
it 'edits a variable to be masked' do
- page.within('.ci-variable-table') do
+ page.within('[data-testid="ci-variable-table"]') do
click_button('Edit')
end
@@ -133,7 +133,7 @@ RSpec.shared_examples 'variable list' do
wait_for_requests
- page.within('.ci-variable-table') do
+ page.within('[data-testid="ci-variable-table"]') do
click_button('Edit')
end
@@ -143,7 +143,7 @@ RSpec.shared_examples 'variable list' do
click_button('Update variable')
end
- page.within('.ci-variable-table') do
+ page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Masked"] svg[data-testid="mobile-issue-close-icon"]')).to be_present
end
end
@@ -211,7 +211,7 @@ RSpec.shared_examples 'variable list' do
expect(page).to have_selector('.js-ci-variable-row', count: 3)
# Remove the `akey` variable
- page.within('.ci-variable-table') do
+ page.within('[data-testid="ci-variable-table"]') do
page.within('.js-ci-variable-row:first-child') do
click_button('Edit')
end
diff --git a/spec/support/shared_examples/graphql/mutation_shared_examples.rb b/spec/support/shared_examples/graphql/mutation_shared_examples.rb
index 84ebd4852b9..51d52cbb901 100644
--- a/spec/support/shared_examples/graphql/mutation_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/mutation_shared_examples.rb
@@ -48,6 +48,6 @@ RSpec.shared_examples 'a mutation that returns errors in the response' do |error
it do
post_graphql_mutation(mutation, current_user: current_user)
- expect(mutation_response['errors']).to eq(errors)
+ expect(mutation_response['errors']).to match_array(errors)
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 2b93d174653..2e3a3ce6b41 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
@@ -66,9 +66,7 @@ RSpec.shared_examples 'boards create mutation' do
context 'when the Boards::CreateService returns an error response' do
before do
- allow_next_instance_of(Boards::CreateService) do |service|
- allow(service).to receive(:execute).and_return(ServiceResponse.error(message: 'There was an error.'))
- end
+ params[:name] = ''
end
it 'does not create a board' do
@@ -80,7 +78,7 @@ RSpec.shared_examples 'boards create mutation' do
expect(mutation_response).to have_key('board')
expect(mutation_response['board']).to be_nil
- expect(mutation_response['errors'].first).to eq('There was an error.')
+ expect(mutation_response['errors'].first).to eq('There was an error when creating a board.')
end
end
end
diff --git a/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb b/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb
index d294f034d2e..bb4270d7db6 100644
--- a/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb
+++ b/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb
@@ -21,14 +21,14 @@ RSpec.shared_examples 'a mutation which can mutate a spammable' do
end
end
- describe "#with_spam_action_fields" do
+ describe "#with_spam_action_response_fields" do
it 'resolves with spam action fields' do
subject
# NOTE: We do not need to assert on the specific values of spam action fields here, we only need
- # to verify that #with_spam_action_fields was invoked and that the fields are present in the
- # response. The specific behavior of #with_spam_action_fields is covered in the
- # CanMutateSpammable unit tests.
+ # to verify that #with_spam_action_response_fields was invoked and that the fields are present in the
+ # response. The specific behavior of #with_spam_action_response_fields is covered in the
+ # HasSpamActionResponseFields unit tests.
expect(mutation_response.keys)
.to include('spam', 'spamLogId', 'needsCaptchaResponse', 'captchaSiteKey')
end
diff --git a/spec/support/shared_examples/graphql/notes_on_noteables_shared_examples.rb b/spec/support/shared_examples/graphql/notes_on_noteables_shared_examples.rb
index 41b7da07d2d..0d2e9f6ec8c 100644
--- a/spec/support/shared_examples/graphql/notes_on_noteables_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/notes_on_noteables_shared_examples.rb
@@ -17,7 +17,7 @@ RSpec.shared_context 'exposing regular notes on a noteable in GraphQL' do
notes {
edges {
node {
- #{all_graphql_fields_for('Note')}
+ #{all_graphql_fields_for('Note', max_depth: 1)}
}
}
}
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 269e9170906..bc091a678e2 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
@@ -6,7 +6,7 @@ RSpec.shared_examples 'Gitlab-style deprecations' do
expect { subject(deprecation_reason: 'foo') }.to raise_error(
ArgumentError,
'Use `deprecated` property instead of `deprecation_reason`. ' \
- 'See https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-fields-and-enum-values'
+ 'See https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-fields-arguments-and-enum-values'
)
end
diff --git a/spec/support/shared_examples/helpers/issuable_description_templates_shared_examples.rb b/spec/support/shared_examples/helpers/issuable_description_templates_shared_examples.rb
index 9e8c96d576a..47e34b21036 100644
--- a/spec/support/shared_examples/helpers/issuable_description_templates_shared_examples.rb
+++ b/spec/support/shared_examples/helpers/issuable_description_templates_shared_examples.rb
@@ -23,11 +23,11 @@ RSpec.shared_examples 'project issuable templates' do
end
it 'returns only md files as issue templates' do
- expect(helper.issuable_templates(project, 'issue')).to eq(templates('issue', project))
+ expect(helper.issuable_templates(project, 'issue')).to eq(expected_templates('issue'))
end
it 'returns only md files as merge_request templates' do
- expect(helper.issuable_templates(project, 'merge_request')).to eq(templates('merge_request', project))
+ expect(helper.issuable_templates(project, 'merge_request')).to eq(expected_templates('merge_request'))
end
end
diff --git a/spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb
index 145a7290ac8..7d341d79bae 100644
--- a/spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb
@@ -8,6 +8,7 @@ RSpec.shared_examples_for 'value stream analytics event' do
it { expect(described_class.identifier).to be_a_kind_of(Symbol) }
it { expect(instance.object_type.ancestors).to include(ApplicationRecord) }
it { expect(instance).to respond_to(:timestamp_projection) }
+ it { expect(instance).to respond_to(:markdown_description) }
it { expect(instance.column_list).to be_a_kind_of(Array) }
describe '#apply_query_customization' do
diff --git a/spec/support/shared_examples/lib/gitlab/usage_data_counters/issue_activity_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb
index edd9b6cdf37..aa6e64a3820 100644
--- a/spec/support/shared_examples/lib/gitlab/usage_data_counters/issue_activity_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-RSpec.shared_examples 'a tracked issue edit event' do |event|
+RSpec.shared_examples 'a daily tracked issuable event' do
before do
stub_application_setting(usage_ping_enabled: true)
end
@@ -25,3 +25,13 @@ RSpec.shared_examples 'a tracked issue edit event' do |event|
expect(track_action(author: nil)).to be_nil
end
end
+
+RSpec.shared_examples 'does not track when feature flag is disabled' do |feature_flag|
+ context "when feature flag #{feature_flag} is disabled" do
+ it 'does not track action' do
+ stub_feature_flags(feature_flag => false)
+
+ expect(track_action(author: user1)).to be_nil
+ end
+ end
+end
diff --git a/spec/support/shared_examples/lib/sentry/client_shared_examples.rb b/spec/support/shared_examples/lib/sentry/client_shared_examples.rb
index 4221708b55c..d73c7b6848d 100644
--- a/spec/support/shared_examples/lib/sentry/client_shared_examples.rb
+++ b/spec/support/shared_examples/lib/sentry/client_shared_examples.rb
@@ -26,7 +26,7 @@ RSpec.shared_examples 'no Sentry redirects' do |http_method|
end
it 'does not follow redirects' do
- expect { subject }.to raise_exception(Sentry::Client::Error, 'Sentry response status code: 302')
+ expect { subject }.to raise_exception(ErrorTracking::SentryClient::Error, 'Sentry response status code: 302')
expect(redirect_req_stub).to have_been_requested
expect(redirected_req_stub).not_to have_been_requested
end
@@ -53,7 +53,7 @@ RSpec.shared_examples 'maps Sentry exceptions' do |http_method|
it do
expect { subject }
- .to raise_exception(Sentry::Client::Error, message)
+ .to raise_exception(ErrorTracking::SentryClient::Error, message)
end
end
end
diff --git a/spec/support/shared_examples/metrics/active_record_subscriber_shared_examples.rb b/spec/support/shared_examples/metrics/active_record_subscriber_shared_examples.rb
new file mode 100644
index 00000000000..7bf2456c548
--- /dev/null
+++ b/spec/support/shared_examples/metrics/active_record_subscriber_shared_examples.rb
@@ -0,0 +1,137 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'store ActiveRecord info in RequestStore' do |db_role|
+ it 'prevents db counters from leaking to the next transaction' do
+ 2.times do
+ Gitlab::WithRequestStore.with_request_store do
+ subscriber.sql(event)
+
+ if db_role == :primary
+ expect(described_class.db_counter_payload).to eq(
+ db_count: record_query ? 1 : 0,
+ db_write_count: record_write_query ? 1 : 0,
+ db_cached_count: record_cached_query ? 1 : 0,
+ db_primary_cached_count: record_cached_query ? 1 : 0,
+ db_primary_count: record_query ? 1 : 0,
+ db_primary_duration_s: record_query ? 0.002 : 0,
+ db_replica_cached_count: 0,
+ db_replica_count: 0,
+ db_replica_duration_s: 0.0
+ )
+ elsif db_role == :replica
+ expect(described_class.db_counter_payload).to eq(
+ db_count: record_query ? 1 : 0,
+ db_write_count: record_write_query ? 1 : 0,
+ db_cached_count: record_cached_query ? 1 : 0,
+ db_primary_cached_count: 0,
+ db_primary_count: 0,
+ db_primary_duration_s: 0.0,
+ db_replica_cached_count: record_cached_query ? 1 : 0,
+ db_replica_count: record_query ? 1 : 0,
+ db_replica_duration_s: record_query ? 0.002 : 0
+ )
+ else
+ expect(described_class.db_counter_payload).to eq(
+ db_count: record_query ? 1 : 0,
+ db_write_count: record_write_query ? 1 : 0,
+ db_cached_count: record_cached_query ? 1 : 0
+ )
+ end
+ end
+ end
+ end
+end
+
+RSpec.shared_examples 'record ActiveRecord metrics in a metrics transaction' do |db_role|
+ it 'increments only db counters' do
+ if record_query
+ expect(transaction).to receive(:increment).with(:gitlab_transaction_db_count_total, 1)
+ expect(transaction).to receive(:increment).with("gitlab_transaction_db_#{db_role}_count_total".to_sym, 1) if db_role
+ else
+ expect(transaction).not_to receive(:increment).with(:gitlab_transaction_db_count_total, 1)
+ expect(transaction).not_to receive(:increment).with("gitlab_transaction_db_#{db_role}_count_total".to_sym, 1) if db_role
+ end
+
+ if record_write_query
+ expect(transaction).to receive(:increment).with(:gitlab_transaction_db_write_count_total, 1)
+ else
+ expect(transaction).not_to receive(:increment).with(:gitlab_transaction_db_write_count_total, 1)
+ end
+
+ if record_cached_query
+ expect(transaction).to receive(:increment).with(:gitlab_transaction_db_cached_count_total, 1)
+ expect(transaction).to receive(:increment).with("gitlab_transaction_db_#{db_role}_cached_count_total".to_sym, 1) if db_role
+ else
+ expect(transaction).not_to receive(:increment).with(:gitlab_transaction_db_cached_count_total, 1)
+ expect(transaction).not_to receive(:increment).with("gitlab_transaction_db_#{db_role}_cached_count_total".to_sym, 1) if db_role
+ end
+
+ subscriber.sql(event)
+ end
+
+ it 'observes sql_duration metric' do
+ if record_query
+ expect(transaction).to receive(:observe).with(:gitlab_sql_duration_seconds, 0.002)
+ expect(transaction).to receive(:observe).with("gitlab_sql_#{db_role}_duration_seconds".to_sym, 0.002) if db_role
+ else
+ expect(transaction).not_to receive(:observe)
+ end
+
+ subscriber.sql(event)
+ end
+end
+
+RSpec.shared_examples 'record ActiveRecord metrics' do |db_role|
+ context 'when both web and background transaction are available' do
+ let(:transaction) { double('Gitlab::Metrics::WebTransaction') }
+ let(:background_transaction) { double('Gitlab::Metrics::WebTransaction') }
+
+ before do
+ allow(::Gitlab::Metrics::WebTransaction).to receive(:current)
+ .and_return(transaction)
+ allow(::Gitlab::Metrics::BackgroundTransaction).to receive(:current)
+ .and_return(background_transaction)
+ allow(transaction).to receive(:increment)
+ allow(transaction).to receive(:observe)
+ end
+
+ it_behaves_like 'record ActiveRecord metrics in a metrics transaction', db_role
+
+ it 'captures the metrics for web only' do
+ expect(background_transaction).not_to receive(:observe)
+ expect(background_transaction).not_to receive(:increment)
+
+ subscriber.sql(event)
+ end
+ end
+
+ context 'when web transaction is available' do
+ let(:transaction) { double('Gitlab::Metrics::WebTransaction') }
+
+ before do
+ allow(::Gitlab::Metrics::WebTransaction).to receive(:current)
+ .and_return(transaction)
+ allow(::Gitlab::Metrics::BackgroundTransaction).to receive(:current)
+ .and_return(nil)
+ allow(transaction).to receive(:increment)
+ allow(transaction).to receive(:observe)
+ end
+
+ it_behaves_like 'record ActiveRecord metrics in a metrics transaction', db_role
+ end
+
+ context 'when background transaction is available' do
+ let(:transaction) { double('Gitlab::Metrics::BackgroundTransaction') }
+
+ before do
+ allow(::Gitlab::Metrics::WebTransaction).to receive(:current)
+ .and_return(nil)
+ allow(::Gitlab::Metrics::BackgroundTransaction).to receive(:current)
+ .and_return(transaction)
+ allow(transaction).to receive(:increment)
+ allow(transaction).to receive(:observe)
+ end
+
+ it_behaves_like 'record ActiveRecord metrics in a metrics transaction', db_role
+ end
+end
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 92fd4363134..60a02d85a1e 100644
--- a/spec/support/shared_examples/models/application_setting_shared_examples.rb
+++ b/spec/support/shared_examples/models/application_setting_shared_examples.rb
@@ -289,6 +289,7 @@ RSpec.shared_examples 'application settings examples' do
describe '#pick_repository_storage' do
before do
+ allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(%w(default backup))
allow(setting).to receive(:repository_storages_weighted).and_return({ 'default' => 20, 'backup' => 80 })
end
@@ -304,15 +305,19 @@ RSpec.shared_examples 'application settings examples' do
describe '#normalized_repository_storage_weights' do
using RSpec::Parameterized::TableSyntax
- where(:storages, :normalized) do
- { 'default' => 0, 'backup' => 100 } | { 'default' => 0.0, 'backup' => 1.0 }
- { 'default' => 100, 'backup' => 100 } | { 'default' => 0.5, 'backup' => 0.5 }
- { 'default' => 20, 'backup' => 80 } | { 'default' => 0.2, 'backup' => 0.8 }
- { 'default' => 0, 'backup' => 0 } | { 'default' => 0.0, 'backup' => 0.0 }
+ where(:config_storages, :storages, :normalized) do
+ %w(default backup) | { 'default' => 0, 'backup' => 100 } | { 'default' => 0.0, 'backup' => 1.0 }
+ %w(default backup) | { 'default' => 100, 'backup' => 100 } | { 'default' => 0.5, 'backup' => 0.5 }
+ %w(default backup) | { 'default' => 20, 'backup' => 80 } | { 'default' => 0.2, 'backup' => 0.8 }
+ %w(default backup) | { 'default' => 0, 'backup' => 0 } | { 'default' => 0.0, 'backup' => 0.0 }
+ %w(default) | { 'default' => 0, 'backup' => 100 } | { 'default' => 0.0 }
+ %w(default) | { 'default' => 100, 'backup' => 100 } | { 'default' => 1.0 }
+ %w(default) | { 'default' => 20, 'backup' => 80 } | { 'default' => 1.0 }
end
with_them do
before do
+ allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(config_storages)
allow(setting).to receive(:repository_storages_weighted).and_return(storages)
end
diff --git a/spec/support/shared_examples/models/boards/user_preferences_shared_examples.rb b/spec/support/shared_examples/models/boards/user_preferences_shared_examples.rb
new file mode 100644
index 00000000000..766aeac9476
--- /dev/null
+++ b/spec/support/shared_examples/models/boards/user_preferences_shared_examples.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'list_preferences_for user' do |list_factory, list_id_attribute|
+ subject { create(list_factory) } # rubocop:disable Rails/SaveBang
+
+ let_it_be(:user) { create(:user) }
+
+ describe '#preferences_for' do
+ context 'when user is nil' do
+ it 'returns not persisted preferences' do
+ preferences = subject.preferences_for(nil)
+
+ expect(preferences).not_to be_persisted
+ expect(preferences[list_id_attribute]).to eq(subject.id)
+ expect(preferences.user_id).to be_nil
+ end
+ end
+
+ context 'when a user preference already exists' do
+ before do
+ subject.update_preferences_for(user, collapsed: true)
+ end
+
+ it 'loads preference for user' do
+ preferences = subject.preferences_for(user)
+
+ expect(preferences).to be_persisted
+ expect(preferences.collapsed).to eq(true)
+ end
+ end
+
+ context 'when preferences for user does not exist' do
+ it 'returns not persisted preferences' do
+ preferences = subject.preferences_for(user)
+
+ expect(preferences).not_to be_persisted
+ expect(preferences.user_id).to eq(user.id)
+ expect(preferences.public_send(list_id_attribute)).to eq(subject.id)
+ end
+ end
+ end
+
+ describe '#update_preferences_for' do
+ context 'when user is present' do
+ context 'when there are no preferences for user' do
+ it 'creates new user preferences' do
+ expect { subject.update_preferences_for(user, collapsed: true) }.to change { subject.preferences.count }.by(1)
+ expect(subject.preferences_for(user).collapsed).to eq(true)
+ end
+ end
+
+ context 'when there are preferences for user' do
+ it 'updates user preferences' do
+ subject.update_preferences_for(user, collapsed: false)
+
+ expect { subject.update_preferences_for(user, collapsed: true) }.not_to change { subject.preferences.count }
+ expect(subject.preferences_for(user).collapsed).to eq(true)
+ end
+ end
+
+ context 'when user is nil' do
+ it 'does not create user preferences' do
+ expect { subject.update_preferences_for(nil, collapsed: true) }.not_to change { subject.preferences.count }
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/chat_service_shared_examples.rb b/spec/support/shared_examples/models/chat_service_shared_examples.rb
index ad237ad9f49..59e249bb865 100644
--- a/spec/support/shared_examples/models/chat_service_shared_examples.rb
+++ b/spec/support/shared_examples/models/chat_service_shared_examples.rb
@@ -53,9 +53,13 @@ RSpec.shared_examples "chat service" do |service_name|
end
it "calls #{service_name} API" do
- subject.execute(sample_data)
+ result = subject.execute(sample_data)
- expect(WebMock).to have_requested(:post, webhook_url).with { |req| req.body =~ /\A{"#{content_key}":.+}\Z/ }.once
+ expect(result).to be(true)
+ expect(WebMock).to have_requested(:post, webhook_url).once.with { |req|
+ json_body = Gitlab::Json.parse(req.body).with_indifferent_access
+ expect(json_body).to include(payload)
+ }
end
end
@@ -67,7 +71,8 @@ RSpec.shared_examples "chat service" do |service_name|
it "does not call #{service_name} API" do
result = subject.execute(sample_data)
- expect(result).to be_falsy
+ expect(result).to be(false)
+ expect(WebMock).not_to have_requested(:post, webhook_url)
end
end
diff --git a/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb b/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb
index f91e4bd8cf7..68142e667a4 100644
--- a/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb
@@ -18,7 +18,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type|
context 'with a project' do
it_behaves_like 'AtomicInternalId' do
let(:internal_id_attribute) { :iid }
- let(:instance) { build(timebox_type, *timebox_args, project: build(:project), group: nil) }
+ let(:instance) { build(timebox_type, *timebox_args, project: create(:project), group: nil) }
let(:scope) { :project }
let(:scope_attrs) { { project: instance.project } }
let(:usage) { timebox_table_name }
@@ -28,7 +28,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type|
context 'with a group' do
it_behaves_like 'AtomicInternalId' do
let(:internal_id_attribute) { :iid }
- let(:instance) { build(timebox_type, *timebox_args, project: nil, group: build(:group)) }
+ let(:instance) { build(timebox_type, *timebox_args, project: nil, group: create(:group)) }
let(:scope) { :group }
let(:scope_attrs) { { namespace: instance.group } }
let(:usage) { timebox_table_name }
diff --git a/spec/support/shared_examples/models/email_format_shared_examples.rb b/spec/support/shared_examples/models/email_format_shared_examples.rb
index a8115e440a4..77ded168637 100644
--- a/spec/support/shared_examples/models/email_format_shared_examples.rb
+++ b/spec/support/shared_examples/models/email_format_shared_examples.rb
@@ -6,7 +6,7 @@
# Note: You have access to `email_value` which is the email address value
# being currently tested).
-RSpec.shared_examples 'an object with email-formated attributes' do |*attributes|
+RSpec.shared_examples 'an object with email-formatted attributes' do |*attributes|
attributes.each do |attribute|
describe "specifically its :#{attribute} attribute" do
%w[
@@ -45,7 +45,7 @@ RSpec.shared_examples 'an object with email-formated attributes' do |*attributes
end
end
-RSpec.shared_examples 'an object with RFC3696 compliant email-formated attributes' do |*attributes|
+RSpec.shared_examples 'an object with RFC3696 compliant email-formatted attributes' do |*attributes|
attributes.each do |attribute|
describe "specifically its :#{attribute} attribute" do
%w[
diff --git a/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb b/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb
index a1867e1ce39..71a76121d38 100644
--- a/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb
+++ b/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb
@@ -7,7 +7,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
let(:webhook_url) { 'https://example.gitlab.com' }
def execute_with_options(options)
- receive(:new).with(webhook_url, options.merge(http_client: SlackService::Notifier::HTTPClient))
+ receive(:new).with(webhook_url, options.merge(http_client: SlackMattermost::Notifier::HTTPClient))
.and_return(double(:slack_service).as_null_object)
end
@@ -66,193 +66,180 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name|
end
describe "#execute" do
- let(:user) { create(:user) }
- let(:project) { create(:project, :repository, :wiki_repo) }
- let(:username) { 'slack_username' }
- let(:channel) { 'slack_channel' }
- let(:issue_service_options) { { title: 'Awesome issue', description: 'please fix' } }
+ let_it_be(:project) { create(:project, :repository, :wiki_repo) }
+ let_it_be(:user) { create(:user) }
- let(:data) do
- Gitlab::DataBuilder::Push.build_sample(project, user)
- end
+ let(:chat_service) { described_class.new( { project: project, webhook: webhook_url, branches_to_be_notified: 'all' }.merge(chat_service_params)) }
+ let(:chat_service_params) { {} }
+ let(:data) { Gitlab::DataBuilder::Push.build_sample(project, user) }
let!(:stubbed_resolved_hostname) do
stub_full_request(webhook_url, method: :post).request_pattern.uri_pattern.to_s
end
- before do
- allow(chat_service).to receive_messages(
- project: project,
- project_id: project.id,
- service_hook: true,
- webhook: webhook_url
- )
+ subject(:execute_service) { chat_service.execute(data) }
- issue_service = Issues::CreateService.new(project, user, issue_service_options)
- @issue = issue_service.execute
- @issues_sample_data = issue_service.hook_data(@issue, 'open')
-
- project.add_developer(user)
- opts = {
- title: 'Awesome merge_request',
- description: 'please fix',
- source_branch: 'feature',
- target_branch: 'master'
- }
- merge_service = MergeRequests::CreateService.new(project,
- user, opts)
- @merge_request = merge_service.execute
- @merge_sample_data = merge_service.hook_data(@merge_request,
- 'open')
-
- opts = {
- title: "Awesome wiki_page",
- content: "Some text describing some thing or another",
- format: "md",
- message: "user created page: Awesome wiki_page"
- }
-
- @wiki_page = create(:wiki_page, wiki: project.wiki, **opts)
- @wiki_page_sample_data = Gitlab::DataBuilder::WikiPage.build(@wiki_page, user, 'create')
- end
-
- it "calls #{service_name} API for push events" do
- chat_service.execute(data)
-
- expect(WebMock).to have_requested(:post, stubbed_resolved_hostname).once
- end
+ shared_examples 'calls the service API with the event message' do |event_message|
+ specify do
+ expect_next_instance_of(Slack::Messenger) do |messenger|
+ expect(messenger).to receive(:ping).with(event_message, anything).and_call_original
+ end
- it "calls #{service_name} API for issue events" do
- chat_service.execute(@issues_sample_data)
+ execute_service
- expect(WebMock).to have_requested(:post, stubbed_resolved_hostname).once
+ expect(WebMock).to have_requested(:post, stubbed_resolved_hostname).once
+ end
end
- it "calls #{service_name} API for merge requests events" do
- chat_service.execute(@merge_sample_data)
+ context 'with username for slack configured' do
+ let(:chat_service_params) { { username: 'slack_username' } }
+
+ it 'uses the username as an option' do
+ expect(Slack::Messenger).to execute_with_options(username: 'slack_username')
- expect(WebMock).to have_requested(:post, stubbed_resolved_hostname).once
+ execute_service
+ end
end
- it "calls #{service_name} API for wiki page events" do
- chat_service.execute(@wiki_page_sample_data)
+ context 'push events' do
+ let(:data) { Gitlab::DataBuilder::Push.build_sample(project, user) }
- expect(WebMock).to have_requested(:post, stubbed_resolved_hostname).once
- end
+ it_behaves_like 'calls the service API with the event message', /pushed to branch/
- it "calls #{service_name} API for deployment events" do
- deployment_event_data = { object_kind: 'deployment' }
+ context 'with event channel' do
+ let(:chat_service_params) { { push_channel: 'random' } }
- chat_service.execute(deployment_event_data)
+ it 'uses the right channel for push event' do
+ expect(Slack::Messenger).to execute_with_options(channel: ['random'])
- expect(WebMock).to have_requested(:post, stubbed_resolved_hostname).once
+ execute_service
+ end
+ end
end
- it 'uses the username as an option for slack when configured' do
- allow(chat_service).to receive(:username).and_return(username)
-
- expect(Slack::Messenger).to execute_with_options(username: username)
+ context 'tag_push events' do
+ let(:oldrev) { Gitlab::Git::BLANK_SHA }
+ let(:newrev) { '8a2a6eb295bb170b34c24c76c49ed0e9b2eaf34b' } # gitlab-test: git rev-parse refs/tags/v1.1.0
+ let(:ref) { 'refs/tags/v1.1.0' }
+ let(:data) { Git::TagHooksService.new(project, user, change: { oldrev: oldrev, newrev: newrev, ref: ref }).send(:push_data) }
- chat_service.execute(data)
+ it_behaves_like 'calls the service API with the event message', /pushed new tag/
end
- it 'uses the channel as an option when it is configured' do
- allow(chat_service).to receive(:channel).and_return(channel)
- expect(Slack::Messenger).to execute_with_options(channel: [channel])
- chat_service.execute(data)
- end
+ context 'issue events' do
+ let_it_be(:issue) { create(:issue) }
+ let(:data) { issue.to_hook_data(user) }
- context "event channels" do
- it "uses the right channel for push event" do
- chat_service.update!(push_channel: "random")
+ it_behaves_like 'calls the service API with the event message', /Issue (.*?) opened by/
- expect(Slack::Messenger).to execute_with_options(channel: ['random'])
+ context 'whith event channel' do
+ let(:chat_service_params) { { issue_channel: 'random' } }
- chat_service.execute(data)
- end
+ it 'uses the right channel for issue event' do
+ expect(Slack::Messenger).to execute_with_options(channel: ['random'])
- it "uses the right channel for merge request event" do
- chat_service.update!(merge_request_channel: "random")
+ execute_service
+ end
- expect(Slack::Messenger).to execute_with_options(channel: ['random'])
+ context 'for confidential issues' do
+ before_all do
+ issue.update!(confidential: true)
+ end
- chat_service.execute(@merge_sample_data)
- end
+ it 'falls back to issue channel' do
+ expect(Slack::Messenger).to execute_with_options(channel: ['random'])
+
+ execute_service
+ end
- it "uses the right channel for issue event" do
- chat_service.update!(issue_channel: "random")
+ context 'and confidential_issue_channel is defined' do
+ let(:chat_service_params) { { issue_channel: 'random', confidential_issue_channel: 'confidential' } }
- expect(Slack::Messenger).to execute_with_options(channel: ['random'])
+ it 'uses the confidential issue channel when it is defined' do
+ expect(Slack::Messenger).to execute_with_options(channel: ['confidential'])
- chat_service.execute(@issues_sample_data)
+ execute_service
+ end
+ end
+ end
end
+ end
+
+ context 'merge request events' do
+ let_it_be(:merge_request) { create(:merge_request) }
+ let(:data) { merge_request.to_hook_data(user) }
- context 'for confidential issues' do
- let(:issue_service_options) { { title: 'Secret', confidential: true } }
+ it_behaves_like 'calls the service API with the event message', /opened merge request/
- it "uses confidential issue channel" do
- chat_service.update!(confidential_issue_channel: 'confidential')
+ context 'with event channel' do
+ let(:chat_service_params) { { merge_request_channel: 'random' } }
- expect(Slack::Messenger).to execute_with_options(channel: ['confidential'])
+ it 'uses the right channel for merge request event' do
+ expect(Slack::Messenger).to execute_with_options(channel: ['random'])
- chat_service.execute(@issues_sample_data)
+ execute_service
end
+ end
+ end
+
+ context 'wiki page events' do
+ let_it_be(:wiki_page) { create(:wiki_page, wiki: project.wiki, message: 'user created page: Awesome wiki_page') }
+ let(:data) { Gitlab::DataBuilder::WikiPage.build(wiki_page, user, 'create') }
- it 'falls back to issue channel' do
- chat_service.update!(issue_channel: 'fallback_channel')
+ it_behaves_like 'calls the service API with the event message', / created (.*?)wikis\/(.*?)|wiki page> in/
- expect(Slack::Messenger).to execute_with_options(channel: ['fallback_channel'])
+ context 'with event channel' do
+ let(:chat_service_params) { { wiki_page_channel: 'random' } }
- chat_service.execute(@issues_sample_data)
+ it 'uses the right channel for wiki event' do
+ expect(Slack::Messenger).to execute_with_options(channel: ['random'])
+
+ execute_service
end
end
+ end
- it "uses the right channel for wiki event" do
- chat_service.update!(wiki_page_channel: "random")
-
- expect(Slack::Messenger).to execute_with_options(channel: ['random'])
+ context 'deployment events' do
+ let_it_be(:deployment) { create(:deployment) }
+ let(:data) { Gitlab::DataBuilder::Deployment.build(deployment) }
- chat_service.execute(@wiki_page_sample_data)
- end
+ it_behaves_like 'calls the service API with the event message', /Deploy to (.*?) created/
+ end
- context "note event" do
- let(:issue_note) do
- create(:note_on_issue, project: project, note: "issue note")
- end
+ context 'note event' do
+ let_it_be(:issue_note) { create(:note_on_issue, project: project, note: "issue note") }
+ let(:data) { Gitlab::DataBuilder::Note.build(issue_note, user) }
- it "uses the right channel" do
- chat_service.update!(note_channel: "random")
+ it_behaves_like 'calls the service API with the event message', /commented on issue/
- note_data = Gitlab::DataBuilder::Note.build(issue_note, user)
+ context 'with event channel' do
+ let(:chat_service_params) { { note_channel: 'random' } }
+ it 'uses the right channel' do
expect(Slack::Messenger).to execute_with_options(channel: ['random'])
- chat_service.execute(note_data)
+ execute_service
end
context 'for confidential notes' do
- before do
- issue_note.noteable.update!(confidential: true)
+ before_all do
+ issue_note.update!(confidential: true)
end
- it "uses confidential channel" do
- chat_service.update!(confidential_note_channel: "confidential")
-
- note_data = Gitlab::DataBuilder::Note.build(issue_note, user)
-
- expect(Slack::Messenger).to execute_with_options(channel: ['confidential'])
+ it 'falls back to note channel' do
+ expect(Slack::Messenger).to execute_with_options(channel: ['random'])
- chat_service.execute(note_data)
+ execute_service
end
- it 'falls back to note channel' do
- chat_service.update!(note_channel: "fallback_channel")
-
- note_data = Gitlab::DataBuilder::Note.build(issue_note, user)
+ context 'and confidential_note_channel is defined' do
+ let(:chat_service_params) { { note_channel: 'random', confidential_note_channel: 'confidential' } }
- expect(Slack::Messenger).to execute_with_options(channel: ['fallback_channel'])
+ it 'uses confidential channel' do
+ expect(Slack::Messenger).to execute_with_options(channel: ['confidential'])
- chat_service.execute(note_data)
+ execute_service
+ end
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 89d30688b5c..abc6e3ecce8 100644
--- a/spec/support/shared_examples/models/wiki_shared_examples.rb
+++ b/spec/support/shared_examples/models/wiki_shared_examples.rb
@@ -354,27 +354,47 @@ RSpec.shared_examples 'wiki model' do
subject.repository.create_file(user, 'image.png', image, branch_name: subject.default_branch, message: 'add image')
end
- it 'returns the latest version of the file if it exists' do
- file = subject.find_file('image.png')
+ shared_examples 'find_file results' do
+ it 'returns the latest version of the file if it exists' do
+ file = subject.find_file('image.png')
- expect(file.mime_type).to eq('image/png')
- end
+ expect(file.mime_type).to eq('image/png')
+ end
+
+ it 'returns nil if the page does not exist' do
+ expect(subject.find_file('non-existent')).to eq(nil)
+ end
+
+ it 'returns a Gitlab::Git::WikiFile instance' do
+ file = subject.find_file('image.png')
+
+ expect(file).to be_a Gitlab::Git::WikiFile
+ end
- it 'returns nil if the page does not exist' do
- expect(subject.find_file('non-existent')).to eq(nil)
+ it 'returns the whole file' do
+ file = subject.find_file('image.png')
+ image.rewind
+
+ expect(file.raw_data.b).to eq(image.read.b)
+ end
end
- it 'returns a Gitlab::Git::WikiFile instance' do
- file = subject.find_file('image.png')
+ it_behaves_like 'find_file results'
+
+ context 'when load_content is disabled' do
+ it 'includes the file data in the Gitlab::Git::WikiFile' do
+ file = subject.find_file('image.png', load_content: false)
- expect(file).to be_a Gitlab::Git::WikiFile
+ expect(file.raw_data).to be_empty
+ end
end
- it 'returns the whole file' do
- file = subject.find_file('image.png')
- image.rewind
+ context 'when feature flag :gitaly_find_file is disabled' do
+ before do
+ stub_feature_flags(gitaly_find_file: false)
+ end
- expect(file.raw_data.b).to eq(image.read.b)
+ it_behaves_like 'find_file results'
end
end
diff --git a/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb b/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb
index 17fd2b836d3..92849ddf1cb 100644
--- a/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb
+++ b/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb
@@ -93,6 +93,6 @@ end
def submit_time(quick_action)
fill_in 'note[note]', with: quick_action
- find('.js-comment-submit-button').click
+ find('[data-testid="comment-button"]').click
wait_for_requests
end
diff --git a/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb
index 49b6fc13900..54ea876bed2 100644
--- a/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb
@@ -1,63 +1,13 @@
# frozen_string_literal: true
RSpec.shared_examples 'conan ping endpoint' do
- it 'responds with 401 Unauthorized when no token provided' do
+ it 'responds with 200 OK when no token provided' do
get api(url)
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
-
- it 'responds with 200 OK when valid token is provided' do
- jwt = build_jwt(personal_access_token)
- get api(url), headers: build_token_auth_header(jwt.encoded)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.headers['X-Conan-Server-Capabilities']).to eq("")
- end
-
- it 'responds with 200 OK when valid job token is provided' do
- jwt = build_jwt_from_job(job)
- get api(url), headers: build_token_auth_header(jwt.encoded)
-
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers['X-Conan-Server-Capabilities']).to eq("")
end
- it 'responds with 200 OK when valid deploy token is provided' do
- jwt = build_jwt_from_deploy_token(deploy_token)
- get api(url), headers: build_token_auth_header(jwt.encoded)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.headers['X-Conan-Server-Capabilities']).to eq("")
- end
-
- it 'responds with 401 Unauthorized when invalid access token ID is provided' do
- jwt = build_jwt(double(id: 12345), user_id: personal_access_token.user_id)
- get api(url), headers: build_token_auth_header(jwt.encoded)
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
-
- it 'responds with 401 Unauthorized when invalid user is provided' do
- jwt = build_jwt(personal_access_token, user_id: 12345)
- get api(url), headers: build_token_auth_header(jwt.encoded)
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
-
- it 'responds with 401 Unauthorized when the provided JWT is signed with different secret' do
- jwt = build_jwt(personal_access_token, secret: SecureRandom.base64(32))
- get api(url), headers: build_token_auth_header(jwt.encoded)
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
-
- it 'responds with 401 Unauthorized when invalid JWT is provided' do
- get api(url), headers: build_token_auth_header('invalid-jwt')
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
-
context 'packages feature disabled' do
it 'responds with 404 Not Found' do
stub_packages_setting(enabled: false)
@@ -72,7 +22,10 @@ RSpec.shared_examples 'conan search endpoint' do
before do
project.update_column(:visibility_level, Gitlab::VisibilityLevel::PUBLIC)
- get api(url), headers: headers, params: params
+ # Do not pass the HTTP_AUTHORIZATION header,
+ # in order to test that this public project's packages
+ # are visible to anonymous search.
+ get api(url), params: params
end
subject { json_response['results'] }
@@ -109,6 +62,33 @@ RSpec.shared_examples 'conan authenticate endpoint' do
end
end
+ it 'responds with 401 Unauthorized when an invalid access token ID is provided' do
+ jwt = build_jwt(double(id: 12345), user_id: personal_access_token.user_id)
+ get api(url), headers: build_token_auth_header(jwt.encoded)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ it 'responds with 401 Unauthorized when invalid user is provided' do
+ jwt = build_jwt(personal_access_token, user_id: 12345)
+ get api(url), headers: build_token_auth_header(jwt.encoded)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ it 'responds with 401 Unauthorized when the provided JWT is signed with different secret' do
+ jwt = build_jwt(personal_access_token, secret: SecureRandom.base64(32))
+ get api(url), headers: build_token_auth_header(jwt.encoded)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ it 'responds with 401 UnauthorizedOK when invalid JWT is provided' do
+ get api(url), headers: build_token_auth_header('invalid-jwt')
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
context 'when valid JWT access token is provided' do
it 'responds with 200' do
subject
@@ -507,19 +487,37 @@ RSpec.shared_examples 'delete package endpoint' do
end
end
+RSpec.shared_examples 'allows download with no token' do
+ context 'with no private token' do
+ let(:headers) { {} }
+
+ it 'returns 200' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+end
+
RSpec.shared_examples 'denies download with no token' do
context 'with no private token' do
let(:headers) { {} }
- it 'returns 400' do
+ it 'returns 404' do
subject
- expect(response).to have_gitlab_http_status(:unauthorized)
+ expect(response).to have_gitlab_http_status(:not_found)
end
end
end
RSpec.shared_examples 'a public project with packages' do
+ before do
+ project.update_column(:visibility_level, Gitlab::VisibilityLevel::PUBLIC)
+ end
+
+ it_behaves_like 'allows download with no token'
+
it 'returns the file' do
subject
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 54f4ba7ff73..274516cd87b 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
@@ -25,7 +25,7 @@ RSpec.shared_examples 'group and project boards query' do
board = create(:board, resource_parent: board_parent, name: 'A')
allow(Ability).to receive(:allowed?).and_call_original
- allow(Ability).to receive(:allowed?).with(user, :read_board, board).and_return(false)
+ allow(Ability).to receive(:allowed?).with(user, :read_issue_board, board).and_return(false)
post_graphql(query, current_user: current_user)
diff --git a/spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb
new file mode 100644
index 00000000000..66fbfa798b0
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'group and project packages query' do
+ include GraphqlHelpers
+
+ context 'when user has access to the resource' do
+ before do
+ resource.add_reporter(current_user)
+ post_graphql(query, current_user: current_user)
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns packages successfully' do
+ expect(package_names).to contain_exactly(
+ package.name,
+ maven_package.name,
+ debian_package.name,
+ composer_package.name
+ )
+ end
+
+ it 'deals with metadata' do
+ expect(target_shas).to contain_exactly(composer_metadatum.target_sha)
+ end
+ end
+
+ context 'when the user does not have access to the resource' do
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns nil' do
+ expect(packages).to be_nil
+ end
+ end
+
+ context 'when the user is not authenticated' do
+ before do
+ post_graphql(query)
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns nil' do
+ expect(packages).to be_nil
+ end
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/logging_application_context_shared_examples.rb b/spec/support/shared_examples/requests/api/logging_application_context_shared_examples.rb
index 038ede884c8..4a71b696d57 100644
--- a/spec/support/shared_examples/requests/api/logging_application_context_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/logging_application_context_shared_examples.rb
@@ -22,3 +22,19 @@ RSpec.shared_examples 'storing arguments in the application context' do
hash.transform_keys! { |key| "meta.#{key}" }
end
end
+
+RSpec.shared_examples 'not executing any extra queries for the application context' do |expected_extra_queries = 0|
+ it 'does not execute more queries than without adding anything to the application context' do
+ # Call the subject once to memoize all factories being used for the spec, so they won't
+ # add any queries to the expectation.
+ subject_proc.call
+
+ expect do
+ allow(Gitlab::ApplicationContext).to receive(:push).and_call_original
+ subject_proc.call
+ end.to issue_same_number_of_queries_as {
+ allow(Gitlab::ApplicationContext).to receive(:push)
+ subject_proc.call
+ }.with_threshold(expected_extra_queries).ignoring_cached_queries
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb
index be051dcbb7b..c15c59e1a1d 100644
--- a/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb
@@ -45,136 +45,234 @@ RSpec.shared_examples 'handling get metadata requests' do |scope: :project|
end
end
- where(:auth, :package_name_type, :request_forward, :visibility, :user_role, :expected_result, :expected_status) do
- nil | :scoped_naming_convention | true | 'PUBLIC' | nil | :accept | :ok
- nil | :scoped_naming_convention | false | 'PUBLIC' | nil | :accept | :ok
- nil | :non_existing | true | 'PUBLIC' | nil | :redirect | :redirected
- nil | :non_existing | false | 'PUBLIC' | nil | :reject | :not_found
- nil | :scoped_naming_convention | true | 'PRIVATE' | nil | :reject | :not_found
- nil | :scoped_naming_convention | false | 'PRIVATE' | nil | :reject | :not_found
- nil | :non_existing | true | 'PRIVATE' | nil | :redirect | :redirected
- nil | :non_existing | false | 'PRIVATE' | nil | :reject | :not_found
- nil | :scoped_naming_convention | true | 'INTERNAL' | nil | :reject | :not_found
- nil | :scoped_naming_convention | false | 'INTERNAL' | nil | :reject | :not_found
- nil | :non_existing | true | 'INTERNAL' | nil | :redirect | :redirected
- nil | :non_existing | false | 'INTERNAL' | nil | :reject | :not_found
-
- :oauth | :scoped_naming_convention | true | 'PUBLIC' | :guest | :accept | :ok
- :oauth | :scoped_naming_convention | true | 'PUBLIC' | :reporter | :accept | :ok
- :oauth | :scoped_naming_convention | false | 'PUBLIC' | :guest | :accept | :ok
- :oauth | :scoped_naming_convention | false | 'PUBLIC' | :reporter | :accept | :ok
- :oauth | :non_existing | true | 'PUBLIC' | :guest | :redirect | :redirected
- :oauth | :non_existing | true | 'PUBLIC' | :reporter | :redirect | :redirected
- :oauth | :non_existing | false | 'PUBLIC' | :guest | :reject | :not_found
- :oauth | :non_existing | false | 'PUBLIC' | :reporter | :reject | :not_found
- :oauth | :scoped_naming_convention | true | 'PRIVATE' | :guest | :reject | :forbidden
- :oauth | :scoped_naming_convention | true | 'PRIVATE' | :reporter | :accept | :ok
- :oauth | :scoped_naming_convention | false | 'PRIVATE' | :guest | :reject | :forbidden
- :oauth | :scoped_naming_convention | false | 'PRIVATE' | :reporter | :accept | :ok
- :oauth | :non_existing | true | 'PRIVATE' | :guest | :redirect | :redirected
- :oauth | :non_existing | true | 'PRIVATE' | :reporter | :redirect | :redirected
- :oauth | :non_existing | false | 'PRIVATE' | :guest | :reject | :forbidden
- :oauth | :non_existing | false | 'PRIVATE' | :reporter | :reject | :not_found
- :oauth | :scoped_naming_convention | true | 'INTERNAL' | :guest | :accept | :ok
- :oauth | :scoped_naming_convention | true | 'INTERNAL' | :reporter | :accept | :ok
- :oauth | :scoped_naming_convention | false | 'INTERNAL' | :guest | :accept | :ok
- :oauth | :scoped_naming_convention | false | 'INTERNAL' | :reporter | :accept | :ok
- :oauth | :non_existing | true | 'INTERNAL' | :guest | :redirect | :redirected
- :oauth | :non_existing | true | 'INTERNAL' | :reporter | :redirect | :redirected
- :oauth | :non_existing | false | 'INTERNAL' | :guest | :reject | :not_found
- :oauth | :non_existing | false | 'INTERNAL' | :reporter | :reject | :not_found
-
- :personal_access_token | :scoped_naming_convention | true | 'PUBLIC' | :guest | :accept | :ok
- :personal_access_token | :scoped_naming_convention | true | 'PUBLIC' | :reporter | :accept | :ok
- :personal_access_token | :scoped_naming_convention | false | 'PUBLIC' | :guest | :accept | :ok
- :personal_access_token | :scoped_naming_convention | false | 'PUBLIC' | :reporter | :accept | :ok
- :personal_access_token | :non_existing | true | 'PUBLIC' | :guest | :redirect | :redirected
- :personal_access_token | :non_existing | true | 'PUBLIC' | :reporter | :redirect | :redirected
- :personal_access_token | :non_existing | false | 'PUBLIC' | :guest | :reject | :not_found
- :personal_access_token | :non_existing | false | 'PUBLIC' | :reporter | :reject | :not_found
- :personal_access_token | :scoped_naming_convention | true | 'PRIVATE' | :guest | :reject | :forbidden
- :personal_access_token | :scoped_naming_convention | true | 'PRIVATE' | :reporter | :accept | :ok
- :personal_access_token | :scoped_naming_convention | false | 'PRIVATE' | :guest | :reject | :forbidden
- :personal_access_token | :scoped_naming_convention | false | 'PRIVATE' | :reporter | :accept | :ok
- :personal_access_token | :non_existing | true | 'PRIVATE' | :guest | :redirect | :redirected
- :personal_access_token | :non_existing | true | 'PRIVATE' | :reporter | :redirect | :redirected
- :personal_access_token | :non_existing | false | 'PRIVATE' | :guest | :reject | :forbidden
- :personal_access_token | :non_existing | false | 'PRIVATE' | :reporter | :reject | :not_found
- :personal_access_token | :scoped_naming_convention | true | 'INTERNAL' | :guest | :accept | :ok
- :personal_access_token | :scoped_naming_convention | true | 'INTERNAL' | :reporter | :accept | :ok
- :personal_access_token | :scoped_naming_convention | false | 'INTERNAL' | :guest | :accept | :ok
- :personal_access_token | :scoped_naming_convention | false | 'INTERNAL' | :reporter | :accept | :ok
- :personal_access_token | :non_existing | true | 'INTERNAL' | :guest | :redirect | :redirected
- :personal_access_token | :non_existing | true | 'INTERNAL' | :reporter | :redirect | :redirected
- :personal_access_token | :non_existing | false | 'INTERNAL' | :guest | :reject | :not_found
- :personal_access_token | :non_existing | false | 'INTERNAL' | :reporter | :reject | :not_found
-
- :job_token | :scoped_naming_convention | true | 'PUBLIC' | :developer | :accept | :ok
- :job_token | :scoped_naming_convention | false | 'PUBLIC' | :developer | :accept | :ok
- :job_token | :non_existing | true | 'PUBLIC' | :developer | :redirect | :redirected
- :job_token | :non_existing | false | 'PUBLIC' | :developer | :reject | :not_found
- :job_token | :scoped_naming_convention | true | 'PRIVATE' | :developer | :accept | :ok
- :job_token | :scoped_naming_convention | false | 'PRIVATE' | :developer | :accept | :ok
- :job_token | :non_existing | true | 'PRIVATE' | :developer | :redirect | :redirected
- :job_token | :non_existing | false | 'PRIVATE' | :developer | :reject | :not_found
- :job_token | :scoped_naming_convention | true | 'INTERNAL' | :developer | :accept | :ok
- :job_token | :scoped_naming_convention | false | 'INTERNAL' | :developer | :accept | :ok
- :job_token | :non_existing | true | 'INTERNAL' | :developer | :redirect | :redirected
- :job_token | :non_existing | false | 'INTERNAL' | :developer | :reject | :not_found
-
- :deploy_token | :scoped_naming_convention | true | 'PUBLIC' | nil | :accept | :ok
- :deploy_token | :scoped_naming_convention | false | 'PUBLIC' | nil | :accept | :ok
- :deploy_token | :non_existing | true | 'PUBLIC' | nil | :redirect | :redirected
- :deploy_token | :non_existing | false | 'PUBLIC' | nil | :reject | :not_found
- :deploy_token | :scoped_naming_convention | true | 'PRIVATE' | nil | :accept | :ok
- :deploy_token | :scoped_naming_convention | false | 'PRIVATE' | nil | :accept | :ok
- :deploy_token | :non_existing | true | 'PRIVATE' | nil | :redirect | :redirected
- :deploy_token | :non_existing | false | 'PRIVATE' | nil | :reject | :not_found
- :deploy_token | :scoped_naming_convention | true | 'INTERNAL' | nil | :accept | :ok
- :deploy_token | :scoped_naming_convention | false | 'INTERNAL' | nil | :accept | :ok
- :deploy_token | :non_existing | true | 'INTERNAL' | nil | :redirect | :redirected
- :deploy_token | :non_existing | false | 'INTERNAL' | nil | :reject | :not_found
- end
+ shared_examples 'handling all conditions' do
+ where(:auth, :package_name_type, :request_forward, :visibility, :user_role, :expected_result, :expected_status) do
+ nil | :scoped_naming_convention | true | :public | nil | :accept | :ok
+ nil | :scoped_naming_convention | false | :public | nil | :accept | :ok
+ nil | :scoped_no_naming_convention | true | :public | nil | :accept | :ok
+ nil | :scoped_no_naming_convention | false | :public | nil | :accept | :ok
+ nil | :unscoped | true | :public | nil | :accept | :ok
+ nil | :unscoped | false | :public | nil | :accept | :ok
+ nil | :non_existing | true | :public | nil | :redirect | :redirected
+ nil | :non_existing | false | :public | nil | :reject | :not_found
+ nil | :scoped_naming_convention | true | :private | nil | :reject | :not_found
+ nil | :scoped_naming_convention | false | :private | nil | :reject | :not_found
+ nil | :scoped_no_naming_convention | true | :private | nil | :reject | :not_found
+ nil | :scoped_no_naming_convention | false | :private | nil | :reject | :not_found
+ nil | :unscoped | true | :private | nil | :reject | :not_found
+ nil | :unscoped | false | :private | nil | :reject | :not_found
+ nil | :non_existing | true | :private | nil | :redirect | :redirected
+ nil | :non_existing | false | :private | nil | :reject | :not_found
+ nil | :scoped_naming_convention | true | :internal | nil | :reject | :not_found
+ nil | :scoped_naming_convention | false | :internal | nil | :reject | :not_found
+ nil | :scoped_no_naming_convention | true | :internal | nil | :reject | :not_found
+ nil | :scoped_no_naming_convention | false | :internal | nil | :reject | :not_found
+ nil | :unscoped | true | :internal | nil | :reject | :not_found
+ nil | :unscoped | false | :internal | nil | :reject | :not_found
+ nil | :non_existing | true | :internal | nil | :redirect | :redirected
+ nil | :non_existing | false | :internal | nil | :reject | :not_found
+
+ :oauth | :scoped_naming_convention | true | :public | :guest | :accept | :ok
+ :oauth | :scoped_naming_convention | true | :public | :reporter | :accept | :ok
+ :oauth | :scoped_naming_convention | false | :public | :guest | :accept | :ok
+ :oauth | :scoped_naming_convention | false | :public | :reporter | :accept | :ok
+ :oauth | :scoped_no_naming_convention | true | :public | :guest | :accept | :ok
+ :oauth | :scoped_no_naming_convention | true | :public | :reporter | :accept | :ok
+ :oauth | :scoped_no_naming_convention | false | :public | :guest | :accept | :ok
+ :oauth | :scoped_no_naming_convention | false | :public | :reporter | :accept | :ok
+ :oauth | :unscoped | true | :public | :guest | :accept | :ok
+ :oauth | :unscoped | true | :public | :reporter | :accept | :ok
+ :oauth | :unscoped | false | :public | :guest | :accept | :ok
+ :oauth | :unscoped | false | :public | :reporter | :accept | :ok
+ :oauth | :non_existing | true | :public | :guest | :redirect | :redirected
+ :oauth | :non_existing | true | :public | :reporter | :redirect | :redirected
+ :oauth | :non_existing | false | :public | :guest | :reject | :not_found
+ :oauth | :non_existing | false | :public | :reporter | :reject | :not_found
+ :oauth | :scoped_naming_convention | true | :private | :guest | :reject | :forbidden
+ :oauth | :scoped_naming_convention | true | :private | :reporter | :accept | :ok
+ :oauth | :scoped_naming_convention | false | :private | :guest | :reject | :forbidden
+ :oauth | :scoped_naming_convention | false | :private | :reporter | :accept | :ok
+ :oauth | :scoped_no_naming_convention | true | :private | :guest | :reject | :forbidden
+ :oauth | :scoped_no_naming_convention | true | :private | :reporter | :accept | :ok
+ :oauth | :scoped_no_naming_convention | false | :private | :guest | :reject | :forbidden
+ :oauth | :scoped_no_naming_convention | false | :private | :reporter | :accept | :ok
+ :oauth | :unscoped | true | :private | :guest | :reject | :forbidden
+ :oauth | :unscoped | true | :private | :reporter | :accept | :ok
+ :oauth | :unscoped | false | :private | :guest | :reject | :forbidden
+ :oauth | :unscoped | false | :private | :reporter | :accept | :ok
+ :oauth | :non_existing | true | :private | :guest | :redirect | :redirected
+ :oauth | :non_existing | true | :private | :reporter | :redirect | :redirected
+ :oauth | :non_existing | false | :private | :guest | :reject | :forbidden
+ :oauth | :non_existing | false | :private | :reporter | :reject | :not_found
+ :oauth | :scoped_naming_convention | true | :internal | :guest | :accept | :ok
+ :oauth | :scoped_naming_convention | true | :internal | :reporter | :accept | :ok
+ :oauth | :scoped_naming_convention | false | :internal | :guest | :accept | :ok
+ :oauth | :scoped_naming_convention | false | :internal | :reporter | :accept | :ok
+ :oauth | :scoped_no_naming_convention | true | :internal | :guest | :accept | :ok
+ :oauth | :scoped_no_naming_convention | true | :internal | :reporter | :accept | :ok
+ :oauth | :scoped_no_naming_convention | false | :internal | :guest | :accept | :ok
+ :oauth | :scoped_no_naming_convention | false | :internal | :reporter | :accept | :ok
+ :oauth | :unscoped | true | :internal | :guest | :accept | :ok
+ :oauth | :unscoped | true | :internal | :reporter | :accept | :ok
+ :oauth | :unscoped | false | :internal | :guest | :accept | :ok
+ :oauth | :unscoped | false | :internal | :reporter | :accept | :ok
+ :oauth | :non_existing | true | :internal | :guest | :redirect | :redirected
+ :oauth | :non_existing | true | :internal | :reporter | :redirect | :redirected
+ :oauth | :non_existing | false | :internal | :guest | :reject | :not_found
+ :oauth | :non_existing | false | :internal | :reporter | :reject | :not_found
+
+ :personal_access_token | :scoped_naming_convention | true | :public | :guest | :accept | :ok
+ :personal_access_token | :scoped_naming_convention | true | :public | :reporter | :accept | :ok
+ :personal_access_token | :scoped_naming_convention | false | :public | :guest | :accept | :ok
+ :personal_access_token | :scoped_naming_convention | false | :public | :reporter | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | true | :public | :guest | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | true | :public | :reporter | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | false | :public | :guest | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | false | :public | :reporter | :accept | :ok
+ :personal_access_token | :unscoped | true | :public | :guest | :accept | :ok
+ :personal_access_token | :unscoped | true | :public | :reporter | :accept | :ok
+ :personal_access_token | :unscoped | false | :public | :guest | :accept | :ok
+ :personal_access_token | :unscoped | false | :public | :reporter | :accept | :ok
+ :personal_access_token | :non_existing | true | :public | :guest | :redirect | :redirected
+ :personal_access_token | :non_existing | true | :public | :reporter | :redirect | :redirected
+ :personal_access_token | :non_existing | false | :public | :guest | :reject | :not_found
+ :personal_access_token | :non_existing | false | :public | :reporter | :reject | :not_found
+ :personal_access_token | :scoped_naming_convention | true | :private | :guest | :reject | :forbidden
+ :personal_access_token | :scoped_naming_convention | true | :private | :reporter | :accept | :ok
+ :personal_access_token | :scoped_naming_convention | false | :private | :guest | :reject | :forbidden
+ :personal_access_token | :scoped_naming_convention | false | :private | :reporter | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | true | :private | :guest | :reject | :forbidden
+ :personal_access_token | :scoped_no_naming_convention | true | :private | :reporter | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | false | :private | :guest | :reject | :forbidden
+ :personal_access_token | :scoped_no_naming_convention | false | :private | :reporter | :accept | :ok
+ :personal_access_token | :unscoped | true | :private | :guest | :reject | :forbidden
+ :personal_access_token | :unscoped | true | :private | :reporter | :accept | :ok
+ :personal_access_token | :unscoped | false | :private | :guest | :reject | :forbidden
+ :personal_access_token | :unscoped | false | :private | :reporter | :accept | :ok
+ :personal_access_token | :non_existing | true | :private | :guest | :redirect | :redirected
+ :personal_access_token | :non_existing | true | :private | :reporter | :redirect | :redirected
+ :personal_access_token | :non_existing | false | :private | :guest | :reject | :forbidden
+ :personal_access_token | :non_existing | false | :private | :reporter | :reject | :not_found
+ :personal_access_token | :scoped_naming_convention | true | :internal | :guest | :accept | :ok
+ :personal_access_token | :scoped_naming_convention | true | :internal | :reporter | :accept | :ok
+ :personal_access_token | :scoped_naming_convention | false | :internal | :guest | :accept | :ok
+ :personal_access_token | :scoped_naming_convention | false | :internal | :reporter | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | true | :internal | :guest | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | true | :internal | :reporter | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | false | :internal | :guest | :accept | :ok
+ :personal_access_token | :scoped_no_naming_convention | false | :internal | :reporter | :accept | :ok
+ :personal_access_token | :unscoped | true | :internal | :guest | :accept | :ok
+ :personal_access_token | :unscoped | true | :internal | :reporter | :accept | :ok
+ :personal_access_token | :unscoped | false | :internal | :guest | :accept | :ok
+ :personal_access_token | :unscoped | false | :internal | :reporter | :accept | :ok
+ :personal_access_token | :non_existing | true | :internal | :guest | :redirect | :redirected
+ :personal_access_token | :non_existing | true | :internal | :reporter | :redirect | :redirected
+ :personal_access_token | :non_existing | false | :internal | :guest | :reject | :not_found
+ :personal_access_token | :non_existing | false | :internal | :reporter | :reject | :not_found
+
+ :job_token | :scoped_naming_convention | true | :public | :developer | :accept | :ok
+ :job_token | :scoped_naming_convention | false | :public | :developer | :accept | :ok
+ :job_token | :scoped_no_naming_convention | true | :public | :developer | :accept | :ok
+ :job_token | :scoped_no_naming_convention | false | :public | :developer | :accept | :ok
+ :job_token | :unscoped | true | :public | :developer | :accept | :ok
+ :job_token | :unscoped | false | :public | :developer | :accept | :ok
+ :job_token | :non_existing | true | :public | :developer | :redirect | :redirected
+ :job_token | :non_existing | false | :public | :developer | :reject | :not_found
+ :job_token | :scoped_naming_convention | true | :private | :developer | :accept | :ok
+ :job_token | :scoped_naming_convention | false | :private | :developer | :accept | :ok
+ :job_token | :scoped_no_naming_convention | true | :private | :developer | :accept | :ok
+ :job_token | :scoped_no_naming_convention | false | :private | :developer | :accept | :ok
+ :job_token | :unscoped | true | :private | :developer | :accept | :ok
+ :job_token | :unscoped | false | :private | :developer | :accept | :ok
+ :job_token | :non_existing | true | :private | :developer | :redirect | :redirected
+ :job_token | :non_existing | false | :private | :developer | :reject | :not_found
+ :job_token | :scoped_naming_convention | true | :internal | :developer | :accept | :ok
+ :job_token | :scoped_naming_convention | false | :internal | :developer | :accept | :ok
+ :job_token | :scoped_no_naming_convention | true | :internal | :developer | :accept | :ok
+ :job_token | :scoped_no_naming_convention | false | :internal | :developer | :accept | :ok
+ :job_token | :unscoped | true | :internal | :developer | :accept | :ok
+ :job_token | :unscoped | false | :internal | :developer | :accept | :ok
+ :job_token | :non_existing | true | :internal | :developer | :redirect | :redirected
+ :job_token | :non_existing | false | :internal | :developer | :reject | :not_found
+
+ :deploy_token | :scoped_naming_convention | true | :public | nil | :accept | :ok
+ :deploy_token | :scoped_naming_convention | false | :public | nil | :accept | :ok
+ :deploy_token | :scoped_no_naming_convention | true | :public | nil | :accept | :ok
+ :deploy_token | :scoped_no_naming_convention | false | :public | nil | :accept | :ok
+ :deploy_token | :unscoped | true | :public | nil | :accept | :ok
+ :deploy_token | :unscoped | false | :public | nil | :accept | :ok
+ :deploy_token | :non_existing | true | :public | nil | :redirect | :redirected
+ :deploy_token | :non_existing | false | :public | nil | :reject | :not_found
+ :deploy_token | :scoped_naming_convention | true | :private | nil | :accept | :ok
+ :deploy_token | :scoped_naming_convention | false | :private | nil | :accept | :ok
+ :deploy_token | :scoped_no_naming_convention | true | :private | nil | :accept | :ok
+ :deploy_token | :scoped_no_naming_convention | false | :private | nil | :accept | :ok
+ :deploy_token | :unscoped | true | :private | nil | :accept | :ok
+ :deploy_token | :unscoped | false | :private | nil | :accept | :ok
+ :deploy_token | :non_existing | true | :private | nil | :redirect | :redirected
+ :deploy_token | :non_existing | false | :private | nil | :reject | :not_found
+ :deploy_token | :scoped_naming_convention | true | :internal | nil | :accept | :ok
+ :deploy_token | :scoped_naming_convention | false | :internal | nil | :accept | :ok
+ :deploy_token | :scoped_no_naming_convention | true | :internal | nil | :accept | :ok
+ :deploy_token | :scoped_no_naming_convention | false | :internal | nil | :accept | :ok
+ :deploy_token | :unscoped | true | :internal | nil | :accept | :ok
+ :deploy_token | :unscoped | false | :internal | nil | :accept | :ok
+ :deploy_token | :non_existing | true | :internal | nil | :redirect | :redirected
+ :deploy_token | :non_existing | false | :internal | nil | :reject | :not_found
+ end
- with_them do
- include_context 'set package name from package name type'
-
- let(:headers) do
- case auth
- when :oauth
- build_token_auth_header(token.token)
- when :personal_access_token
- build_token_auth_header(personal_access_token.token)
- when :job_token
- build_token_auth_header(job.token)
- when :deploy_token
- build_token_auth_header(deploy_token.token)
- else
- {}
+ with_them do
+ include_context 'set package name from package name type'
+
+ let(:headers) do
+ case auth
+ when :oauth
+ build_token_auth_header(token.token)
+ when :personal_access_token
+ build_token_auth_header(personal_access_token.token)
+ when :job_token
+ build_token_auth_header(job.token)
+ when :deploy_token
+ build_token_auth_header(deploy_token.token)
+ else
+ {}
+ end
end
- end
- before do
- project.send("add_#{user_role}", user) if user_role
- project.update!(visibility: Gitlab::VisibilityLevel.const_get(visibility, false))
- package.update!(name: package_name) unless package_name == 'non-existing-package'
- stub_application_setting(npm_package_requests_forwarding: request_forward)
- end
+ before do
+ project.send("add_#{user_role}", user) if user_role
+ project.update!(visibility: visibility.to_s)
+ package.update!(name: package_name) unless package_name == 'non-existing-package'
+ stub_application_setting(npm_package_requests_forwarding: request_forward)
+ end
- example_name = "#{params[:expected_result]} metadata request"
- status = params[:expected_status]
+ example_name = "#{params[:expected_result]} metadata request"
+ status = params[:expected_status]
- if scope == :instance && params[:package_name_type] != :scoped_naming_convention
- if params[:request_forward]
- example_name = 'redirect metadata request'
- status = :redirected
- else
- example_name = 'reject metadata request'
- status = :not_found
+ if scope == :instance && params[:package_name_type] != :scoped_naming_convention
+ if params[:request_forward]
+ example_name = 'redirect metadata request'
+ status = :redirected
+ else
+ example_name = 'reject metadata request'
+ status = :not_found
+ end
end
+
+ it_behaves_like example_name, status: status
end
+ end
- it_behaves_like example_name, status: status
+ context 'with a group namespace' do
+ it_behaves_like 'handling all conditions'
+ end
+
+ if scope != :project
+ context 'with a user namespace' do
+ let_it_be(:namespace) { user.namespace }
+
+ it_behaves_like 'handling all conditions'
+ end
end
context 'with a developer' do
@@ -225,26 +323,44 @@ RSpec.shared_examples 'handling get dist tags requests' do |scope: :project|
shared_examples 'handling different package names, visibilities and user roles' do
where(:package_name_type, :visibility, :user_role, :expected_result, :expected_status) do
- :scoped_naming_convention | 'PUBLIC' | :anonymous | :accept | :ok
- :scoped_naming_convention | 'PUBLIC' | :guest | :accept | :ok
- :scoped_naming_convention | 'PUBLIC' | :reporter | :accept | :ok
- :non_existing | 'PUBLIC' | :anonymous | :reject | :not_found
- :non_existing | 'PUBLIC' | :guest | :reject | :not_found
- :non_existing | 'PUBLIC' | :reporter | :reject | :not_found
-
- :scoped_naming_convention | 'PRIVATE' | :anonymous | :reject | :not_found
- :scoped_naming_convention | 'PRIVATE' | :guest | :reject | :forbidden
- :scoped_naming_convention | 'PRIVATE' | :reporter | :accept | :ok
- :non_existing | 'PRIVATE' | :anonymous | :reject | :not_found
- :non_existing | 'PRIVATE' | :guest | :reject | :forbidden
- :non_existing | 'PRIVATE' | :reporter | :reject | :not_found
-
- :scoped_naming_convention | 'INTERNAL' | :anonymous | :reject | :not_found
- :scoped_naming_convention | 'INTERNAL' | :guest | :accept | :ok
- :scoped_naming_convention | 'INTERNAL' | :reporter | :accept | :ok
- :non_existing | 'INTERNAL' | :anonymous | :reject | :not_found
- :non_existing | 'INTERNAL' | :guest | :reject | :not_found
- :non_existing | 'INTERNAL' | :reporter | :reject | :not_found
+ :scoped_naming_convention | :public | :anonymous | :accept | :ok
+ :scoped_naming_convention | :public | :guest | :accept | :ok
+ :scoped_naming_convention | :public | :reporter | :accept | :ok
+ :scoped_no_naming_convention | :public | :anonymous | :accept | :ok
+ :scoped_no_naming_convention | :public | :guest | :accept | :ok
+ :scoped_no_naming_convention | :public | :reporter | :accept | :ok
+ :unscoped | :public | :anonymous | :accept | :ok
+ :unscoped | :public | :guest | :accept | :ok
+ :unscoped | :public | :reporter | :accept | :ok
+ :non_existing | :public | :anonymous | :reject | :not_found
+ :non_existing | :public | :guest | :reject | :not_found
+ :non_existing | :public | :reporter | :reject | :not_found
+
+ :scoped_naming_convention | :private | :anonymous | :reject | :not_found
+ :scoped_naming_convention | :private | :guest | :reject | :forbidden
+ :scoped_naming_convention | :private | :reporter | :accept | :ok
+ :scoped_no_naming_convention | :private | :anonymous | :reject | :not_found
+ :scoped_no_naming_convention | :private | :guest | :reject | :forbidden
+ :scoped_no_naming_convention | :private | :reporter | :accept | :ok
+ :unscoped | :private | :anonymous | :reject | :not_found
+ :unscoped | :private | :guest | :reject | :forbidden
+ :unscoped | :private | :reporter | :accept | :ok
+ :non_existing | :private | :anonymous | :reject | :not_found
+ :non_existing | :private | :guest | :reject | :forbidden
+ :non_existing | :private | :reporter | :reject | :not_found
+
+ :scoped_naming_convention | :internal | :anonymous | :reject | :not_found
+ :scoped_naming_convention | :internal | :guest | :accept | :ok
+ :scoped_naming_convention | :internal | :reporter | :accept | :ok
+ :scoped_no_naming_convention | :internal | :anonymous | :reject | :not_found
+ :scoped_no_naming_convention | :internal | :guest | :accept | :ok
+ :scoped_no_naming_convention | :internal | :reporter | :accept | :ok
+ :unscoped | :internal | :anonymous | :reject | :not_found
+ :unscoped | :internal | :guest | :accept | :ok
+ :unscoped | :internal | :reporter | :accept | :ok
+ :non_existing | :internal | :anonymous | :reject | :not_found
+ :non_existing | :internal | :guest | :reject | :not_found
+ :non_existing | :internal | :reporter | :reject | :not_found
end
with_them do
@@ -254,7 +370,7 @@ RSpec.shared_examples 'handling get dist tags requests' do |scope: :project|
before do
project.send("add_#{user_role}", user) unless anonymous
- project.update!(visibility: Gitlab::VisibilityLevel.const_get(visibility, false))
+ project.update!(visibility: visibility.to_s)
end
example_name = "#{params[:expected_result]} package tags request"
@@ -269,16 +385,30 @@ RSpec.shared_examples 'handling get dist tags requests' do |scope: :project|
end
end
- context 'with oauth token' do
- let(:headers) { build_token_auth_header(token.token) }
+ shared_examples 'handling all conditions' do
+ context 'with oauth token' do
+ let(:headers) { build_token_auth_header(token.token) }
+
+ it_behaves_like 'handling different package names, visibilities and user roles'
+ end
+
+ context 'with personal access token' do
+ let(:headers) { build_token_auth_header(personal_access_token.token) }
- it_behaves_like 'handling different package names, visibilities and user roles'
+ it_behaves_like 'handling different package names, visibilities and user roles'
+ end
end
- context 'with personal access token' do
- let(:headers) { build_token_auth_header(personal_access_token.token) }
+ context 'with a group namespace' do
+ it_behaves_like 'handling all conditions'
+ end
- it_behaves_like 'handling different package names, visibilities and user roles'
+ if scope != :project
+ context 'with a user namespace' do
+ let_it_be(:namespace) { user.namespace }
+
+ it_behaves_like 'handling all conditions'
+ end
end
end
@@ -303,26 +433,44 @@ RSpec.shared_examples 'handling create dist tag requests' do |scope: :project|
shared_examples 'handling different package names, visibilities and user roles' do
where(:package_name_type, :visibility, :user_role, :expected_result, :expected_status) do
- :scoped_naming_convention | 'PUBLIC' | :anonymous | :reject | :forbidden
- :scoped_naming_convention | 'PUBLIC' | :guest | :reject | :forbidden
- :scoped_naming_convention | 'PUBLIC' | :developer | :accept | :ok
- :non_existing | 'PUBLIC' | :anonymous | :reject | :forbidden
- :non_existing | 'PUBLIC' | :guest | :reject | :forbidden
- :non_existing | 'PUBLIC' | :developer | :reject | :not_found
-
- :scoped_naming_convention | 'PRIVATE' | :anonymous | :reject | :not_found
- :scoped_naming_convention | 'PRIVATE' | :guest | :reject | :forbidden
- :scoped_naming_convention | 'PRIVATE' | :developer | :accept | :ok
- :non_existing | 'PRIVATE' | :anonymous | :reject | :not_found
- :non_existing | 'PRIVATE' | :guest | :reject | :forbidden
- :non_existing | 'PRIVATE' | :developer | :reject | :not_found
-
- :scoped_naming_convention | 'INTERNAL' | :anonymous | :reject | :forbidden
- :scoped_naming_convention | 'INTERNAL' | :guest | :reject | :forbidden
- :scoped_naming_convention | 'INTERNAL' | :developer | :accept | :ok
- :non_existing | 'INTERNAL' | :anonymous | :reject | :forbidden
- :non_existing | 'INTERNAL' | :guest | :reject | :forbidden
- :non_existing | 'INTERNAL' | :developer | :reject | :not_found
+ :scoped_naming_convention | :public | :anonymous | :reject | :forbidden
+ :scoped_naming_convention | :public | :guest | :reject | :forbidden
+ :scoped_naming_convention | :public | :developer | :accept | :ok
+ :scoped_no_naming_convention | :public | :anonymous | :reject | :forbidden
+ :scoped_no_naming_convention | :public | :guest | :reject | :forbidden
+ :scoped_no_naming_convention | :public | :developer | :accept | :ok
+ :unscoped | :public | :anonymous | :reject | :forbidden
+ :unscoped | :public | :guest | :reject | :forbidden
+ :unscoped | :public | :developer | :accept | :ok
+ :non_existing | :public | :anonymous | :reject | :forbidden
+ :non_existing | :public | :guest | :reject | :forbidden
+ :non_existing | :public | :developer | :reject | :not_found
+
+ :scoped_naming_convention | :private | :anonymous | :reject | :not_found
+ :scoped_naming_convention | :private | :guest | :reject | :forbidden
+ :scoped_naming_convention | :private | :developer | :accept | :ok
+ :scoped_no_naming_convention | :private | :anonymous | :reject | :not_found
+ :scoped_no_naming_convention | :private | :guest | :reject | :forbidden
+ :scoped_no_naming_convention | :private | :developer | :accept | :ok
+ :unscoped | :private | :anonymous | :reject | :not_found
+ :unscoped | :private | :guest | :reject | :forbidden
+ :unscoped | :private | :developer | :accept | :ok
+ :non_existing | :private | :anonymous | :reject | :not_found
+ :non_existing | :private | :guest | :reject | :forbidden
+ :non_existing | :private | :developer | :reject | :not_found
+
+ :scoped_naming_convention | :internal | :anonymous | :reject | :forbidden
+ :scoped_naming_convention | :internal | :guest | :reject | :forbidden
+ :scoped_naming_convention | :internal | :developer | :accept | :ok
+ :scoped_no_naming_convention | :internal | :anonymous | :reject | :forbidden
+ :scoped_no_naming_convention | :internal | :guest | :reject | :forbidden
+ :scoped_no_naming_convention | :internal | :developer | :accept | :ok
+ :unscoped | :internal | :anonymous | :reject | :forbidden
+ :unscoped | :internal | :guest | :reject | :forbidden
+ :unscoped | :internal | :developer | :accept | :ok
+ :non_existing | :internal | :anonymous | :reject | :forbidden
+ :non_existing | :internal | :guest | :reject | :forbidden
+ :non_existing | :internal | :developer | :reject | :not_found
end
with_them do
@@ -332,7 +480,7 @@ RSpec.shared_examples 'handling create dist tag requests' do |scope: :project|
before do
project.send("add_#{user_role}", user) unless anonymous
- project.update!(visibility: Gitlab::VisibilityLevel.const_get(visibility, false))
+ project.update!(visibility: visibility.to_s)
end
example_name = "#{params[:expected_result]} create package tag request"
@@ -347,16 +495,30 @@ RSpec.shared_examples 'handling create dist tag requests' do |scope: :project|
end
end
- context 'with oauth token' do
- let(:headers) { build_token_auth_header(token.token) }
+ shared_examples 'handling all conditions' do
+ context 'with oauth token' do
+ let(:headers) { build_token_auth_header(token.token) }
- it_behaves_like 'handling different package names, visibilities and user roles'
+ it_behaves_like 'handling different package names, visibilities and user roles'
+ end
+
+ context 'with personal access token' do
+ let(:headers) { build_token_auth_header(personal_access_token.token) }
+
+ it_behaves_like 'handling different package names, visibilities and user roles'
+ end
end
- context 'with personal access token' do
- let(:headers) { build_token_auth_header(personal_access_token.token) }
+ context 'with a group namespace' do
+ it_behaves_like 'handling all conditions'
+ end
- it_behaves_like 'handling different package names, visibilities and user roles'
+ if scope != :project
+ context 'with a user namespace' do
+ let_it_be(:namespace) { user.namespace }
+
+ it_behaves_like 'handling all conditions'
+ end
end
end
@@ -379,19 +541,44 @@ RSpec.shared_examples 'handling delete dist tag requests' do |scope: :project|
shared_examples 'handling different package names, visibilities and user roles' do
where(:package_name_type, :visibility, :user_role, :expected_result, :expected_status) do
- :scoped_naming_convention | 'PUBLIC' | :anonymous | :reject | :forbidden
- :scoped_naming_convention | 'PUBLIC' | :guest | :reject | :forbidden
- :scoped_naming_convention | 'PUBLIC' | :maintainer | :accept | :ok
- :non_existing | 'PUBLIC' | :anonymous | :reject | :forbidden
- :non_existing | 'PUBLIC' | :guest | :reject | :forbidden
- :non_existing | 'PUBLIC' | :maintainer | :reject | :not_found
-
- :scoped_naming_convention | 'PRIVATE' | :anonymous | :reject | :not_found
- :scoped_naming_convention | 'PRIVATE' | :guest | :reject | :forbidden
- :scoped_naming_convention | 'PRIVATE' | :maintainer | :accept | :ok
- :non_existing | 'INTERNAL' | :anonymous | :reject | :forbidden
- :non_existing | 'INTERNAL' | :guest | :reject | :forbidden
- :non_existing | 'INTERNAL' | :maintainer | :reject | :not_found
+ :scoped_naming_convention | :public | :anonymous | :reject | :forbidden
+ :scoped_naming_convention | :public | :guest | :reject | :forbidden
+ :scoped_naming_convention | :public | :maintainer | :accept | :ok
+ :scoped_no_naming_convention | :public | :anonymous | :reject | :forbidden
+ :scoped_no_naming_convention | :public | :guest | :reject | :forbidden
+ :scoped_no_naming_convention | :public | :maintainer | :accept | :ok
+ :unscoped | :public | :anonymous | :reject | :forbidden
+ :unscoped | :public | :guest | :reject | :forbidden
+ :unscoped | :public | :maintainer | :accept | :ok
+ :non_existing | :public | :anonymous | :reject | :forbidden
+ :non_existing | :public | :guest | :reject | :forbidden
+ :non_existing | :public | :maintainer | :reject | :not_found
+
+ :scoped_naming_convention | :private | :anonymous | :reject | :not_found
+ :scoped_naming_convention | :private | :guest | :reject | :forbidden
+ :scoped_naming_convention | :private | :maintainer | :accept | :ok
+ :scoped_no_naming_convention | :private | :anonymous | :reject | :not_found
+ :scoped_no_naming_convention | :private | :guest | :reject | :forbidden
+ :scoped_no_naming_convention | :private | :maintainer | :accept | :ok
+ :unscoped | :private | :anonymous | :reject | :not_found
+ :unscoped | :private | :guest | :reject | :forbidden
+ :unscoped | :private | :maintainer | :accept | :ok
+ :non_existing | :private | :anonymous | :reject | :not_found
+ :non_existing | :private | :guest | :reject | :forbidden
+ :non_existing | :private | :maintainer | :reject | :not_found
+
+ :scoped_naming_convention | :internal | :anonymous | :reject | :forbidden
+ :scoped_naming_convention | :internal | :guest | :reject | :forbidden
+ :scoped_naming_convention | :internal | :maintainer | :accept | :ok
+ :scoped_no_naming_convention | :internal | :anonymous | :reject | :forbidden
+ :scoped_no_naming_convention | :internal | :guest | :reject | :forbidden
+ :scoped_no_naming_convention | :internal | :maintainer | :accept | :ok
+ :unscoped | :internal | :anonymous | :reject | :forbidden
+ :unscoped | :internal | :guest | :reject | :forbidden
+ :unscoped | :internal | :maintainer | :accept | :ok
+ :non_existing | :internal | :anonymous | :reject | :forbidden
+ :non_existing | :internal | :guest | :reject | :forbidden
+ :non_existing | :internal | :maintainer | :reject | :not_found
end
with_them do
@@ -401,7 +588,7 @@ RSpec.shared_examples 'handling delete dist tag requests' do |scope: :project|
before do
project.send("add_#{user_role}", user) unless anonymous
- project.update!(visibility: Gitlab::VisibilityLevel.const_get(visibility, false))
+ project.update!(visibility: visibility.to_s)
end
example_name = "#{params[:expected_result]} delete package tag request"
@@ -416,15 +603,29 @@ RSpec.shared_examples 'handling delete dist tag requests' do |scope: :project|
end
end
- context 'with oauth token' do
- let(:headers) { build_token_auth_header(token.token) }
+ shared_examples 'handling all conditions' do
+ context 'with oauth token' do
+ let(:headers) { build_token_auth_header(token.token) }
+
+ it_behaves_like 'handling different package names, visibilities and user roles'
+ end
+
+ context 'with personal access token' do
+ let(:headers) { build_token_auth_header(personal_access_token.token) }
- it_behaves_like 'handling different package names, visibilities and user roles'
+ it_behaves_like 'handling different package names, visibilities and user roles'
+ end
end
- context 'with personal access token' do
- let(:headers) { build_token_auth_header(personal_access_token.token) }
+ context 'with a group namespace' do
+ it_behaves_like 'handling all conditions'
+ end
- it_behaves_like 'handling different package names, visibilities and user roles'
+ if scope != :project
+ context 'with a user namespace' do
+ let_it_be(:namespace) { user.namespace }
+
+ it_behaves_like 'handling all conditions'
+ end
end
end
diff --git a/spec/support/shared_examples/requests/api/rubygems_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/rubygems_packages_shared_examples.rb
new file mode 100644
index 00000000000..15fb6611b90
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/rubygems_packages_shared_examples.rb
@@ -0,0 +1,194 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'rejects rubygems packages access' do |user_type, status, add_member = true|
+ context "for user type #{user_type}" do
+ before do
+ project.send("add_#{user_type}", user) if add_member && user_type != :anonymous
+ end
+
+ it_behaves_like 'returning response status', status
+ end
+end
+
+RSpec.shared_examples 'process rubygems workhorse authorization' do |user_type, status, add_member = true|
+ context "for user type #{user_type}" do
+ before do
+ project.send("add_#{user_type}", user) if add_member && user_type != :anonymous
+ end
+
+ it_behaves_like 'returning response status', status
+
+ it 'has the proper content type' do
+ subject
+
+ expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ end
+
+ context 'with a request that bypassed gitlab-workhorse' do
+ let(:headers) do
+ { 'HTTP_AUTHORIZATION' => personal_access_token.token }
+ .merge(workhorse_headers)
+ .tap { |h| h.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER) }
+ end
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ it_behaves_like 'returning response status', :forbidden
+ end
+ end
+end
+
+RSpec.shared_examples 'process rubygems upload' do |user_type, status, add_member = true|
+ RSpec.shared_examples 'creates rubygems package files' do
+ it 'creates package files', :aggregate_failures do
+ expect { subject }
+ .to change { project.packages.count }.by(1)
+ .and change { Packages::PackageFile.count }.by(1)
+ expect(response).to have_gitlab_http_status(status)
+
+ package_file = project.packages.last.package_files.reload.last
+ expect(package_file.file_name).to eq('package.gem')
+ end
+ end
+
+ context "for user type #{user_type}" do
+ before do
+ project.send("add_#{user_type}", user) if add_member && user_type != :anonymous
+ end
+
+ context 'with object storage disabled' do
+ before do
+ stub_package_file_object_storage(enabled: false)
+ end
+
+ context 'without a file from workhorse' do
+ let(:send_rewritten_field) { false }
+
+ it_behaves_like 'returning response status', :bad_request
+ end
+
+ context 'with correct params' do
+ it_behaves_like 'package workhorse uploads'
+ it_behaves_like 'creates rubygems package files'
+ it_behaves_like 'a package tracking event', 'API::RubygemPackages', 'push_package'
+ end
+ end
+
+ context 'with object storage enabled' do
+ let(:tmp_object) do
+ fog_connection.directories.new(key: 'packages').files.create( # rubocop:disable Rails/SaveBang
+ key: "tmp/uploads/#{file_name}",
+ body: 'content'
+ )
+ end
+
+ let(:fog_file) { fog_to_uploaded_file(tmp_object) }
+ let(:params) { { file: fog_file, 'file.remote_id' => file_name } }
+
+ context 'and direct upload enabled' do
+ let(:fog_connection) do
+ stub_package_file_object_storage(direct_upload: true)
+ end
+
+ it_behaves_like 'creates rubygems package files'
+
+ ['123123', '../../123123'].each do |remote_id|
+ context "with invalid remote_id: #{remote_id}" do
+ let(:params) do
+ {
+ file: fog_file,
+ 'file.remote_id' => remote_id
+ }
+ end
+
+ it_behaves_like 'returning response status', :forbidden
+ end
+ end
+ end
+
+ context 'and direct upload disabled' do
+ context 'and background upload disabled' do
+ let(:fog_connection) do
+ stub_package_file_object_storage(direct_upload: false, background_upload: false)
+ end
+
+ it_behaves_like 'creates rubygems package files'
+ end
+
+ context 'and background upload enabled' do
+ let(:fog_connection) do
+ stub_package_file_object_storage(direct_upload: false, background_upload: true)
+ end
+
+ it_behaves_like 'creates rubygems package files'
+ end
+ end
+ end
+ end
+end
+
+RSpec.shared_examples 'dependency endpoint success' do |user_type, status, add_member = true|
+ context "for user type #{user_type}" do
+ before do
+ project.send("add_#{user_type}", user) if add_member && user_type != :anonymous
+ end
+
+ raise 'Status is not :success' if status != :success
+
+ context 'with no params', :aggregate_failures do
+ it 'returns empty' do
+ subject
+
+ expect(response.body).to eq('200')
+ expect(response).to have_gitlab_http_status(status)
+ end
+ end
+
+ context 'with gems params' do
+ let(:params) { { gems: 'foo,bar' } }
+ let(:expected_response) { Marshal.dump(%w(result result)) }
+
+ it 'returns successfully', :aggregate_failures do
+ service_result = double('DependencyResolverService', execute: ServiceResponse.success(payload: 'result'))
+
+ expect(Packages::Rubygems::DependencyResolverService).to receive(:new).with(project, anything, gem_name: 'foo').and_return(service_result)
+ expect(Packages::Rubygems::DependencyResolverService).to receive(:new).with(project, anything, gem_name: 'bar').and_return(service_result)
+
+ subject
+
+ expect(response.body).to eq(expected_response) # rubocop:disable Security/MarshalLoad
+ expect(response).to have_gitlab_http_status(status)
+ end
+
+ it 'rejects if the service fails', :aggregate_failures do
+ service_result = double('DependencyResolverService', execute: ServiceResponse.error(message: 'rejected', http_status: :bad_request))
+
+ expect(Packages::Rubygems::DependencyResolverService).to receive(:new).with(project, anything, gem_name: 'foo').and_return(service_result)
+
+ subject
+
+ expect(response.body).to match(/rejected/)
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+ end
+end
+
+RSpec.shared_examples 'Rubygems gem download' do |user_type, status, add_member = true|
+ context "for user type #{user_type}" do
+ before do
+ project.send("add_#{user_type}", user) if add_member && user_type != :anonymous
+ end
+
+ it 'returns the gem', :aggregate_failures do
+ subject
+
+ expect(response.media_type).to eq('application/octet-stream')
+ expect(response).to have_gitlab_http_status(status)
+ end
+
+ it_behaves_like 'a package tracking event', described_class.name, 'pull_package'
+ end
+end
diff --git a/spec/support/shared_examples/service_desk_issue_templates_examples.rb b/spec/support/shared_examples/service_desk_issue_templates_examples.rb
new file mode 100644
index 00000000000..fd9645df7a3
--- /dev/null
+++ b/spec/support/shared_examples/service_desk_issue_templates_examples.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'issue description templates from current project only' do
+ it 'loads issue description templates from the project only' do
+ within('#service-desk-template-select') do
+ expect(page).to have_content('project-issue-bar')
+ expect(page).to have_content('project-issue-foo')
+ expect(page).not_to have_content('group-issue-bar')
+ expect(page).not_to have_content('group-issue-foo')
+ end
+ end
+end
diff --git a/spec/support/shared_examples/services/boards/update_boards_shared_examples.rb b/spec/support/shared_examples/services/boards/update_boards_shared_examples.rb
new file mode 100644
index 00000000000..cd773a2a04a
--- /dev/null
+++ b/spec/support/shared_examples/services/boards/update_boards_shared_examples.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'board update service' do
+ subject(:service) { described_class.new(board.resource_parent, user, all_params) }
+
+ it 'updates the board with valid params' do
+ result = described_class.new(group, user, name: 'Engineering').execute(board)
+
+ expect(result).to eq(true)
+ expect(board.reload.name).to eq('Engineering')
+ end
+
+ it 'does not update the board with invalid params' do
+ orig_name = board.name
+
+ result = described_class.new(group, user, name: nil).execute(board)
+
+ expect(result).to eq(false)
+ expect(board.reload.name).to eq(orig_name)
+ end
+
+ context 'with scoped_issue_board available' do
+ before do
+ stub_licensed_features(scoped_issue_board: true)
+ end
+
+ context 'user is member of the board parent' do
+ before do
+ board.resource_parent.add_reporter(user)
+ end
+
+ it 'updates the configuration params when scoped issue board is enabled' do
+ service.execute(board)
+
+ labels = updated_scoped_params.delete(:labels)
+ expect(board.reload).to have_attributes(updated_scoped_params)
+ expect(board.labels).to match_array(labels)
+ end
+ end
+
+ context 'when labels param is used' do
+ let(:params) { { labels: [label.name, parent_label.name, 'new label'].join(',') } }
+
+ subject(:service) { described_class.new(board.resource_parent, user, params) }
+
+ context 'when user can create new labels' do
+ before do
+ board.resource_parent.add_reporter(user)
+ end
+
+ it 'adds labels to the board' do
+ service.execute(board)
+
+ expect(board.reload.labels.map(&:name)).to match_array([label.name, parent_label.name, 'new label'])
+ end
+ end
+
+ context 'when user can not create new labels' do
+ before do
+ board.resource_parent.add_guest(user)
+ end
+
+ it 'adds only existing labels to the board' do
+ service.execute(board)
+
+ expect(board.reload.labels.map(&:name)).to match_array([label.name, parent_label.name])
+ end
+ end
+ end
+ end
+
+ context 'without scoped_issue_board available' do
+ before do
+ stub_licensed_features(scoped_issue_board: false)
+ end
+
+ it 'filters unpermitted params when scoped issue board is not enabled' do
+ service.execute(board)
+
+ expect(board.reload).to have_attributes(updated_without_scoped_params)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/services/packages/maven/metadata_shared_examples.rb b/spec/support/shared_examples/services/packages/maven/metadata_shared_examples.rb
new file mode 100644
index 00000000000..4de672bb732
--- /dev/null
+++ b/spec/support/shared_examples/services/packages/maven/metadata_shared_examples.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'handling metadata content pointing to a file for the create xml service' do
+ context 'with metadata content pointing to a file' do
+ let(:service) { described_class.new(metadata_content: file, package: package) }
+ let(:file) do
+ Tempfile.new('metadata').tap do |file|
+ if file_contents
+ file.write(file_contents)
+ file.flush
+ file.rewind
+ end
+ end
+ end
+
+ after do
+ file.close
+ file.unlink
+ end
+
+ context 'with valid content' do
+ let(:file_contents) { metadata_xml }
+
+ it 'returns no changes' do
+ expect(subject).to be_success
+ expect(subject.payload).to eq(changes_exist: false, empty_versions: false)
+ end
+ end
+
+ context 'with invalid content' do
+ let(:file_contents) { '<meta></metadata>' }
+
+ it_behaves_like 'returning an error service response', message: 'metadata_content is invalid'
+ end
+
+ context 'with no content' do
+ let(:file_contents) { nil }
+
+ it_behaves_like 'returning an error service response', message: 'metadata_content is invalid'
+ end
+ end
+end
+
+RSpec.shared_examples 'handling invalid parameters for create xml service' do
+ context 'with no package' do
+ let(:metadata_xml) { '' }
+ let(:package) { nil }
+
+ it_behaves_like 'returning an error service response', message: 'package not set'
+ end
+
+ context 'with no metadata content' do
+ let(:metadata_xml) { nil }
+
+ it_behaves_like 'returning an error service response', message: 'metadata_content not set'
+ end
+end
diff --git a/spec/support/snowplow.rb b/spec/support/snowplow.rb
index 0d6102f1705..e58be667b37 100644
--- a/spec/support/snowplow.rb
+++ b/spec/support/snowplow.rb
@@ -1,24 +1,13 @@
# frozen_string_literal: true
+require_relative 'stub_snowplow'
+
RSpec.configure do |config|
config.include SnowplowHelpers, :snowplow
+ config.include StubSnowplow, :snowplow
config.before(:each, :snowplow) do
- # Using a high buffer size to not cause early flushes
- buffer_size = 100
- # WebMock is set up to allow requests to `localhost`
- host = 'localhost'
-
- allow_any_instance_of(Gitlab::Tracking::Destinations::ProductAnalytics).to receive(:event)
-
- allow_any_instance_of(Gitlab::Tracking::Destinations::Snowplow)
- .to receive(:emitter)
- .and_return(SnowplowTracker::Emitter.new(host, buffer_size: buffer_size))
-
- stub_application_setting(snowplow_enabled: true)
-
- allow(SnowplowTracker::SelfDescribingJson).to receive(:new).and_call_original
- allow(Gitlab::Tracking).to receive(:event).and_call_original # rubocop:disable RSpec/ExpectGitlabTracking
+ stub_snowplow
end
config.after(:each, :snowplow) do
diff --git a/spec/support/stub_snowplow.rb b/spec/support/stub_snowplow.rb
new file mode 100644
index 00000000000..a21ce2399d7
--- /dev/null
+++ b/spec/support/stub_snowplow.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module StubSnowplow
+ def stub_snowplow
+ # Using a high buffer size to not cause early flushes
+ buffer_size = 100
+ # WebMock is set up to allow requests to `localhost`
+ host = 'localhost'
+
+ # rubocop:disable RSpec/AnyInstanceOf
+ allow_any_instance_of(Gitlab::Tracking::Destinations::ProductAnalytics).to receive(:event)
+
+ allow_any_instance_of(Gitlab::Tracking::Destinations::Snowplow)
+ .to receive(:emitter)
+ .and_return(SnowplowTracker::Emitter.new(host, buffer_size: buffer_size))
+ # rubocop:enable RSpec/AnyInstanceOf
+
+ stub_application_setting(snowplow_enabled: true)
+
+ allow(SnowplowTracker::SelfDescribingJson).to receive(:new).and_call_original
+ allow(Gitlab::Tracking).to receive(:event).and_call_original # rubocop:disable RSpec/ExpectGitlabTracking
+ end
+end
diff --git a/spec/tasks/admin_mode_spec.rb b/spec/tasks/admin_mode_spec.rb
new file mode 100644
index 00000000000..9dd35650ab6
--- /dev/null
+++ b/spec/tasks/admin_mode_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'rake_helper'
+
+RSpec.describe 'admin mode on tasks' do
+ before do
+ allow(::Gitlab::Runtime).to receive(:test_suite?).and_return(false)
+ allow(::Gitlab::Runtime).to receive(:rake?).and_return(true)
+ end
+
+ shared_examples 'verify admin mode' do |state|
+ it 'matches the expected admin mode' do
+ Rake::Task.define_task :verify_admin_mode do
+ expect(Gitlab::Auth::CurrentUserMode.new(user).admin_mode?).to be(state)
+ end
+
+ run_rake_task('verify_admin_mode')
+ end
+ end
+
+ describe 'with a regular user' do
+ let(:user) { create(:user) }
+
+ include_examples 'verify admin mode', false
+ end
+
+ describe 'with an admin' do
+ let(:user) { create(:admin) }
+
+ include_examples 'verify admin mode', true
+ end
+end
diff --git a/spec/tasks/gitlab/packages/composer_rake_spec.rb b/spec/tasks/gitlab/packages/composer_rake_spec.rb
new file mode 100644
index 00000000000..d54e1b02599
--- /dev/null
+++ b/spec/tasks/gitlab/packages/composer_rake_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'rake_helper'
+
+RSpec.describe 'gitlab:packages:build_composer_cache namespace rake task' do
+ let_it_be(:package_name) { 'sample-project' }
+ let_it_be(:package_name2) { 'sample-project2' }
+ let_it_be(:json) { { 'name' => package_name } }
+ let_it_be(:json2) { { 'name' => package_name2 } }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, :custom_repo, files: { 'composer.json' => json.to_json }, group: group) }
+ let_it_be(:project2) { create(:project, :custom_repo, files: { 'composer.json' => json2.to_json }, group: group) }
+ let!(:package) { create(:composer_package, :with_metadatum, project: project, name: package_name, version: '1.0.0', json: json) }
+ let!(:package2) { create(:composer_package, :with_metadatum, project: project, name: package_name, version: '2.0.0', json: json) }
+ let!(:package3) { create(:composer_package, :with_metadatum, project: project2, name: package_name2, version: '3.0.0', json: json2) }
+
+ before :all do
+ Rake.application.rake_require 'tasks/gitlab/packages/composer'
+ end
+
+ subject do
+ run_rake_task("gitlab:packages:build_composer_cache")
+ end
+
+ it 'generates the cache files' do
+ expect { subject }.to change { Packages::Composer::CacheFile.count }.by(2)
+ end
+end
diff --git a/spec/tooling/danger/base_linter_spec.rb b/spec/tooling/danger/base_linter_spec.rb
deleted file mode 100644
index 54d8f3dc1f7..00000000000
--- a/spec/tooling/danger/base_linter_spec.rb
+++ /dev/null
@@ -1,192 +0,0 @@
-# frozen_string_literal: true
-
-require 'rspec-parameterized'
-require_relative 'danger_spec_helper'
-
-require_relative '../../../tooling/danger/base_linter'
-
-RSpec.describe Tooling::Danger::BaseLinter do
- let(:commit_class) do
- Struct.new(:message, :sha, :diff_parent)
- end
-
- let(:commit_message) { 'A commit message' }
- let(:commit) { commit_class.new(commit_message, anything, anything) }
-
- subject(:commit_linter) { described_class.new(commit) }
-
- describe '#failed?' do
- context 'with no failures' do
- it { expect(commit_linter).not_to be_failed }
- end
-
- context 'with failures' do
- before do
- commit_linter.add_problem(:subject_too_long, described_class.subject_description)
- end
-
- it { expect(commit_linter).to be_failed }
- end
- end
-
- describe '#add_problem' do
- it 'stores messages in #failures' do
- commit_linter.add_problem(:subject_too_long, '%s')
-
- expect(commit_linter.problems).to eq({ subject_too_long: described_class.problems_mapping[:subject_too_long] })
- end
- end
-
- shared_examples 'a valid commit' do
- it 'does not have any problem' do
- commit_linter.lint_subject
-
- expect(commit_linter.problems).to be_empty
- end
- end
-
- describe '#lint_subject' do
- context 'when subject valid' do
- it_behaves_like 'a valid commit'
- end
-
- context 'when subject is too short' do
- let(:commit_message) { 'A B' }
-
- it 'adds a problem' do
- expect(commit_linter).to receive(:add_problem).with(:subject_too_short, described_class.subject_description)
-
- commit_linter.lint_subject
- end
- end
-
- context 'when subject is too long' do
- let(:commit_message) { 'A B ' + 'C' * described_class::MAX_LINE_LENGTH }
-
- it 'adds a problem' do
- expect(commit_linter).to receive(:add_problem).with(:subject_too_long, described_class.subject_description)
-
- commit_linter.lint_subject
- end
- end
-
- context 'when ignoring length issues for subject having not-ready wording' do
- using RSpec::Parameterized::TableSyntax
-
- let(:final_message) { 'A B C' }
-
- context 'when used as prefix' do
- where(prefix: [
- 'WIP: ',
- 'WIP:',
- 'wIp:',
- '[WIP] ',
- '[WIP]',
- '[draft]',
- '[draft] ',
- '(draft)',
- '(draft) ',
- 'draft - ',
- 'draft: ',
- 'draft:',
- 'DRAFT:'
- ])
-
- with_them do
- it 'does not have any problems' do
- commit_message = prefix + final_message + 'D' * (described_class::MAX_LINE_LENGTH - final_message.size)
- commit = commit_class.new(commit_message, anything, anything)
-
- linter = described_class.new(commit).lint_subject
-
- expect(linter.problems).to be_empty
- end
- end
- end
-
- context 'when used as suffix' do
- where(suffix: %w[WIP draft])
-
- with_them do
- it 'does not have any problems' do
- commit_message = final_message + 'D' * (described_class::MAX_LINE_LENGTH - final_message.size) + suffix
- commit = commit_class.new(commit_message, anything, anything)
-
- linter = described_class.new(commit).lint_subject
-
- expect(linter.problems).to be_empty
- end
- end
- end
- end
-
- context 'when subject does not have enough words and is too long' do
- let(:commit_message) { 'A ' + 'B' * described_class::MAX_LINE_LENGTH }
-
- it 'adds a problem' do
- expect(commit_linter).to receive(:add_problem).with(:subject_too_short, described_class.subject_description)
- expect(commit_linter).to receive(:add_problem).with(:subject_too_long, described_class.subject_description)
-
- commit_linter.lint_subject
- end
- end
-
- context 'when subject starts with lowercase' do
- let(:commit_message) { 'a B C' }
-
- it 'adds a problem' do
- expect(commit_linter).to receive(:add_problem).with(:subject_starts_with_lowercase, described_class.subject_description)
-
- commit_linter.lint_subject
- end
- end
-
- [
- '[ci skip] A commit message',
- '[Ci skip] A commit message',
- '[API] A commit message',
- 'api: A commit message',
- 'API: A commit message',
- 'API: a commit message',
- 'API: a commit message'
- ].each do |message|
- context "when subject is '#{message}'" do
- let(:commit_message) { message }
-
- it 'does not add a problem' do
- expect(commit_linter).not_to receive(:add_problem)
-
- commit_linter.lint_subject
- end
- end
- end
-
- [
- '[ci skip]A commit message',
- '[Ci skip] A commit message',
- '[ci skip] a commit message',
- 'api: a commit message',
- '! A commit message'
- ].each do |message|
- context "when subject is '#{message}'" do
- let(:commit_message) { message }
-
- it 'adds a problem' do
- expect(commit_linter).to receive(:add_problem).with(:subject_starts_with_lowercase, described_class.subject_description)
-
- commit_linter.lint_subject
- end
- end
- end
-
- context 'when subject ends with a period' do
- let(:commit_message) { 'A B C.' }
-
- it 'adds a problem' do
- expect(commit_linter).to receive(:add_problem).with(:subject_ends_with_a_period, described_class.subject_description)
-
- commit_linter.lint_subject
- end
- end
- end
-end
diff --git a/spec/tooling/danger/changelog_spec.rb b/spec/tooling/danger/changelog_spec.rb
index c0eca67ce92..b74039b3cd1 100644
--- a/spec/tooling/danger/changelog_spec.rb
+++ b/spec/tooling/danger/changelog_spec.rb
@@ -1,51 +1,76 @@
# frozen_string_literal: true
-require_relative 'danger_spec_helper'
+require 'gitlab-dangerfiles'
+require 'gitlab/dangerfiles/spec_helper'
require_relative '../../../tooling/danger/changelog'
+require_relative '../../../tooling/danger/project_helper'
RSpec.describe Tooling::Danger::Changelog do
- include DangerSpecHelper
+ include_context "with dangerfile"
- let(:added_files) { nil }
- let(:fake_git) { double('fake-git', added_files: added_files) }
+ let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) }
+ let(:fake_project_helper) { double('fake-project-helper', helper: fake_helper).tap { |h| h.class.include(Tooling::Danger::ProjectHelper) } }
- let(:mr_labels) { nil }
- let(:mr_json) { nil }
- let(:fake_gitlab) { double('fake-gitlab', mr_labels: mr_labels, mr_json: mr_json) }
+ subject(:changelog) { fake_danger.new(helper: fake_helper) }
- let(:changes_by_category) { nil }
- let(:sanitize_mr_title) { nil }
- let(:ee?) { false }
- let(:fake_helper) { double('fake-helper', changes_by_category: changes_by_category, sanitize_mr_title: sanitize_mr_title, ee?: ee?) }
+ before do
+ allow(changelog).to receive(:project_helper).and_return(fake_project_helper)
+ end
+
+ describe '#required_reasons' do
+ subject { changelog.required_reasons }
+
+ context "added files contain a migration" do
+ let(:changes) { changes_class.new([change_class.new('foo', :added, :migration)]) }
+
+ it { is_expected.to include(:db_changes) }
+ end
+
+ context "removed files contains a feature flag" do
+ let(:changes) { changes_class.new([change_class.new('foo', :deleted, :feature_flag)]) }
- let(:fake_danger) { new_fake_danger.include(described_class) }
+ it { is_expected.to include(:feature_flag_removed) }
+ end
+
+ context "added files do not contain a migration" do
+ let(:changes) { changes_class.new([change_class.new('foo', :added, :frontend)]) }
+
+ it { is_expected.to be_empty }
+ end
+
+ context "removed files do not contain a feature flag" do
+ let(:changes) { changes_class.new([change_class.new('foo', :deleted, :backend)]) }
- subject(:changelog) { fake_danger.new(git: fake_git, gitlab: fake_gitlab, helper: fake_helper) }
+ it { is_expected.to be_empty }
+ end
+ end
describe '#required?' do
subject { changelog.required? }
context 'added files contain a migration' do
- [
- 'db/migrate/20200000000000_new_migration.rb',
- 'db/post_migrate/20200000000000_new_migration.rb'
- ].each do |file_path|
- let(:added_files) { [file_path] }
+ let(:changes) { changes_class.new([change_class.new('foo', :added, :migration)]) }
- it { is_expected.to be_truthy }
- end
+ it { is_expected.to be_truthy }
+ end
+
+ context "removed files contains a feature flag" do
+ let(:changes) { changes_class.new([change_class.new('foo', :deleted, :feature_flag)]) }
+
+ it { is_expected.to be_truthy }
end
context 'added files do not contain a migration' do
- [
- 'app/models/model.rb',
- 'app/assets/javascripts/file.js'
- ].each do |file_path|
- let(:added_files) { [file_path] }
+ let(:changes) { changes_class.new([change_class.new('foo', :added, :frontend)]) }
- it { is_expected.to be_falsey }
- end
+ it { is_expected.to be_falsey }
+ end
+
+ context "removed files do not contain a feature flag" do
+ let(:changes) { changes_class.new([change_class.new('foo', :deleted, :backend)]) }
+
+ it { is_expected.to be_falsey }
end
end
@@ -58,8 +83,7 @@ RSpec.describe Tooling::Danger::Changelog do
subject { changelog.optional? }
context 'when MR contains only categories requiring no changelog' do
- let(:changes_by_category) { { category_without_changelog => nil } }
- let(:mr_labels) { [] }
+ let(:changes) { changes_class.new([change_class.new('foo', :modified, category_without_changelog)]) }
it 'is falsey' do
is_expected.to be_falsy
@@ -67,7 +91,7 @@ RSpec.describe Tooling::Danger::Changelog do
end
context 'when MR contains a label that require no changelog' do
- let(:changes_by_category) { { category_with_changelog => nil } }
+ let(:changes) { changes_class.new([change_class.new('foo', :modified, category_with_changelog)]) }
let(:mr_labels) { [label_with_changelog, label_without_changelog] }
it 'is falsey' do
@@ -76,29 +100,28 @@ RSpec.describe Tooling::Danger::Changelog do
end
context 'when MR contains a category that require changelog and a category that require no changelog' do
- let(:changes_by_category) { { category_with_changelog => nil, category_without_changelog => nil } }
- let(:mr_labels) { [] }
+ let(:changes) { changes_class.new([change_class.new('foo', :modified, category_with_changelog), change_class.new('foo', :modified, category_without_changelog)]) }
- it 'is truthy' do
- is_expected.to be_truthy
+ context 'with no labels' do
+ it 'is truthy' do
+ is_expected.to be_truthy
+ end
end
- end
- context 'when MR contains a category that require changelog and a category that require no changelog with changelog label' do
- let(:changes_by_category) { { category_with_changelog => nil, category_without_changelog => nil } }
- let(:mr_labels) { ['feature'] }
+ context 'with changelog label' do
+ let(:mr_labels) { ['feature'] }
- it 'is truthy' do
- is_expected.to be_truthy
+ it 'is truthy' do
+ is_expected.to be_truthy
+ end
end
- end
- context 'when MR contains a category that require changelog and a category that require no changelog with no changelog label' do
- let(:changes_by_category) { { category_with_changelog => nil, category_without_changelog => nil } }
- let(:mr_labels) { ['tooling'] }
+ context 'with no changelog label' do
+ let(:mr_labels) { ['tooling'] }
- it 'is truthy' do
- is_expected.to be_falsey
+ it 'is truthy' do
+ is_expected.to be_falsey
+ end
end
end
end
@@ -107,54 +130,39 @@ RSpec.describe Tooling::Danger::Changelog do
subject { changelog.found }
context 'added files contain a changelog' do
- [
- 'changelogs/unreleased/entry.yml',
- 'ee/changelogs/unreleased/entry.yml'
- ].each do |file_path|
- let(:added_files) { [file_path] }
+ let(:changes) { changes_class.new([change_class.new('foo', :added, :changelog)]) }
- it { is_expected.to be_truthy }
- end
+ it { is_expected.to be_truthy }
end
context 'added files do not contain a changelog' do
- [
- 'app/models/model.rb',
- 'app/assets/javascripts/file.js'
- ].each do |file_path|
- let(:added_files) { [file_path] }
- it { is_expected.to eq(nil) }
- end
+ let(:changes) { changes_class.new([change_class.new('foo', :added, :backend)]) }
+
+ it { is_expected.to eq(nil) }
end
end
describe '#ee_changelog?' do
subject { changelog.ee_changelog? }
- before do
- allow(changelog).to receive(:found).and_return(file_path)
- end
-
context 'is ee changelog' do
- let(:file_path) { 'ee/changelogs/unreleased/entry.yml' }
+ let(:changes) { changes_class.new([change_class.new('ee/changelogs/unreleased/entry.yml', :added, :changelog)]) }
it { is_expected.to be_truthy }
end
context 'is not ee changelog' do
- let(:file_path) { 'changelogs/unreleased/entry.yml' }
+ let(:changes) { changes_class.new([change_class.new('changelogs/unreleased/entry.yml', :added, :changelog)]) }
it { is_expected.to be_falsy }
end
end
describe '#modified_text' do
- let(:mr_json) { { "iid" => 1234, "title" => sanitize_mr_title } }
-
subject { changelog.modified_text }
context "when title is not changed from sanitization", :aggregate_failures do
- let(:sanitize_mr_title) { 'Fake Title' }
+ let(:mr_title) { 'Fake Title' }
specify do
expect(subject).to include('CHANGELOG.md was edited')
@@ -164,7 +172,7 @@ RSpec.describe Tooling::Danger::Changelog do
end
context "when title needs sanitization", :aggregate_failures do
- let(:sanitize_mr_title) { 'DRAFT: Fake Title' }
+ let(:mr_title) { 'DRAFT: Fake Title' }
specify do
expect(subject).to include('CHANGELOG.md was edited')
@@ -174,39 +182,46 @@ RSpec.describe Tooling::Danger::Changelog do
end
end
- describe '#required_text' do
- let(:mr_json) { { "iid" => 1234, "title" => sanitize_mr_title } }
-
- subject { changelog.required_text }
+ describe '#required_texts' do
+ let(:mr_title) { 'Fake Title' }
- context "when title is not changed from sanitization", :aggregate_failures do
- let(:sanitize_mr_title) { 'Fake Title' }
+ subject { changelog.required_texts }
+ shared_examples 'changelog required text' do |key|
specify do
- expect(subject).to include('CHANGELOG missing')
- expect(subject).to include('bin/changelog -m 1234 "Fake Title"')
- expect(subject).not_to include('--ee')
+ expect(subject).to have_key(key)
+ expect(subject[key]).to include('CHANGELOG missing')
+ expect(subject[key]).to include('bin/changelog -m 1234 "Fake Title"')
+ expect(subject[key]).not_to include('--ee')
end
end
- context "when title needs sanitization", :aggregate_failures do
- let(:sanitize_mr_title) { 'DRAFT: Fake Title' }
+ context 'with a new migration file' do
+ let(:changes) { changes_class.new([change_class.new('foo', :added, :migration)]) }
- specify do
- expect(subject).to include('CHANGELOG missing')
- expect(subject).to include('bin/changelog -m 1234 "Fake Title"')
- expect(subject).not_to include('--ee')
+ context "when title is not changed from sanitization", :aggregate_failures do
+ it_behaves_like 'changelog required text', :db_changes
+ end
+
+ context "when title needs sanitization", :aggregate_failures do
+ let(:mr_title) { 'DRAFT: Fake Title' }
+
+ it_behaves_like 'changelog required text', :db_changes
end
end
+
+ context 'with a removed feature flag file' do
+ let(:changes) { changes_class.new([change_class.new('foo', :deleted, :feature_flag)]) }
+
+ it_behaves_like 'changelog required text', :feature_flag_removed
+ end
end
describe '#optional_text' do
- let(:mr_json) { { "iid" => 1234, "title" => sanitize_mr_title } }
-
subject { changelog.optional_text }
context "when title is not changed from sanitization", :aggregate_failures do
- let(:sanitize_mr_title) { 'Fake Title' }
+ let(:mr_title) { 'Fake Title' }
specify do
expect(subject).to include('CHANGELOG missing')
@@ -216,7 +231,7 @@ RSpec.describe Tooling::Danger::Changelog do
end
context "when title needs sanitization", :aggregate_failures do
- let(:sanitize_mr_title) { 'DRAFT: Fake Title' }
+ let(:mr_title) { 'DRAFT: Fake Title' }
specify do
expect(subject).to include('CHANGELOG missing')
diff --git a/spec/tooling/danger/commit_linter_spec.rb b/spec/tooling/danger/commit_linter_spec.rb
deleted file mode 100644
index 694e524af21..00000000000
--- a/spec/tooling/danger/commit_linter_spec.rb
+++ /dev/null
@@ -1,241 +0,0 @@
-# frozen_string_literal: true
-
-require 'rspec-parameterized'
-require_relative 'danger_spec_helper'
-
-require_relative '../../../tooling/danger/commit_linter'
-
-RSpec.describe Tooling::Danger::CommitLinter do
- using RSpec::Parameterized::TableSyntax
-
- let(:total_files_changed) { 2 }
- let(:total_lines_changed) { 10 }
- let(:stats) { { total: { files: total_files_changed, lines: total_lines_changed } } }
- let(:diff_parent) { Struct.new(:stats).new(stats) }
- let(:commit_class) do
- Struct.new(:message, :sha, :diff_parent)
- end
-
- let(:commit_message) { 'A commit message' }
- let(:commit_sha) { 'abcd1234' }
- let(:commit) { commit_class.new(commit_message, commit_sha, diff_parent) }
-
- subject(:commit_linter) { described_class.new(commit) }
-
- describe '#fixup?' do
- where(:commit_message, :is_fixup) do
- 'A commit message' | false
- 'fixup!' | true
- 'fixup! A commit message' | true
- 'squash!' | true
- 'squash! A commit message' | true
- end
-
- with_them do
- it 'is true when commit message starts with "fixup!" or "squash!"' do
- expect(commit_linter.fixup?).to be(is_fixup)
- end
- end
- end
-
- describe '#suggestion?' do
- where(:commit_message, :is_suggestion) do
- 'A commit message' | false
- 'Apply suggestion to' | true
- 'Apply suggestion to "A commit message"' | true
- end
-
- with_them do
- it 'is true when commit message starts with "Apply suggestion to"' do
- expect(commit_linter.suggestion?).to be(is_suggestion)
- end
- end
- end
-
- describe '#merge?' do
- where(:commit_message, :is_merge) do
- 'A commit message' | false
- 'Merge branch' | true
- 'Merge branch "A commit message"' | true
- end
-
- with_them do
- it 'is true when commit message starts with "Merge branch"' do
- expect(commit_linter.merge?).to be(is_merge)
- end
- end
- end
-
- describe '#revert?' do
- where(:commit_message, :is_revert) do
- 'A commit message' | false
- 'Revert' | false
- 'Revert "' | true
- 'Revert "A commit message"' | true
- end
-
- with_them do
- it 'is true when commit message starts with "Revert \""' do
- expect(commit_linter.revert?).to be(is_revert)
- end
- end
- end
-
- describe '#multi_line?' do
- where(:commit_message, :is_multi_line) do
- "A commit message" | false
- "A commit message\n" | false
- "A commit message\n\n" | false
- "A commit message\n\nSigned-off-by: User Name <user@name.me>" | false
- "A commit message\n\nWith details" | true
- end
-
- with_them do
- it 'is true when commit message contains details' do
- expect(commit_linter.multi_line?).to be(is_multi_line)
- end
- end
- end
-
- shared_examples 'a valid commit' do
- it 'does not have any problem' do
- commit_linter.lint
-
- expect(commit_linter.problems).to be_empty
- end
- end
-
- describe '#lint' do
- describe 'separator' do
- context 'when separator is missing' do
- let(:commit_message) { "A B C\n" }
-
- it_behaves_like 'a valid commit'
- end
-
- context 'when separator is a blank line' do
- let(:commit_message) { "A B C\n\nMore details." }
-
- it_behaves_like 'a valid commit'
- end
-
- context 'when separator is missing' do
- let(:commit_message) { "A B C\nMore details." }
-
- it 'adds a problem' do
- expect(commit_linter).to receive(:add_problem).with(:separator_missing)
-
- commit_linter.lint
- end
- end
- end
-
- describe 'details' do
- context 'when details are valid' do
- let(:commit_message) { "A B C\n\nMore details." }
-
- it_behaves_like 'a valid commit'
- end
-
- context 'when no details are given and many files are changed' do
- let(:total_files_changed) { described_class::MAX_CHANGED_FILES_IN_COMMIT + 1 }
-
- it_behaves_like 'a valid commit'
- end
-
- context 'when no details are given and many lines are changed' do
- let(:total_lines_changed) { described_class::MAX_CHANGED_LINES_IN_COMMIT + 1 }
-
- it_behaves_like 'a valid commit'
- end
-
- context 'when no details are given and many files and lines are changed' do
- let(:total_files_changed) { described_class::MAX_CHANGED_FILES_IN_COMMIT + 1 }
- let(:total_lines_changed) { described_class::MAX_CHANGED_LINES_IN_COMMIT + 1 }
-
- it 'adds a problem' do
- expect(commit_linter).to receive(:add_problem).with(:details_too_many_changes)
-
- commit_linter.lint
- end
- end
-
- context 'when details exceeds the max line length' do
- let(:commit_message) { "A B C\n\n" + 'D' * (described_class::MAX_LINE_LENGTH + 1) }
-
- it 'adds a problem' do
- expect(commit_linter).to receive(:add_problem).with(:details_line_too_long)
-
- commit_linter.lint
- end
- end
-
- context 'when details exceeds the max line length including URLs' do
- let(:commit_message) do
- "A B C\n\nsome message with https://example.com and https://gitlab.com" + 'D' * described_class::MAX_LINE_LENGTH
- end
-
- it_behaves_like 'a valid commit'
- end
- end
-
- describe 'message' do
- context 'when message includes a text emoji' do
- let(:commit_message) { "A commit message :+1:" }
-
- it 'adds a problem' do
- expect(commit_linter).to receive(:add_problem).with(:message_contains_text_emoji)
-
- commit_linter.lint
- end
- end
-
- context 'when message includes a unicode emoji' do
- let(:commit_message) { "A commit message 🚀" }
-
- it 'adds a problem' do
- expect(commit_linter).to receive(:add_problem).with(:message_contains_unicode_emoji)
-
- commit_linter.lint
- end
- end
-
- context 'when message includes a value that is surrounded by backticks' do
- let(:commit_message) { "A commit message `%20`" }
-
- it 'does not add a problem' do
- expect(commit_linter).not_to receive(:add_problem)
-
- commit_linter.lint
- end
- end
-
- context 'when message includes a short reference' do
- [
- 'A commit message to fix #1234',
- 'A commit message to fix !1234',
- 'A commit message to fix &1234',
- 'A commit message to fix %1234',
- 'A commit message to fix gitlab#1234',
- 'A commit message to fix gitlab!1234',
- 'A commit message to fix gitlab&1234',
- 'A commit message to fix gitlab%1234',
- 'A commit message to fix gitlab-org/gitlab#1234',
- 'A commit message to fix gitlab-org/gitlab!1234',
- 'A commit message to fix gitlab-org/gitlab&1234',
- 'A commit message to fix gitlab-org/gitlab%1234',
- 'A commit message to fix "gitlab-org/gitlab%1234"',
- 'A commit message to fix `gitlab-org/gitlab%1234'
- ].each do |message|
- let(:commit_message) { message }
-
- it 'adds a problem' do
- expect(commit_linter).to receive(:add_problem).with(:message_contains_short_reference)
-
- commit_linter.lint
- end
- end
- end
- end
- end
-end
diff --git a/spec/tooling/danger/danger_spec_helper.rb b/spec/tooling/danger/danger_spec_helper.rb
deleted file mode 100644
index b1e84b3c13d..00000000000
--- a/spec/tooling/danger/danger_spec_helper.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-module DangerSpecHelper
- def new_fake_danger
- Class.new do
- attr_reader :git, :gitlab, :helper
-
- # rubocop:disable Gitlab/ModuleWithInstanceVariables
- def initialize(git: nil, gitlab: nil, helper: nil)
- @git = git
- @gitlab = gitlab
- @helper = helper
- end
- # rubocop:enable Gitlab/ModuleWithInstanceVariables
- end
- end
-end
diff --git a/spec/tooling/danger/emoji_checker_spec.rb b/spec/tooling/danger/emoji_checker_spec.rb
deleted file mode 100644
index bbd957b3d00..00000000000
--- a/spec/tooling/danger/emoji_checker_spec.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-require 'rspec-parameterized'
-
-require_relative '../../../tooling/danger/emoji_checker'
-
-RSpec.describe Tooling::Danger::EmojiChecker do
- using RSpec::Parameterized::TableSyntax
-
- describe '#includes_text_emoji?' do
- where(:text, :includes_emoji) do
- 'Hello World!' | false
- ':+1:' | true
- 'Hello World! :+1:' | true
- end
-
- with_them do
- it 'is true when text includes a text emoji' do
- expect(subject.includes_text_emoji?(text)).to be(includes_emoji)
- end
- end
- end
-
- describe '#includes_unicode_emoji?' do
- where(:text, :includes_emoji) do
- 'Hello World!' | false
- '🚀' | true
- 'Hello World! 🚀' | true
- end
-
- with_them do
- it 'is true when text includes a text emoji' do
- expect(subject.includes_unicode_emoji?(text)).to be(includes_emoji)
- end
- end
- end
-end
diff --git a/spec/tooling/danger/feature_flag_spec.rb b/spec/tooling/danger/feature_flag_spec.rb
index db63116cc37..5e495cd43c6 100644
--- a/spec/tooling/danger/feature_flag_spec.rb
+++ b/spec/tooling/danger/feature_flag_spec.rb
@@ -1,29 +1,16 @@
# frozen_string_literal: true
-require_relative 'danger_spec_helper'
+require 'gitlab-dangerfiles'
+require 'gitlab/dangerfiles/spec_helper'
require_relative '../../../tooling/danger/feature_flag'
RSpec.describe Tooling::Danger::FeatureFlag do
- include DangerSpecHelper
+ include_context "with dangerfile"
- let(:added_files) { nil }
- let(:modified_files) { nil }
- let(:deleted_files) { nil }
- let(:fake_git) { double('fake-git', added_files: added_files, modified_files: modified_files, deleted_files: deleted_files) }
+ let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) }
- let(:mr_labels) { nil }
- let(:mr_json) { nil }
- let(:fake_gitlab) { double('fake-gitlab', mr_labels: mr_labels, mr_json: mr_json) }
-
- let(:changes_by_category) { nil }
- let(:sanitize_mr_title) { nil }
- let(:ee?) { false }
- let(:fake_helper) { double('fake-helper', changes_by_category: changes_by_category, sanitize_mr_title: sanitize_mr_title, ee?: ee?) }
-
- let(:fake_danger) { new_fake_danger.include(described_class) }
-
- subject(:feature_flag) { fake_danger.new(git: fake_git, gitlab: fake_gitlab, helper: fake_helper) }
+ subject(:feature_flag) { fake_danger.new(git: fake_git) }
describe '#feature_flag_files' do
let(:feature_flag_files) do
diff --git a/spec/tooling/danger/helper_spec.rb b/spec/tooling/danger/helper_spec.rb
deleted file mode 100644
index c338d138352..00000000000
--- a/spec/tooling/danger/helper_spec.rb
+++ /dev/null
@@ -1,682 +0,0 @@
-# frozen_string_literal: true
-
-require 'fast_spec_helper'
-require 'rspec-parameterized'
-require_relative 'danger_spec_helper'
-
-require_relative '../../../tooling/danger/helper'
-
-RSpec.describe Tooling::Danger::Helper do
- using RSpec::Parameterized::TableSyntax
- include DangerSpecHelper
-
- let(:fake_git) { double('fake-git') }
-
- let(:mr_author) { nil }
- let(:fake_gitlab) { double('fake-gitlab', mr_author: mr_author) }
-
- let(:fake_danger) { new_fake_danger.include(described_class) }
-
- subject(:helper) { fake_danger.new(git: fake_git, gitlab: fake_gitlab) }
-
- describe '#gitlab_helper' do
- context 'when gitlab helper is not available' do
- let(:fake_gitlab) { nil }
-
- it 'returns nil' do
- expect(helper.gitlab_helper).to be_nil
- end
- end
-
- context 'when gitlab helper is available' do
- it 'returns the gitlab helper' do
- expect(helper.gitlab_helper).to eq(fake_gitlab)
- end
- end
-
- context 'when danger gitlab plugin is not available' do
- it 'returns nil' do
- invalid_danger = Class.new do
- include Tooling::Danger::Helper
- end.new
-
- expect(invalid_danger.gitlab_helper).to be_nil
- end
- end
- end
-
- describe '#release_automation?' do
- context 'when gitlab helper is not available' do
- it 'returns false' do
- expect(helper.release_automation?).to be_falsey
- end
- end
-
- context 'when gitlab helper is available' do
- context "but the MR author isn't the RELEASE_TOOLS_BOT" do
- let(:mr_author) { 'johnmarston' }
-
- it 'returns false' do
- expect(helper.release_automation?).to be_falsey
- end
- end
-
- context 'and the MR author is the RELEASE_TOOLS_BOT' do
- let(:mr_author) { described_class::RELEASE_TOOLS_BOT }
-
- it 'returns true' do
- expect(helper.release_automation?).to be_truthy
- end
- end
- end
- end
-
- describe '#all_changed_files' do
- subject { helper.all_changed_files }
-
- it 'interprets a list of changes from the danger git plugin' do
- expect(fake_git).to receive(:added_files) { %w[a b c.old] }
- expect(fake_git).to receive(:modified_files) { %w[d e] }
- expect(fake_git)
- .to receive(:renamed_files)
- .at_least(:once)
- .and_return([{ before: 'c.old', after: 'c.new' }])
-
- is_expected.to contain_exactly('a', 'b', 'c.new', 'd', 'e')
- end
- end
-
- describe '#changed_lines' do
- subject { helper.changed_lines('changed_file.rb') }
-
- before do
- allow(fake_git).to receive(:diff_for_file).with('changed_file.rb').and_return(diff)
- end
-
- context 'when file has diff' do
- let(:diff) { double(:diff, patch: "+ # New change here\n+ # New change there") }
-
- it 'returns file changes' do
- is_expected.to eq(['+ # New change here', '+ # New change there'])
- end
- end
-
- context 'when file has no diff (renamed without changes)' do
- let(:diff) { nil }
-
- it 'returns a blank array' do
- is_expected.to eq([])
- end
- end
- end
-
- describe "changed_files" do
- it 'returns list of changed files matching given regex' do
- expect(helper).to receive(:all_changed_files).and_return(%w[migration.rb usage_data.rb])
-
- expect(helper.changed_files(/usage_data/)).to contain_exactly('usage_data.rb')
- end
- end
-
- describe '#all_ee_changes' do
- subject { helper.all_ee_changes }
-
- it 'returns all changed files starting with ee/' do
- expect(helper).to receive(:all_changed_files).and_return(%w[fr/ee/beer.rb ee/wine.rb ee/lib/ido.rb ee.k])
-
- is_expected.to match_array(%w[ee/wine.rb ee/lib/ido.rb])
- end
- end
-
- describe '#ee?' do
- subject { helper.ee? }
-
- it 'returns true if CI_PROJECT_NAME if set to gitlab' do
- stub_env('CI_PROJECT_NAME', 'gitlab')
- expect(Dir).not_to receive(:exist?)
-
- is_expected.to be_truthy
- end
-
- it 'delegates to CHANGELOG-EE.md existence if CI_PROJECT_NAME is set to something else' do
- stub_env('CI_PROJECT_NAME', 'something else')
- expect(Dir).to receive(:exist?).with(File.expand_path('../../../../ee', __dir__)) { true }
-
- is_expected.to be_truthy
- end
-
- it 'returns true if ee exists' do
- stub_env('CI_PROJECT_NAME', nil)
- expect(Dir).to receive(:exist?).with(File.expand_path('../../../../ee', __dir__)) { true }
-
- is_expected.to be_truthy
- end
-
- it "returns false if ee doesn't exist" do
- stub_env('CI_PROJECT_NAME', nil)
- expect(Dir).to receive(:exist?).with(File.expand_path('../../../../ee', __dir__)) { false }
-
- is_expected.to be_falsy
- end
- end
-
- describe '#project_name' do
- subject { helper.project_name }
-
- it 'returns gitlab if ee? returns true' do
- expect(helper).to receive(:ee?) { true }
-
- is_expected.to eq('gitlab')
- end
-
- it 'returns gitlab-ce if ee? returns false' do
- expect(helper).to receive(:ee?) { false }
-
- is_expected.to eq('gitlab-foss')
- end
- end
-
- describe '#markdown_list' do
- it 'creates a markdown list of items' do
- items = %w[a b]
-
- expect(helper.markdown_list(items)).to eq("* `a`\n* `b`")
- end
-
- it 'wraps items in <details> when there are more than 10 items' do
- items = ('a'..'k').to_a
-
- expect(helper.markdown_list(items)).to match(%r{<details>[^<]+</details>})
- end
- end
-
- describe '#changes_by_category' do
- it 'categorizes changed files' do
- expect(fake_git).to receive(:added_files) { %w[foo foo.md foo.rb foo.js db/migrate/foo lib/gitlab/database/foo.rb qa/foo ee/changelogs/foo.yml] }
- allow(fake_git).to receive(:modified_files) { [] }
- allow(fake_git).to receive(:renamed_files) { [] }
-
- expect(helper.changes_by_category).to eq(
- backend: %w[foo.rb],
- database: %w[db/migrate/foo lib/gitlab/database/foo.rb],
- frontend: %w[foo.js],
- none: %w[ee/changelogs/foo.yml foo.md],
- qa: %w[qa/foo],
- unknown: %w[foo]
- )
- end
- end
-
- describe '#categories_for_file' do
- before do
- allow(fake_git).to receive(:diff_for_file).with('usage_data.rb') { double(:diff, patch: "+ count(User.active)") }
- end
-
- where(:path, :expected_categories) do
- 'usage_data.rb' | [:database, :backend]
- 'doc/foo.md' | [:docs]
- 'CONTRIBUTING.md' | [:docs]
- 'LICENSE' | [:docs]
- 'MAINTENANCE.md' | [:docs]
- 'PHILOSOPHY.md' | [:docs]
- 'PROCESS.md' | [:docs]
- 'README.md' | [:docs]
-
- 'ee/doc/foo' | [:unknown]
- 'ee/README' | [:unknown]
-
- 'app/assets/foo' | [:frontend]
- 'app/views/foo' | [:frontend]
- 'public/foo' | [:frontend]
- 'scripts/frontend/foo' | [:frontend]
- 'spec/javascripts/foo' | [:frontend]
- 'spec/frontend/bar' | [:frontend]
- 'vendor/assets/foo' | [:frontend]
- 'babel.config.js' | [:frontend]
- 'jest.config.js' | [:frontend]
- 'package.json' | [:frontend]
- 'yarn.lock' | [:frontend]
- 'config/foo.js' | [:frontend]
- 'config/deep/foo.js' | [:frontend]
-
- 'ee/app/assets/foo' | [:frontend]
- 'ee/app/views/foo' | [:frontend]
- 'ee/spec/javascripts/foo' | [:frontend]
- 'ee/spec/frontend/bar' | [:frontend]
-
- '.gitlab/ci/frontend.gitlab-ci.yml' | %i[frontend engineering_productivity]
-
- 'app/models/foo' | [:backend]
- 'bin/foo' | [:backend]
- 'config/foo' | [:backend]
- 'lib/foo' | [:backend]
- 'rubocop/foo' | [:backend]
- '.rubocop.yml' | [:backend]
- '.rubocop_todo.yml' | [:backend]
- '.rubocop_manual_todo.yml' | [:backend]
- 'spec/foo' | [:backend]
- 'spec/foo/bar' | [:backend]
-
- 'ee/app/foo' | [:backend]
- 'ee/bin/foo' | [:backend]
- 'ee/spec/foo' | [:backend]
- 'ee/spec/foo/bar' | [:backend]
-
- 'spec/features/foo' | [:test]
- 'ee/spec/features/foo' | [:test]
- 'spec/support/shared_examples/features/foo' | [:test]
- 'ee/spec/support/shared_examples/features/foo' | [:test]
- 'spec/support/shared_contexts/features/foo' | [:test]
- 'ee/spec/support/shared_contexts/features/foo' | [:test]
- 'spec/support/helpers/features/foo' | [:test]
- 'ee/spec/support/helpers/features/foo' | [:test]
-
- 'generator_templates/foo' | [:backend]
- 'vendor/languages.yml' | [:backend]
- 'file_hooks/examples/' | [:backend]
-
- 'Gemfile' | [:backend]
- 'Gemfile.lock' | [:backend]
- 'Rakefile' | [:backend]
- 'FOO_VERSION' | [:backend]
-
- 'Dangerfile' | [:engineering_productivity]
- 'danger/commit_messages/Dangerfile' | [:engineering_productivity]
- 'ee/danger/commit_messages/Dangerfile' | [:engineering_productivity]
- 'danger/commit_messages/' | [:engineering_productivity]
- 'ee/danger/commit_messages/' | [:engineering_productivity]
- '.gitlab-ci.yml' | [:engineering_productivity]
- '.gitlab/ci/cng.gitlab-ci.yml' | [:engineering_productivity]
- '.gitlab/ci/ee-specific-checks.gitlab-ci.yml' | [:engineering_productivity]
- 'scripts/foo' | [:engineering_productivity]
- 'tooling/danger/foo' | [:engineering_productivity]
- 'ee/tooling/danger/foo' | [:engineering_productivity]
- 'lefthook.yml' | [:engineering_productivity]
- '.editorconfig' | [:engineering_productivity]
- 'tooling/bin/find_foss_tests' | [:engineering_productivity]
- '.codeclimate.yml' | [:engineering_productivity]
- '.gitlab/CODEOWNERS' | [:engineering_productivity]
-
- 'lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml' | [:ci_template]
- 'lib/gitlab/ci/templates/dotNET-Core.yml' | [:ci_template]
-
- 'ee/FOO_VERSION' | [:unknown]
-
- 'db/schema.rb' | [:database]
- 'db/structure.sql' | [:database]
- 'db/migrate/foo' | [:database]
- 'db/post_migrate/foo' | [:database]
- 'ee/db/migrate/foo' | [:database]
- 'ee/db/post_migrate/foo' | [:database]
- 'ee/db/geo/migrate/foo' | [:database]
- 'ee/db/geo/post_migrate/foo' | [:database]
- 'app/models/project_authorization.rb' | [:database]
- 'app/services/users/refresh_authorized_projects_service.rb' | [:database]
- 'lib/gitlab/background_migration.rb' | [:database]
- 'lib/gitlab/background_migration/foo' | [:database]
- 'ee/lib/gitlab/background_migration/foo' | [:database]
- 'lib/gitlab/database.rb' | [:database]
- 'lib/gitlab/database/foo' | [:database]
- 'ee/lib/gitlab/database/foo' | [:database]
- 'lib/gitlab/github_import.rb' | [:database]
- 'lib/gitlab/github_import/foo' | [:database]
- 'lib/gitlab/sql/foo' | [:database]
- 'rubocop/cop/migration/foo' | [:database]
-
- 'db/fixtures/foo.rb' | [:backend]
- 'ee/db/fixtures/foo.rb' | [:backend]
- 'doc/api/graphql/reference/gitlab_schema.graphql' | [:backend]
- 'doc/api/graphql/reference/gitlab_schema.json' | [:backend]
-
- 'qa/foo' | [:qa]
- 'ee/qa/foo' | [:qa]
-
- 'changelogs/foo' | [:none]
- 'ee/changelogs/foo' | [:none]
- 'locale/gitlab.pot' | [:none]
-
- 'FOO' | [:unknown]
- 'foo' | [:unknown]
-
- 'foo/bar.rb' | [:backend]
- 'foo/bar.js' | [:frontend]
- 'foo/bar.txt' | [:none]
- 'foo/bar.md' | [:none]
- end
-
- with_them do
- subject { helper.categories_for_file(path) }
-
- it { is_expected.to eq(expected_categories) }
- end
-
- context 'having specific changes' do
- where(:expected_categories, :patch, :changed_files) do
- [:database, :backend] | '+ count(User.active)' | ['usage_data.rb', 'lib/gitlab/usage_data.rb', 'ee/lib/ee/gitlab/usage_data.rb']
- [:database, :backend] | '+ estimate_batch_distinct_count(User.active)' | ['usage_data.rb']
- [:backend] | '+ alt_usage_data(User.active)' | ['usage_data.rb']
- [:backend] | '+ count(User.active)' | ['user.rb']
- [:backend] | '+ count(User.active)' | ['usage_data/topology.rb']
- [:backend] | '+ foo_count(User.active)' | ['usage_data.rb']
- end
-
- with_them do
- it 'has the correct categories' do
- changed_files.each do |file|
- allow(fake_git).to receive(:diff_for_file).with(file) { double(:diff, patch: patch) }
-
- expect(helper.categories_for_file(file)).to eq(expected_categories)
- end
- end
- end
- end
- end
-
- describe '#label_for_category' do
- where(:category, :expected_label) do
- :backend | '~backend'
- :database | '~database'
- :docs | '~documentation'
- :foo | '~foo'
- :frontend | '~frontend'
- :none | ''
- :qa | '~QA'
- :engineering_productivity | '~"Engineering Productivity" for CI, Danger'
- :ci_template | '~"ci::templates"'
- end
-
- with_them do
- subject { helper.label_for_category(category) }
-
- it { is_expected.to eq(expected_label) }
- end
- end
-
- describe '#new_teammates' do
- it 'returns an array of Teammate' do
- usernames = %w[filipa iamphil]
-
- teammates = helper.new_teammates(usernames)
-
- expect(teammates.map(&:username)).to eq(usernames)
- end
- end
-
- describe '#mr_title' do
- it 'returns "" when `gitlab_helper` is unavailable' do
- expect(helper).to receive(:gitlab_helper).and_return(nil)
-
- expect(helper.mr_title).to eq('')
- end
-
- it 'returns the MR title when `gitlab_helper` is available' do
- mr_title = 'My MR title'
- expect(fake_gitlab).to receive(:mr_json)
- .and_return('title' => mr_title)
-
- expect(helper.mr_title).to eq(mr_title)
- end
- end
-
- describe '#mr_web_url' do
- it 'returns "" when `gitlab_helper` is unavailable' do
- expect(helper).to receive(:gitlab_helper).and_return(nil)
-
- expect(helper.mr_web_url).to eq('')
- end
-
- it 'returns the MR web_url when `gitlab_helper` is available' do
- mr_web_url = 'https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1'
- expect(fake_gitlab).to receive(:mr_json)
- .and_return('web_url' => mr_web_url)
-
- expect(helper.mr_web_url).to eq(mr_web_url)
- end
- end
-
- describe '#mr_target_branch' do
- it 'returns "" when `gitlab_helper` is unavailable' do
- expect(helper).to receive(:gitlab_helper).and_return(nil)
-
- expect(helper.mr_target_branch).to eq('')
- end
-
- it 'returns the MR web_url when `gitlab_helper` is available' do
- mr_target_branch = 'main'
- expect(fake_gitlab).to receive(:mr_json)
- .and_return('target_branch' => mr_target_branch)
-
- expect(helper.mr_target_branch).to eq(mr_target_branch)
- end
- end
-
- describe '#security_mr?' do
- it 'returns false when on a normal merge request' do
- expect(fake_gitlab).to receive(:mr_json)
- .and_return('web_url' => 'https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1')
-
- expect(helper).not_to be_security_mr
- end
-
- it 'returns true when on a security merge request' do
- expect(fake_gitlab).to receive(:mr_json)
- .and_return('web_url' => 'https://gitlab.com/gitlab-org/security/gitlab/-/merge_requests/1')
-
- expect(helper).to be_security_mr
- end
- end
-
- describe '#draft_mr?' do
- it 'returns true for a draft MR' do
- expect(fake_gitlab).to receive(:mr_json)
- .and_return('title' => 'Draft: My MR title')
-
- expect(helper).to be_draft_mr
- end
-
- it 'returns false for non draft MR' do
- expect(fake_gitlab).to receive(:mr_json)
- .and_return('title' => 'My MR title')
-
- expect(helper).not_to be_draft_mr
- end
- end
-
- describe '#cherry_pick_mr?' do
- context 'when MR title does not mention a cherry-pick' do
- it 'returns false' do
- expect(fake_gitlab).to receive(:mr_json)
- .and_return('title' => 'Add feature xyz')
-
- expect(helper).not_to be_cherry_pick_mr
- end
- end
-
- context 'when MR title mentions a cherry-pick' do
- [
- 'Cherry Pick !1234',
- 'cherry-pick !1234',
- 'CherryPick !1234'
- ].each do |mr_title|
- it 'returns true' do
- expect(fake_gitlab).to receive(:mr_json)
- .and_return('title' => mr_title)
-
- expect(helper).to be_cherry_pick_mr
- end
- end
- end
- end
-
- describe '#run_all_rspec_mr?' do
- context 'when MR title does not mention RUN ALL RSPEC' do
- it 'returns false' do
- expect(fake_gitlab).to receive(:mr_json)
- .and_return('title' => 'Add feature xyz')
-
- expect(helper).not_to be_run_all_rspec_mr
- end
- end
-
- context 'when MR title mentions RUN ALL RSPEC' do
- it 'returns true' do
- expect(fake_gitlab).to receive(:mr_json)
- .and_return('title' => 'Add feature xyz RUN ALL RSPEC')
-
- expect(helper).to be_run_all_rspec_mr
- end
- end
- end
-
- describe '#run_as_if_foss_mr?' do
- context 'when MR title does not mention RUN AS-IF-FOSS' do
- it 'returns false' do
- expect(fake_gitlab).to receive(:mr_json)
- .and_return('title' => 'Add feature xyz')
-
- expect(helper).not_to be_run_as_if_foss_mr
- end
- end
-
- context 'when MR title mentions RUN AS-IF-FOSS' do
- it 'returns true' do
- expect(fake_gitlab).to receive(:mr_json)
- .and_return('title' => 'Add feature xyz RUN AS-IF-FOSS')
-
- expect(helper).to be_run_as_if_foss_mr
- end
- end
- end
-
- describe '#stable_branch?' do
- it 'returns false when `gitlab_helper` is unavailable' do
- expect(helper).to receive(:gitlab_helper).and_return(nil)
-
- expect(helper).not_to be_stable_branch
- end
-
- context 'when MR target branch is not a stable branch' do
- it 'returns false' do
- expect(fake_gitlab).to receive(:mr_json)
- .and_return('target_branch' => 'my-feature-branch')
-
- expect(helper).not_to be_stable_branch
- end
- end
-
- context 'when MR target branch is a stable branch' do
- %w[
- 13-1-stable-ee
- 13-1-stable-ee-patch-1
- ].each do |target_branch|
- it 'returns true' do
- expect(fake_gitlab).to receive(:mr_json)
- .and_return('target_branch' => target_branch)
-
- expect(helper).to be_stable_branch
- end
- end
- end
- end
-
- describe '#mr_has_label?' do
- it 'returns false when `gitlab_helper` is unavailable' do
- expect(helper).to receive(:gitlab_helper).and_return(nil)
-
- expect(helper.mr_has_labels?('telemetry')).to be_falsey
- end
-
- context 'when mr has labels' do
- before do
- mr_labels = ['telemetry', 'telemetry::reviewed']
- expect(fake_gitlab).to receive(:mr_labels).and_return(mr_labels)
- end
-
- it 'returns true with a matched label' do
- expect(helper.mr_has_labels?('telemetry')).to be_truthy
- end
-
- it 'returns false with unmatched label' do
- expect(helper.mr_has_labels?('database')).to be_falsey
- end
-
- it 'returns true with an array of labels' do
- expect(helper.mr_has_labels?(['telemetry', 'telemetry::reviewed'])).to be_truthy
- end
-
- it 'returns true with multi arguments with matched labels' do
- expect(helper.mr_has_labels?('telemetry', 'telemetry::reviewed')).to be_truthy
- end
-
- it 'returns false with multi arguments with unmatched labels' do
- expect(helper.mr_has_labels?('telemetry', 'telemetry::non existing')).to be_falsey
- end
- end
- end
-
- describe '#labels_list' do
- let(:labels) { ['telemetry', 'telemetry::reviewed'] }
-
- it 'composes the labels string' do
- expect(helper.labels_list(labels)).to eq('~"telemetry", ~"telemetry::reviewed"')
- end
-
- context 'when passing a separator' do
- it 'composes the labels string with the given separator' do
- expect(helper.labels_list(labels, sep: ' ')).to eq('~"telemetry" ~"telemetry::reviewed"')
- end
- end
-
- it 'returns empty string for empty array' do
- expect(helper.labels_list([])).to eq('')
- end
- end
-
- describe '#prepare_labels_for_mr' do
- it 'composes the labels string' do
- mr_labels = ['telemetry', 'telemetry::reviewed']
-
- expect(helper.prepare_labels_for_mr(mr_labels)).to eq('/label ~"telemetry" ~"telemetry::reviewed"')
- end
-
- it 'returns empty string for empty array' do
- expect(helper.prepare_labels_for_mr([])).to eq('')
- end
- end
-
- describe '#has_ci_changes?' do
- context 'when .gitlab/ci is changed' do
- it 'returns true' do
- expect(helper).to receive(:all_changed_files).and_return(%w[migration.rb .gitlab/ci/test.yml])
-
- expect(helper.has_ci_changes?).to be_truthy
- end
- end
-
- context 'when .gitlab-ci.yml is changed' do
- it 'returns true' do
- expect(helper).to receive(:all_changed_files).and_return(%w[migration.rb .gitlab-ci.yml])
-
- expect(helper.has_ci_changes?).to be_truthy
- end
- end
-
- context 'when neither .gitlab/ci/ or .gitlab-ci.yml is changed' do
- it 'returns false' do
- expect(helper).to receive(:all_changed_files).and_return(%w[migration.rb nested/.gitlab-ci.yml])
-
- expect(helper.has_ci_changes?).to be_falsey
- end
- end
- end
-
- describe '#group_label' do
- it 'returns nil when no group label is present' do
- expect(helper.group_label(%w[foo bar])).to be_nil
- end
-
- it 'returns the group label when a group label is present' do
- expect(helper.group_label(['foo', 'group::source code', 'bar'])).to eq('group::source code')
- end
- end
-end
diff --git a/spec/tooling/danger/merge_request_linter_spec.rb b/spec/tooling/danger/merge_request_linter_spec.rb
deleted file mode 100644
index 3273b6b3d07..00000000000
--- a/spec/tooling/danger/merge_request_linter_spec.rb
+++ /dev/null
@@ -1,54 +0,0 @@
-# frozen_string_literal: true
-
-require 'rspec-parameterized'
-require_relative 'danger_spec_helper'
-
-require_relative '../../../tooling/danger/merge_request_linter'
-
-RSpec.describe Tooling::Danger::MergeRequestLinter do
- using RSpec::Parameterized::TableSyntax
-
- let(:mr_class) do
- Struct.new(:message, :sha, :diff_parent)
- end
-
- let(:mr_title) { 'A B ' + 'C' }
- let(:merge_request) { mr_class.new(mr_title, anything, anything) }
-
- describe '#lint_subject' do
- subject(:mr_linter) { described_class.new(merge_request) }
-
- shared_examples 'a valid mr title' do
- it 'does not have any problem' do
- mr_linter.lint
-
- expect(mr_linter.problems).to be_empty
- end
- end
-
- context 'when subject valid' do
- it_behaves_like 'a valid mr title'
- end
-
- context 'when it is too long' do
- let(:mr_title) { 'A B ' + 'C' * described_class::MAX_LINE_LENGTH }
-
- it 'adds a problem' do
- expect(mr_linter).to receive(:add_problem).with(:subject_too_long, described_class.subject_description)
-
- mr_linter.lint
- end
- end
-
- describe 'using magic mr run options' do
- where(run_option: described_class.mr_run_options_regex.split('|') +
- described_class.mr_run_options_regex.split('|').map! { |x| "[#{x}]" })
-
- with_them do
- let(:mr_title) { run_option + ' A B ' + 'C' * (described_class::MAX_LINE_LENGTH - 5) }
-
- it_behaves_like 'a valid mr title'
- end
- end
- end
-end
diff --git a/spec/tooling/danger/project_helper_spec.rb b/spec/tooling/danger/project_helper_spec.rb
new file mode 100644
index 00000000000..a8fda901b4a
--- /dev/null
+++ b/spec/tooling/danger/project_helper_spec.rb
@@ -0,0 +1,260 @@
+# frozen_string_literal: true
+
+require 'rspec-parameterized'
+require 'gitlab-dangerfiles'
+require 'danger/helper'
+require 'gitlab/dangerfiles/spec_helper'
+
+require_relative '../../../danger/plugins/project_helper'
+
+RSpec.describe Tooling::Danger::ProjectHelper do
+ include_context "with dangerfile"
+
+ let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) }
+ let(:fake_helper) { Danger::Helper.new(project_helper) }
+
+ subject(:project_helper) { fake_danger.new(git: fake_git) }
+
+ before do
+ allow(project_helper).to receive(:helper).and_return(fake_helper)
+ end
+
+ describe '#changes' do
+ it 'returns an array of Change objects' do
+ expect(project_helper.changes).to all(be_an(Gitlab::Dangerfiles::Change))
+ end
+
+ it 'groups changes by change type' do
+ changes = project_helper.changes
+
+ expect(changes.added.files).to eq(added_files)
+ expect(changes.modified.files).to eq(modified_files)
+ expect(changes.deleted.files).to eq(deleted_files)
+ expect(changes.renamed_before.files).to eq([renamed_before_file])
+ expect(changes.renamed_after.files).to eq([renamed_after_file])
+ end
+ end
+
+ describe '#categories_for_file' do
+ using RSpec::Parameterized::TableSyntax
+
+ before do
+ allow(fake_git).to receive(:diff_for_file).with('usage_data.rb') { double(:diff, patch: "+ count(User.active)") }
+ end
+
+ where(:path, :expected_categories) do
+ 'usage_data.rb' | [:database, :backend]
+ 'doc/foo.md' | [:docs]
+ 'CONTRIBUTING.md' | [:docs]
+ 'LICENSE' | [:docs]
+ 'MAINTENANCE.md' | [:docs]
+ 'PHILOSOPHY.md' | [:docs]
+ 'PROCESS.md' | [:docs]
+ 'README.md' | [:docs]
+
+ 'ee/doc/foo' | [:unknown]
+ 'ee/README' | [:unknown]
+
+ 'app/assets/foo' | [:frontend]
+ 'app/views/foo' | [:frontend]
+ 'public/foo' | [:frontend]
+ 'scripts/frontend/foo' | [:frontend]
+ 'spec/javascripts/foo' | [:frontend]
+ 'spec/frontend/bar' | [:frontend]
+ 'vendor/assets/foo' | [:frontend]
+ 'babel.config.js' | [:frontend]
+ 'jest.config.js' | [:frontend]
+ 'package.json' | [:frontend]
+ 'yarn.lock' | [:frontend]
+ 'config/foo.js' | [:frontend]
+ 'config/deep/foo.js' | [:frontend]
+
+ 'ee/app/assets/foo' | [:frontend]
+ 'ee/app/views/foo' | [:frontend]
+ 'ee/spec/javascripts/foo' | [:frontend]
+ 'ee/spec/frontend/bar' | [:frontend]
+
+ '.gitlab/ci/frontend.gitlab-ci.yml' | %i[frontend engineering_productivity]
+
+ 'app/models/foo' | [:backend]
+ 'bin/foo' | [:backend]
+ 'config/foo' | [:backend]
+ 'lib/foo' | [:backend]
+ 'rubocop/foo' | [:backend]
+ '.rubocop.yml' | [:backend]
+ '.rubocop_todo.yml' | [:backend]
+ '.rubocop_manual_todo.yml' | [:backend]
+ 'spec/foo' | [:backend]
+ 'spec/foo/bar' | [:backend]
+
+ 'ee/app/foo' | [:backend]
+ 'ee/bin/foo' | [:backend]
+ 'ee/spec/foo' | [:backend]
+ 'ee/spec/foo/bar' | [:backend]
+
+ 'spec/features/foo' | [:test]
+ 'ee/spec/features/foo' | [:test]
+ 'spec/support/shared_examples/features/foo' | [:test]
+ 'ee/spec/support/shared_examples/features/foo' | [:test]
+ 'spec/support/shared_contexts/features/foo' | [:test]
+ 'ee/spec/support/shared_contexts/features/foo' | [:test]
+ 'spec/support/helpers/features/foo' | [:test]
+ 'ee/spec/support/helpers/features/foo' | [:test]
+
+ 'generator_templates/foo' | [:backend]
+ 'vendor/languages.yml' | [:backend]
+ 'file_hooks/examples/' | [:backend]
+
+ 'Gemfile' | [:backend]
+ 'Gemfile.lock' | [:backend]
+ 'Rakefile' | [:backend]
+ 'FOO_VERSION' | [:backend]
+
+ 'Dangerfile' | [:engineering_productivity]
+ 'danger/commit_messages/Dangerfile' | [:engineering_productivity]
+ 'ee/danger/commit_messages/Dangerfile' | [:engineering_productivity]
+ 'danger/commit_messages/' | [:engineering_productivity]
+ 'ee/danger/commit_messages/' | [:engineering_productivity]
+ '.gitlab-ci.yml' | [:engineering_productivity]
+ '.gitlab/ci/cng.gitlab-ci.yml' | [:engineering_productivity]
+ '.gitlab/ci/ee-specific-checks.gitlab-ci.yml' | [:engineering_productivity]
+ 'scripts/foo' | [:engineering_productivity]
+ 'tooling/danger/foo' | [:engineering_productivity]
+ 'ee/tooling/danger/foo' | [:engineering_productivity]
+ 'lefthook.yml' | [:engineering_productivity]
+ '.editorconfig' | [:engineering_productivity]
+ 'tooling/bin/find_foss_tests' | [:engineering_productivity]
+ '.codeclimate.yml' | [:engineering_productivity]
+ '.gitlab/CODEOWNERS' | [:engineering_productivity]
+
+ 'lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml' | [:ci_template]
+ 'lib/gitlab/ci/templates/dotNET-Core.yml' | [:ci_template]
+
+ 'ee/FOO_VERSION' | [:unknown]
+
+ 'db/schema.rb' | [:database]
+ 'db/structure.sql' | [:database]
+ 'db/migrate/foo' | [:database, :migration]
+ 'db/post_migrate/foo' | [:database, :migration]
+ 'ee/db/geo/migrate/foo' | [:database, :migration]
+ 'ee/db/geo/post_migrate/foo' | [:database, :migration]
+ 'app/models/project_authorization.rb' | [:database]
+ 'app/services/users/refresh_authorized_projects_service.rb' | [:database]
+ 'lib/gitlab/background_migration.rb' | [:database]
+ 'lib/gitlab/background_migration/foo' | [:database]
+ 'ee/lib/gitlab/background_migration/foo' | [:database]
+ 'lib/gitlab/database.rb' | [:database]
+ 'lib/gitlab/database/foo' | [:database]
+ 'ee/lib/gitlab/database/foo' | [:database]
+ 'lib/gitlab/github_import.rb' | [:database]
+ 'lib/gitlab/github_import/foo' | [:database]
+ 'lib/gitlab/sql/foo' | [:database]
+ 'rubocop/cop/migration/foo' | [:database]
+
+ 'db/fixtures/foo.rb' | [:backend]
+ 'ee/db/fixtures/foo.rb' | [:backend]
+
+ 'qa/foo' | [:qa]
+ 'ee/qa/foo' | [:qa]
+
+ 'changelogs/foo' | [:none]
+ 'ee/changelogs/foo' | [:none]
+ 'locale/gitlab.pot' | [:none]
+
+ 'FOO' | [:unknown]
+ 'foo' | [:unknown]
+
+ 'foo/bar.rb' | [:backend]
+ 'foo/bar.js' | [:frontend]
+ 'foo/bar.txt' | [:none]
+ 'foo/bar.md' | [:none]
+ end
+
+ with_them do
+ subject { project_helper.categories_for_file(path) }
+
+ it { is_expected.to eq(expected_categories) }
+ end
+
+ context 'having specific changes' do
+ where(:expected_categories, :patch, :changed_files) do
+ [:database, :backend] | '+ count(User.active)' | ['usage_data.rb', 'lib/gitlab/usage_data.rb', 'ee/lib/ee/gitlab/usage_data.rb']
+ [:database, :backend] | '+ estimate_batch_distinct_count(User.active)' | ['usage_data.rb']
+ [:backend] | '+ alt_usage_data(User.active)' | ['usage_data.rb']
+ [:backend] | '+ count(User.active)' | ['user.rb']
+ [:backend] | '+ count(User.active)' | ['usage_data/topology.rb']
+ [:backend] | '+ foo_count(User.active)' | ['usage_data.rb']
+ end
+
+ with_them do
+ it 'has the correct categories' do
+ changed_files.each do |file|
+ allow(fake_git).to receive(:diff_for_file).with(file) { double(:diff, patch: patch) }
+
+ expect(project_helper.categories_for_file(file)).to eq(expected_categories)
+ end
+ end
+ end
+ 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: changes_size, commit_messages, database, documentation, duplicate_yarn_dependencies, eslint, karma, pajamas, pipeline, prettier, product_intelligence, utility_css')
+ 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 '#all_ee_changes' do
+ subject { project_helper.all_ee_changes }
+
+ it 'returns all changed files starting with ee/' do
+ expect(fake_helper).to receive(:all_changed_files).and_return(%w[fr/ee/beer.rb ee/wine.rb ee/lib/ido.rb ee.k])
+
+ is_expected.to match_array(%w[ee/wine.rb ee/lib/ido.rb])
+ end
+ end
+
+ describe '#project_name' do
+ subject { project_helper.project_name }
+
+ it 'returns gitlab if ee? returns true' do
+ expect(project_helper).to receive(:ee?) { true }
+
+ is_expected.to eq('gitlab')
+ end
+
+ it 'returns gitlab-ce if ee? returns false' do
+ expect(project_helper).to receive(:ee?) { false }
+
+ is_expected.to eq('gitlab-foss')
+ end
+ end
+end
diff --git a/spec/tooling/danger/roulette_spec.rb b/spec/tooling/danger/roulette_spec.rb
deleted file mode 100644
index 1e500a1ed08..00000000000
--- a/spec/tooling/danger/roulette_spec.rb
+++ /dev/null
@@ -1,429 +0,0 @@
-# frozen_string_literal: true
-
-require 'webmock/rspec'
-require 'timecop'
-
-require_relative '../../../tooling/danger/roulette'
-require 'active_support/testing/time_helpers'
-
-RSpec.describe Tooling::Danger::Roulette do
- include ActiveSupport::Testing::TimeHelpers
-
- around do |example|
- travel_to(Time.utc(2020, 06, 22, 10)) { example.run }
- end
-
- let(:backend_available) { true }
- let(:backend_tz_offset_hours) { 2.0 }
- let(:backend_maintainer) do
- Tooling::Danger::Teammate.new(
- 'username' => 'backend-maintainer',
- 'name' => 'Backend maintainer',
- 'role' => 'Backend engineer',
- 'projects' => { 'gitlab' => 'maintainer backend' },
- 'available' => backend_available,
- 'tz_offset_hours' => backend_tz_offset_hours
- )
- end
-
- let(:frontend_reviewer) do
- Tooling::Danger::Teammate.new(
- 'username' => 'frontend-reviewer',
- 'name' => 'Frontend reviewer',
- 'role' => 'Frontend engineer',
- 'projects' => { 'gitlab' => 'reviewer frontend' },
- 'available' => true,
- 'tz_offset_hours' => 2.0
- )
- end
-
- let(:frontend_maintainer) do
- Tooling::Danger::Teammate.new(
- 'username' => 'frontend-maintainer',
- 'name' => 'Frontend maintainer',
- 'role' => 'Frontend engineer',
- 'projects' => { 'gitlab' => "maintainer frontend" },
- 'available' => true,
- 'tz_offset_hours' => 2.0
- )
- end
-
- let(:software_engineer_in_test) do
- Tooling::Danger::Teammate.new(
- 'username' => 'software-engineer-in-test',
- 'name' => 'Software Engineer in Test',
- 'role' => 'Software Engineer in Test, Create:Source Code',
- 'projects' => { 'gitlab' => 'maintainer qa', 'gitlab-qa' => 'maintainer' },
- 'available' => true,
- 'tz_offset_hours' => 2.0
- )
- end
-
- let(:engineering_productivity_reviewer) do
- Tooling::Danger::Teammate.new(
- 'username' => 'eng-prod-reviewer',
- 'name' => 'EP engineer',
- 'role' => 'Engineering Productivity',
- 'projects' => { 'gitlab' => 'reviewer backend' },
- 'available' => true,
- 'tz_offset_hours' => 2.0
- )
- end
-
- let(:ci_template_reviewer) do
- Tooling::Danger::Teammate.new(
- 'username' => 'ci-template-maintainer',
- 'name' => 'CI Template engineer',
- 'role' => '~"ci::templates"',
- 'projects' => { 'gitlab' => 'reviewer ci_template' },
- 'available' => true,
- 'tz_offset_hours' => 2.0
- )
- end
-
- let(:teammates) do
- [
- backend_maintainer.to_h,
- frontend_maintainer.to_h,
- frontend_reviewer.to_h,
- software_engineer_in_test.to_h,
- engineering_productivity_reviewer.to_h,
- ci_template_reviewer.to_h
- ]
- end
-
- let(:teammate_json) do
- teammates.to_json
- end
-
- subject(:roulette) { Object.new.extend(described_class) }
-
- describe 'Spin#==' do
- it 'compares Spin attributes' do
- spin1 = described_class::Spin.new(:backend, frontend_reviewer, frontend_maintainer, false, false)
- spin2 = described_class::Spin.new(:backend, frontend_reviewer, frontend_maintainer, false, false)
- spin3 = described_class::Spin.new(:backend, frontend_reviewer, frontend_maintainer, false, true)
- spin4 = described_class::Spin.new(:backend, frontend_reviewer, frontend_maintainer, true, false)
- spin5 = described_class::Spin.new(:backend, frontend_reviewer, backend_maintainer, false, false)
- spin6 = described_class::Spin.new(:backend, backend_maintainer, frontend_maintainer, false, false)
- spin7 = described_class::Spin.new(:frontend, frontend_reviewer, frontend_maintainer, false, false)
-
- expect(spin1).to eq(spin2)
- expect(spin1).not_to eq(spin3)
- expect(spin1).not_to eq(spin4)
- expect(spin1).not_to eq(spin5)
- expect(spin1).not_to eq(spin6)
- expect(spin1).not_to eq(spin7)
- end
- end
-
- describe '#spin' do
- let!(:project) { 'gitlab' }
- let!(:mr_source_branch) { 'a-branch' }
- let!(:mr_labels) { ['backend', 'devops::create'] }
- let!(:author) { Tooling::Danger::Teammate.new('username' => 'johndoe') }
- let(:timezone_experiment) { false }
- let(:spins) do
- # Stub the request at the latest time so that we can modify the raw data, e.g. available fields.
- WebMock
- .stub_request(:get, described_class::ROULETTE_DATA_URL)
- .to_return(body: teammate_json)
-
- subject.spin(project, categories, timezone_experiment: timezone_experiment)
- end
-
- before do
- allow(subject).to receive(:mr_author_username).and_return(author.username)
- allow(subject).to receive(:mr_labels).and_return(mr_labels)
- allow(subject).to receive(:mr_source_branch).and_return(mr_source_branch)
- end
-
- context 'when timezone_experiment == false' do
- context 'when change contains backend category' do
- let(:categories) { [:backend] }
-
- it 'assigns backend reviewer and maintainer' do
- expect(spins[0].reviewer).to eq(engineering_productivity_reviewer)
- expect(spins[0].maintainer).to eq(backend_maintainer)
- expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, false)])
- end
-
- context 'when teammate is not available' do
- let(:backend_available) { false }
-
- it 'assigns backend reviewer and no maintainer' do
- expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, nil, false, false)])
- end
- end
- end
-
- context 'when change contains frontend category' do
- let(:categories) { [:frontend] }
-
- it 'assigns frontend reviewer and maintainer' do
- expect(spins).to eq([described_class::Spin.new(:frontend, frontend_reviewer, frontend_maintainer, false, false)])
- end
- end
-
- context 'when change contains many categories' do
- let(:categories) { [:frontend, :test, :qa, :engineering_productivity, :ci_template, :backend] }
-
- it 'has a deterministic sorting order' do
- expect(spins.map(&:category)).to eq categories.sort
- end
- end
-
- context 'when change contains QA category' do
- let(:categories) { [:qa] }
-
- it 'assigns QA maintainer' do
- expect(spins).to eq([described_class::Spin.new(:qa, nil, software_engineer_in_test, false, false)])
- end
- end
-
- context 'when change contains QA category and another category' do
- let(:categories) { [:backend, :qa] }
-
- it 'assigns QA maintainer' do
- expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, false), described_class::Spin.new(:qa, nil, software_engineer_in_test, :maintainer, false)])
- end
-
- context 'and author is an SET' do
- let!(:author) { Tooling::Danger::Teammate.new('username' => software_engineer_in_test.username) }
-
- it 'assigns QA reviewer' do
- expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, false), described_class::Spin.new(:qa, nil, nil, false, false)])
- end
- end
- end
-
- context 'when change contains Engineering Productivity category' do
- let(:categories) { [:engineering_productivity] }
-
- it 'assigns Engineering Productivity reviewer and fallback to backend maintainer' do
- expect(spins).to eq([described_class::Spin.new(:engineering_productivity, engineering_productivity_reviewer, backend_maintainer, false, false)])
- end
- end
-
- context 'when change contains CI/CD Template category' do
- let(:categories) { [:ci_template] }
-
- it 'assigns CI/CD Template reviewer and fallback to backend maintainer' do
- expect(spins).to eq([described_class::Spin.new(:ci_template, ci_template_reviewer, backend_maintainer, false, false)])
- end
- end
-
- context 'when change contains test category' do
- let(:categories) { [:test] }
-
- it 'assigns corresponding SET' do
- expect(spins).to eq([described_class::Spin.new(:test, software_engineer_in_test, nil, :maintainer, false)])
- end
- end
- end
-
- context 'when timezone_experiment == true' do
- let(:timezone_experiment) { true }
-
- context 'when change contains backend category' do
- let(:categories) { [:backend] }
-
- it 'assigns backend reviewer and maintainer' do
- expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, true)])
- end
-
- context 'when teammate is not in a good timezone' do
- let(:backend_tz_offset_hours) { 5.0 }
-
- it 'assigns backend reviewer and no maintainer' do
- expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, nil, false, true)])
- end
- end
- end
-
- context 'when change includes a category with timezone disabled' do
- let(:categories) { [:backend] }
-
- before do
- stub_const("#{described_class}::INCLUDE_TIMEZONE_FOR_CATEGORY", backend: false)
- end
-
- it 'assigns backend reviewer and maintainer' do
- expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, false)])
- end
-
- context 'when teammate is not in a good timezone' do
- let(:backend_tz_offset_hours) { 5.0 }
-
- it 'assigns backend reviewer and maintainer' do
- expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, false)])
- end
- end
- end
- end
- end
-
- RSpec::Matchers.define :match_teammates do |expected|
- match do |actual|
- expected.each do |expected_person|
- actual_person_found = actual.find { |actual_person| actual_person.name == expected_person.username }
-
- actual_person_found &&
- actual_person_found.name == expected_person.name &&
- actual_person_found.role == expected_person.role &&
- actual_person_found.projects == expected_person.projects
- end
- end
- end
-
- describe '#team' do
- subject(:team) { roulette.team }
-
- context 'HTTP failure' do
- before do
- WebMock
- .stub_request(:get, described_class::ROULETTE_DATA_URL)
- .to_return(status: 404)
- end
-
- it 'raises a pretty error' do
- expect { team }.to raise_error(/Failed to read/)
- end
- end
-
- context 'JSON failure' do
- before do
- WebMock
- .stub_request(:get, described_class::ROULETTE_DATA_URL)
- .to_return(body: 'INVALID JSON')
- end
-
- it 'raises a pretty error' do
- expect { team }.to raise_error(/Failed to parse/)
- end
- end
-
- context 'success' do
- before do
- WebMock
- .stub_request(:get, described_class::ROULETTE_DATA_URL)
- .to_return(body: teammate_json)
- end
-
- it 'returns an array of teammates' do
- is_expected.to match_teammates([
- backend_maintainer,
- frontend_reviewer,
- frontend_maintainer,
- software_engineer_in_test,
- engineering_productivity_reviewer,
- ci_template_reviewer
- ])
- end
-
- it 'memoizes the result' do
- expect(team.object_id).to eq(roulette.team.object_id)
- end
- end
- end
-
- describe '#project_team' do
- subject { roulette.project_team('gitlab-qa') }
-
- before do
- WebMock
- .stub_request(:get, described_class::ROULETTE_DATA_URL)
- .to_return(body: teammate_json)
- end
-
- it 'filters team by project_name' do
- is_expected.to match_teammates([
- software_engineer_in_test
- ])
- end
- end
-
- describe '#spin_for_person' do
- let(:person_tz_offset_hours) { 0.0 }
- let(:person1) do
- Tooling::Danger::Teammate.new(
- 'username' => 'user1',
- 'available' => true,
- 'tz_offset_hours' => person_tz_offset_hours
- )
- end
-
- let(:person2) do
- Tooling::Danger::Teammate.new(
- 'username' => 'user2',
- 'available' => true,
- 'tz_offset_hours' => person_tz_offset_hours)
- end
-
- let(:author) do
- Tooling::Danger::Teammate.new(
- 'username' => 'johndoe',
- 'available' => true,
- 'tz_offset_hours' => 0.0)
- end
-
- let(:unavailable) do
- Tooling::Danger::Teammate.new(
- 'username' => 'janedoe',
- 'available' => false,
- 'tz_offset_hours' => 0.0)
- end
-
- before do
- allow(subject).to receive(:mr_author_username).and_return(author.username)
- end
-
- (-4..4).each do |utc_offset|
- context "when local hour for person is #{10 + utc_offset} (offset: #{utc_offset})" do
- let(:person_tz_offset_hours) { utc_offset }
-
- [false, true].each do |timezone_experiment|
- context "with timezone_experiment == #{timezone_experiment}" do
- it 'returns a random person' do
- persons = [person1, person2]
-
- selected = subject.spin_for_person(persons, random: Random.new, timezone_experiment: timezone_experiment)
-
- expect(persons.map(&:username)).to include(selected.username)
- end
- end
- end
- end
- end
-
- ((-12..-5).to_a + (5..12).to_a).each do |utc_offset|
- context "when local hour for person is #{10 + utc_offset} (offset: #{utc_offset})" do
- let(:person_tz_offset_hours) { utc_offset }
-
- [false, true].each do |timezone_experiment|
- context "with timezone_experiment == #{timezone_experiment}" do
- it 'returns a random person or nil' do
- persons = [person1, person2]
-
- selected = subject.spin_for_person(persons, random: Random.new, timezone_experiment: timezone_experiment)
-
- if timezone_experiment
- expect(selected).to be_nil
- else
- expect(persons.map(&:username)).to include(selected.username)
- end
- end
- end
- end
- end
- end
-
- it 'excludes unavailable persons' do
- expect(subject.spin_for_person([unavailable], random: Random.new)).to be_nil
- end
-
- it 'excludes mr.author' do
- expect(subject.spin_for_person([author], random: Random.new)).to be_nil
- end
- end
-end
diff --git a/spec/tooling/danger/sidekiq_queues_spec.rb b/spec/tooling/danger/sidekiq_queues_spec.rb
index c5fc8592621..9bffc7ee93d 100644
--- a/spec/tooling/danger/sidekiq_queues_spec.rb
+++ b/spec/tooling/danger/sidekiq_queues_spec.rb
@@ -1,20 +1,21 @@
# frozen_string_literal: true
require 'rspec-parameterized'
-require_relative 'danger_spec_helper'
+require 'gitlab-dangerfiles'
+require 'gitlab/dangerfiles/spec_helper'
require_relative '../../../tooling/danger/sidekiq_queues'
RSpec.describe Tooling::Danger::SidekiqQueues do
- using RSpec::Parameterized::TableSyntax
- include DangerSpecHelper
+ include_context "with dangerfile"
- let(:fake_git) { double('fake-git') }
- let(:fake_danger) { new_fake_danger.include(described_class) }
+ let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) }
subject(:sidekiq_queues) { fake_danger.new(git: fake_git) }
describe '#changed_queue_files' do
+ using RSpec::Parameterized::TableSyntax
+
where(:modified_files, :changed_queue_files) do
%w(app/workers/all_queues.yml ee/app/workers/all_queues.yml foo) | %w(app/workers/all_queues.yml ee/app/workers/all_queues.yml)
%w(app/workers/all_queues.yml ee/app/workers/all_queues.yml) | %w(app/workers/all_queues.yml ee/app/workers/all_queues.yml)
diff --git a/spec/tooling/danger/teammate_spec.rb b/spec/tooling/danger/teammate_spec.rb
deleted file mode 100644
index f3afdc6e912..00000000000
--- a/spec/tooling/danger/teammate_spec.rb
+++ /dev/null
@@ -1,225 +0,0 @@
-# frozen_string_literal: true
-
-require_relative '../../../tooling/danger/teammate'
-require 'active_support/testing/time_helpers'
-require 'rspec-parameterized'
-
-RSpec.describe Tooling::Danger::Teammate do
- using RSpec::Parameterized::TableSyntax
-
- subject { described_class.new(options) }
-
- let(:tz_offset_hours) { 2.0 }
- let(:options) do
- {
- 'username' => 'luigi',
- 'projects' => projects,
- 'role' => role,
- 'markdown_name' => '[Luigi](https://gitlab.com/luigi) (`@luigi`)',
- 'tz_offset_hours' => tz_offset_hours
- }
- end
-
- let(:capabilities) { ['reviewer backend'] }
- let(:projects) { { project => capabilities } }
- let(:role) { 'Engineer, Manage' }
- let(:labels) { [] }
- let(:project) { double }
-
- describe '#==' do
- it 'compares Teammate username' do
- joe1 = described_class.new('username' => 'joe', 'projects' => projects)
- joe2 = described_class.new('username' => 'joe', 'projects' => [])
- jane1 = described_class.new('username' => 'jane', 'projects' => projects)
- jane2 = described_class.new('username' => 'jane', 'projects' => [])
-
- expect(joe1).to eq(joe2)
- expect(jane1).to eq(jane2)
- expect(jane1).not_to eq(nil)
- expect(described_class.new('username' => nil)).not_to eq(nil)
- end
- end
-
- describe '#to_h' do
- it 'returns the given options' do
- expect(subject.to_h).to eq(options)
- end
- end
-
- context 'when having multiple capabilities' do
- let(:capabilities) { ['reviewer backend', 'maintainer frontend', 'trainee_maintainer qa'] }
-
- it '#any_capability? returns true if the person has any capability for the category in the given project' do
- expect(subject.any_capability?(project, :backend)).to be_truthy
- expect(subject.any_capability?(project, :frontend)).to be_truthy
- expect(subject.any_capability?(project, :qa)).to be_truthy
- expect(subject.any_capability?(project, :engineering_productivity)).to be_falsey
- end
-
- it '#reviewer? supports multiple roles per project' do
- expect(subject.reviewer?(project, :backend, labels)).to be_truthy
- end
-
- it '#traintainer? supports multiple roles per project' do
- expect(subject.traintainer?(project, :qa, labels)).to be_truthy
- end
-
- it '#maintainer? supports multiple roles per project' do
- expect(subject.maintainer?(project, :frontend, labels)).to be_truthy
- end
-
- context 'when labels contain devops::create and the category is test' do
- let(:labels) { ['devops::create'] }
-
- context 'when role is Software Engineer in Test, Create' do
- let(:role) { 'Software Engineer in Test, Create' }
-
- it '#reviewer? returns true' do
- expect(subject.reviewer?(project, :test, labels)).to be_truthy
- end
-
- it '#maintainer? returns false' do
- expect(subject.maintainer?(project, :test, labels)).to be_falsey
- end
-
- context 'when hyperlink is mangled in the role' do
- let(:role) { '<a href="#">Software Engineer in Test</a>, Create' }
-
- it '#reviewer? returns true' do
- expect(subject.reviewer?(project, :test, labels)).to be_truthy
- end
- end
- end
-
- context 'when role is Software Engineer in Test' do
- let(:role) { 'Software Engineer in Test' }
-
- it '#reviewer? returns false' do
- expect(subject.reviewer?(project, :test, labels)).to be_falsey
- end
- end
-
- context 'when role is Software Engineer in Test, Manage' do
- let(:role) { 'Software Engineer in Test, Manage' }
-
- it '#reviewer? returns false' do
- expect(subject.reviewer?(project, :test, labels)).to be_falsey
- end
- end
-
- context 'when role is Backend Engineer, Engineering Productivity' do
- let(:role) { 'Backend Engineer, Engineering Productivity' }
-
- it '#reviewer? returns true' do
- expect(subject.reviewer?(project, :engineering_productivity, labels)).to be_truthy
- end
-
- it '#maintainer? returns false' do
- expect(subject.maintainer?(project, :engineering_productivity, labels)).to be_falsey
- end
-
- context 'when capabilities include maintainer backend' do
- let(:capabilities) { ['maintainer backend'] }
-
- it '#maintainer? returns true' do
- expect(subject.maintainer?(project, :engineering_productivity, labels)).to be_truthy
- end
- end
-
- context 'when capabilities include maintainer engineering productivity' do
- let(:capabilities) { ['maintainer engineering_productivity'] }
-
- it '#maintainer? returns true' do
- expect(subject.maintainer?(project, :engineering_productivity, labels)).to be_truthy
- end
- end
-
- context 'when capabilities include trainee_maintainer backend' do
- let(:capabilities) { ['trainee_maintainer backend'] }
-
- it '#traintainer? returns true' do
- expect(subject.traintainer?(project, :engineering_productivity, labels)).to be_truthy
- end
- end
- end
- end
- end
-
- context 'when having single capability' do
- let(:capabilities) { 'reviewer backend' }
-
- it '#reviewer? supports one role per project' do
- expect(subject.reviewer?(project, :backend, labels)).to be_truthy
- end
-
- it '#traintainer? supports one role per project' do
- expect(subject.traintainer?(project, :database, labels)).to be_falsey
- end
-
- it '#maintainer? supports one role per project' do
- expect(subject.maintainer?(project, :frontend, labels)).to be_falsey
- end
- end
-
- describe '#local_hour' do
- include ActiveSupport::Testing::TimeHelpers
-
- around do |example|
- travel_to(Time.utc(2020, 6, 23, 10)) { example.run }
- end
-
- context 'when author is given' do
- where(:tz_offset_hours, :expected_local_hour) do
- -12 | 22
- -10 | 0
- 2 | 12
- 4 | 14
- 12 | 22
- end
-
- with_them do
- it 'returns the correct local_hour' do
- expect(subject.local_hour).to eq(expected_local_hour)
- end
- end
- end
- end
-
- describe '#markdown_name' do
- it 'returns markdown name with timezone info' do
- expect(subject.markdown_name).to eq("#{options['markdown_name']} (UTC+2)")
- end
-
- context 'when offset is 1.5' do
- let(:tz_offset_hours) { 1.5 }
-
- it 'returns markdown name with timezone info, not truncated' do
- expect(subject.markdown_name).to eq("#{options['markdown_name']} (UTC+1.5)")
- end
- end
-
- context 'when author is given' do
- where(:tz_offset_hours, :author_offset, :diff_text) do
- -12 | -10 | "2 hours behind `@mario`"
- -10 | -12 | "2 hours ahead of `@mario`"
- -10 | 2 | "12 hours behind `@mario`"
- 2 | 4 | "2 hours behind `@mario`"
- 4 | 2 | "2 hours ahead of `@mario`"
- 2 | 3 | "1 hour behind `@mario`"
- 3 | 2 | "1 hour ahead of `@mario`"
- 2 | 2 | "same timezone as `@mario`"
- end
-
- with_them do
- it 'returns markdown name with timezone info' do
- author = described_class.new(options.merge('username' => 'mario', 'tz_offset_hours' => author_offset))
-
- floored_offset_hours = subject.__send__(:floored_offset_hours)
- utc_offset = floored_offset_hours >= 0 ? "+#{floored_offset_hours}" : floored_offset_hours
-
- expect(subject.markdown_name(author: author)).to eq("#{options['markdown_name']} (UTC#{utc_offset}, #{diff_text})")
- end
- end
- end
- end
-end
diff --git a/spec/tooling/danger/title_linting_spec.rb b/spec/tooling/danger/title_linting_spec.rb
deleted file mode 100644
index 7bc1684cd87..00000000000
--- a/spec/tooling/danger/title_linting_spec.rb
+++ /dev/null
@@ -1,91 +0,0 @@
-# frozen_string_literal: true
-
-require 'rspec-parameterized'
-
-require_relative '../../../tooling/danger/title_linting'
-
-RSpec.describe Tooling::Danger::TitleLinting do
- using RSpec::Parameterized::TableSyntax
-
- describe '#sanitize_mr_title' do
- where(:mr_title, :expected_mr_title) do
- '`My MR title`' | "\\`My MR title\\`"
- 'WIP: My MR title' | 'My MR title'
- 'Draft: My MR title' | 'My MR title'
- '(Draft) My MR title' | 'My MR title'
- '[Draft] My MR title' | 'My MR title'
- '[DRAFT] My MR title' | 'My MR title'
- 'DRAFT: My MR title' | 'My MR title'
- 'DRAFT: `My MR title`' | "\\`My MR title\\`"
- end
-
- with_them do
- subject { described_class.sanitize_mr_title(mr_title) }
-
- it { is_expected.to eq(expected_mr_title) }
- end
- end
-
- describe '#remove_draft_flag' do
- where(:mr_title, :expected_mr_title) do
- 'WIP: My MR title' | 'My MR title'
- 'Draft: My MR title' | 'My MR title'
- '(Draft) My MR title' | 'My MR title'
- '[Draft] My MR title' | 'My MR title'
- '[DRAFT] My MR title' | 'My MR title'
- 'DRAFT: My MR title' | 'My MR title'
- end
-
- with_them do
- subject { described_class.remove_draft_flag(mr_title) }
-
- it { is_expected.to eq(expected_mr_title) }
- end
- end
-
- describe '#has_draft_flag?' do
- it 'returns true for a draft title' do
- expect(described_class.has_draft_flag?('Draft: My MR title')).to be true
- end
-
- it 'returns false for non draft title' do
- expect(described_class.has_draft_flag?('My MR title')).to be false
- end
- end
-
- describe '#has_cherry_pick_flag?' do
- [
- 'Cherry Pick !1234',
- 'cherry-pick !1234',
- 'CherryPick !1234'
- ].each do |mr_title|
- it 'returns true for cherry-pick title' do
- expect(described_class.has_cherry_pick_flag?(mr_title)).to be true
- end
- end
-
- it 'returns false for non cherry-pick title' do
- expect(described_class.has_cherry_pick_flag?('My MR title')).to be false
- end
- end
-
- describe '#has_run_all_rspec_flag?' do
- it 'returns true for a title that includes RUN ALL RSPEC' do
- expect(described_class.has_run_all_rspec_flag?('My MR title RUN ALL RSPEC')).to be true
- end
-
- it 'returns true for a title that does not include RUN ALL RSPEC' do
- expect(described_class.has_run_all_rspec_flag?('My MR title')).to be false
- end
- end
-
- describe '#has_run_as_if_foss_flag?' do
- it 'returns true for a title that includes RUN AS-IF-FOSS' do
- expect(described_class.has_run_as_if_foss_flag?('My MR title RUN AS-IF-FOSS')).to be true
- end
-
- it 'returns true for a title that does not include RUN AS-IF-FOSS' do
- expect(described_class.has_run_as_if_foss_flag?('My MR title')).to be false
- end
- end
-end
diff --git a/spec/tooling/danger/weightage/maintainers_spec.rb b/spec/tooling/danger/weightage/maintainers_spec.rb
deleted file mode 100644
index b99ffe706a4..00000000000
--- a/spec/tooling/danger/weightage/maintainers_spec.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-# frozen_string_literal: true
-
-require_relative '../../../../tooling/danger/weightage/maintainers'
-
-RSpec.describe Tooling::Danger::Weightage::Maintainers do
- let(:multiplier) { Tooling::Danger::Weightage::CAPACITY_MULTIPLIER }
- let(:regular_maintainer) { double('Teammate', reduced_capacity: false) }
- let(:reduced_capacity_maintainer) { double('Teammate', reduced_capacity: true) }
- let(:maintainers) do
- [
- regular_maintainer,
- reduced_capacity_maintainer
- ]
- end
-
- let(:maintainer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT * multiplier }
- let(:reduced_capacity_maintainer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT }
-
- subject(:weighted_maintainers) { described_class.new(maintainers).execute }
-
- describe '#execute' do
- it 'weights the maintainers overall' do
- expect(weighted_maintainers.count).to eq maintainer_count + reduced_capacity_maintainer_count
- end
-
- it 'has total count of regular maintainers' do
- expect(weighted_maintainers.count { |r| r.object_id == regular_maintainer.object_id }).to eq maintainer_count
- end
-
- it 'has count of reduced capacity maintainers' do
- expect(weighted_maintainers.count { |r| r.object_id == reduced_capacity_maintainer.object_id }).to eq reduced_capacity_maintainer_count
- end
- end
-end
diff --git a/spec/tooling/danger/weightage/reviewers_spec.rb b/spec/tooling/danger/weightage/reviewers_spec.rb
deleted file mode 100644
index 5693ce7a10c..00000000000
--- a/spec/tooling/danger/weightage/reviewers_spec.rb
+++ /dev/null
@@ -1,63 +0,0 @@
-# frozen_string_literal: true
-
-require_relative '../../../../tooling/danger/weightage/reviewers'
-
-RSpec.describe Tooling::Danger::Weightage::Reviewers do
- let(:multiplier) { Tooling::Danger::Weightage::CAPACITY_MULTIPLIER }
- let(:regular_reviewer) { double('Teammate', hungry: false, reduced_capacity: false) }
- let(:hungry_reviewer) { double('Teammate', hungry: true, reduced_capacity: false) }
- let(:reduced_capacity_reviewer) { double('Teammate', hungry: false, reduced_capacity: true) }
- let(:reviewers) do
- [
- hungry_reviewer,
- regular_reviewer,
- reduced_capacity_reviewer
- ]
- end
-
- let(:regular_traintainer) { double('Teammate', hungry: false, reduced_capacity: false) }
- let(:hungry_traintainer) { double('Teammate', hungry: true, reduced_capacity: false) }
- let(:reduced_capacity_traintainer) { double('Teammate', hungry: false, reduced_capacity: true) }
- let(:traintainers) do
- [
- hungry_traintainer,
- regular_traintainer,
- reduced_capacity_traintainer
- ]
- end
-
- let(:hungry_reviewer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT * multiplier + described_class::DEFAULT_REVIEWER_WEIGHT }
- let(:hungry_traintainer_count) { described_class::TRAINTAINER_WEIGHT * multiplier + described_class::DEFAULT_REVIEWER_WEIGHT }
- let(:reviewer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT * multiplier }
- let(:traintainer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT * described_class::TRAINTAINER_WEIGHT * multiplier }
- let(:reduced_capacity_reviewer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT }
- let(:reduced_capacity_traintainer_count) { described_class::TRAINTAINER_WEIGHT }
-
- subject(:weighted_reviewers) { described_class.new(reviewers, traintainers).execute }
-
- describe '#execute', :aggregate_failures do
- it 'weights the reviewers overall' do
- reviewers_count = hungry_reviewer_count + reviewer_count + reduced_capacity_reviewer_count
- traintainers_count = hungry_traintainer_count + traintainer_count + reduced_capacity_traintainer_count
-
- expect(weighted_reviewers.count).to eq reviewers_count + traintainers_count
- end
-
- it 'has total count of hungry reviewers and traintainers' do
- expect(weighted_reviewers.count(&:hungry)).to eq hungry_reviewer_count + hungry_traintainer_count
- expect(weighted_reviewers.count { |r| r.object_id == hungry_reviewer.object_id }).to eq hungry_reviewer_count
- expect(weighted_reviewers.count { |r| r.object_id == hungry_traintainer.object_id }).to eq hungry_traintainer_count
- end
-
- it 'has total count of regular reviewers and traintainers' do
- expect(weighted_reviewers.count { |r| r.object_id == regular_reviewer.object_id }).to eq reviewer_count
- expect(weighted_reviewers.count { |r| r.object_id == regular_traintainer.object_id }).to eq traintainer_count
- end
-
- it 'has count of reduced capacity reviewers' do
- expect(weighted_reviewers.count(&:reduced_capacity)).to eq reduced_capacity_reviewer_count + reduced_capacity_traintainer_count
- expect(weighted_reviewers.count { |r| r.object_id == reduced_capacity_reviewer.object_id }).to eq reduced_capacity_reviewer_count
- expect(weighted_reviewers.count { |r| r.object_id == reduced_capacity_traintainer.object_id }).to eq reduced_capacity_traintainer_count
- end
- end
-end
diff --git a/spec/tooling/gitlab_danger_spec.rb b/spec/tooling/gitlab_danger_spec.rb
deleted file mode 100644
index 20ac40d1d2a..00000000000
--- a/spec/tooling/gitlab_danger_spec.rb
+++ /dev/null
@@ -1,76 +0,0 @@
-# frozen_string_literal: true
-
-require_relative '../../tooling/gitlab_danger'
-
-RSpec.describe GitlabDanger do
- let(:gitlab_danger_helper) { nil }
-
- subject { described_class.new(gitlab_danger_helper) }
-
- 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: #{described_class::LOCAL_RULES.join(', ')}")
- 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
- it 'returns local only rules' do
- expect(subject.rule_names).to eq(described_class::LOCAL_RULES)
- end
- end
-
- context 'when running under CI' do
- let(:gitlab_danger_helper) { double('danger_gitlab_helper') }
-
- it 'returns all rules' do
- expect(subject.rule_names).to eq(described_class::LOCAL_RULES | described_class::CI_ONLY_RULES)
- end
- end
- end
-
- describe '#html_link' do
- context 'when running locally' do
- it 'returns the same string' do
- str = 'something'
-
- expect(subject.html_link(str)).to eq(str)
- end
- end
-
- context 'when running under CI' do
- let(:gitlab_danger_helper) { double('danger_gitlab_helper') }
-
- it 'returns a HTML link formatted version of the string' do
- str = 'something'
- html_formatted_str = %Q{<a href="#{str}">#{str}</a>}
-
- expect(gitlab_danger_helper).to receive(:html_link).with(str).and_return(html_formatted_str)
-
- expect(subject.html_link(str)).to eq(html_formatted_str)
- end
- end
- end
-
- describe '#ci?' do
- context 'when gitlab_danger_helper is not available' do
- it 'returns false' do
- expect(subject.ci?).to be_falsey
- end
- end
-
- context 'when gitlab_danger_helper is available' do
- let(:gitlab_danger_helper) { double('danger_gitlab_helper') }
-
- it 'returns true' do
- expect(subject.ci?).to be_truthy
- end
- end
- end
-end
diff --git a/spec/lib/rspec_flaky/config_spec.rb b/spec/tooling/rspec_flaky/config_spec.rb
index 6b148599b67..12b5ed74cb2 100644
--- a/spec/lib/rspec_flaky/config_spec.rb
+++ b/spec/tooling/rspec_flaky/config_spec.rb
@@ -1,14 +1,23 @@
# frozen_string_literal: true
-require 'spec_helper'
+require 'rspec-parameterized'
+require_relative '../../support/helpers/stub_env'
+
+require_relative '../../../tooling/rspec_flaky/config'
RSpec.describe RspecFlaky::Config, :aggregate_failures do
+ include StubENV
+
before do
# Stub these env variables otherwise specs don't behave the same on the CI
stub_env('FLAKY_RSPEC_GENERATE_REPORT', nil)
stub_env('SUITE_FLAKY_RSPEC_REPORT_PATH', nil)
stub_env('FLAKY_RSPEC_REPORT_PATH', nil)
stub_env('NEW_FLAKY_RSPEC_REPORT_PATH', nil)
+ # Ensure the behavior is the same locally and on CI (where Rails is defined since we run this test as part of the whole suite), i.e. Rails isn't defined
+ allow(described_class).to receive(:rails_path).and_wrap_original do |method, path|
+ path
+ end
end
describe '.generate_report?' do
@@ -44,10 +53,7 @@ RSpec.describe RspecFlaky::Config, :aggregate_failures do
describe '.suite_flaky_examples_report_path' do
context "when ENV['SUITE_FLAKY_RSPEC_REPORT_PATH'] is not set" do
it 'returns the default path' do
- expect(Rails.root).to receive(:join).with('rspec_flaky/suite-report.json')
- .and_return('root/rspec_flaky/suite-report.json')
-
- expect(described_class.suite_flaky_examples_report_path).to eq('root/rspec_flaky/suite-report.json')
+ expect(described_class.suite_flaky_examples_report_path).to eq('rspec_flaky/suite-report.json')
end
end
@@ -65,10 +71,7 @@ RSpec.describe RspecFlaky::Config, :aggregate_failures do
describe '.flaky_examples_report_path' do
context "when ENV['FLAKY_RSPEC_REPORT_PATH'] is not set" do
it 'returns the default path' do
- expect(Rails.root).to receive(:join).with('rspec_flaky/report.json')
- .and_return('root/rspec_flaky/report.json')
-
- expect(described_class.flaky_examples_report_path).to eq('root/rspec_flaky/report.json')
+ expect(described_class.flaky_examples_report_path).to eq('rspec_flaky/report.json')
end
end
@@ -86,10 +89,7 @@ RSpec.describe RspecFlaky::Config, :aggregate_failures do
describe '.new_flaky_examples_report_path' do
context "when ENV['NEW_FLAKY_RSPEC_REPORT_PATH'] is not set" do
it 'returns the default path' do
- expect(Rails.root).to receive(:join).with('rspec_flaky/new-report.json')
- .and_return('root/rspec_flaky/new-report.json')
-
- expect(described_class.new_flaky_examples_report_path).to eq('root/rspec_flaky/new-report.json')
+ expect(described_class.new_flaky_examples_report_path).to eq('rspec_flaky/new-report.json')
end
end
diff --git a/spec/lib/rspec_flaky/example_spec.rb b/spec/tooling/rspec_flaky/example_spec.rb
index 4b45a15c463..8ff280fd855 100644
--- a/spec/lib/rspec_flaky/example_spec.rb
+++ b/spec/tooling/rspec_flaky/example_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-require 'spec_helper'
+require_relative '../../../tooling/rspec_flaky/example'
RSpec.describe RspecFlaky::Example do
let(:example_attrs) do
diff --git a/spec/lib/rspec_flaky/flaky_example_spec.rb b/spec/tooling/rspec_flaky/flaky_example_spec.rb
index b1647d5830a..ab652662c0b 100644
--- a/spec/lib/rspec_flaky/flaky_example_spec.rb
+++ b/spec/tooling/rspec_flaky/flaky_example_spec.rb
@@ -1,8 +1,14 @@
# frozen_string_literal: true
-require 'spec_helper'
+require 'active_support/testing/time_helpers'
+require_relative '../../support/helpers/stub_env'
+
+require_relative '../../../tooling/rspec_flaky/flaky_example'
RSpec.describe RspecFlaky::FlakyExample, :aggregate_failures do
+ include ActiveSupport::Testing::TimeHelpers
+ include StubENV
+
let(:flaky_example_attrs) do
{
example_id: 'spec/foo/bar_spec.rb:2',
@@ -30,7 +36,7 @@ RSpec.describe RspecFlaky::FlakyExample, :aggregate_failures do
}
end
- let(:example) { double(example_attrs) }
+ let(:example) { OpenStruct.new(example_attrs) }
before do
# Stub these env variables otherwise specs don't behave the same on the CI
@@ -77,19 +83,33 @@ RSpec.describe RspecFlaky::FlakyExample, :aggregate_failures do
shared_examples 'an up-to-date FlakyExample instance' do
let(:flaky_example) { described_class.new(args) }
- it 'updates the first_flaky_at' do
- now = Time.now
- expected_first_flaky_at = flaky_example.first_flaky_at || now
- Timecop.freeze(now) { flaky_example.update_flakiness! }
+ it 'sets the first_flaky_at if none exists' do
+ args[:first_flaky_at] = nil
- expect(flaky_example.first_flaky_at).to eq(expected_first_flaky_at)
+ freeze_time do
+ flaky_example.update_flakiness!
+
+ expect(flaky_example.first_flaky_at).to eq(Time.now)
+ end
+ end
+
+ it 'maintains the first_flaky_at if exists' do
+ flaky_example.update_flakiness!
+ expected_first_flaky_at = flaky_example.first_flaky_at
+
+ travel_to(Time.now + 42) do
+ flaky_example.update_flakiness!
+ expect(flaky_example.first_flaky_at).to eq(expected_first_flaky_at)
+ end
end
it 'updates the last_flaky_at' do
- now = Time.now
- Timecop.freeze(now) { flaky_example.update_flakiness! }
+ travel_to(Time.now + 42) do
+ the_future = Time.now
+ flaky_example.update_flakiness!
- expect(flaky_example.last_flaky_at).to eq(now)
+ expect(flaky_example.last_flaky_at).to eq(the_future)
+ end
end
it 'updates the flaky_reports' do
diff --git a/spec/lib/rspec_flaky/flaky_examples_collection_spec.rb b/spec/tooling/rspec_flaky/flaky_examples_collection_spec.rb
index b2fd1d3733a..823459e31b4 100644
--- a/spec/lib/rspec_flaky/flaky_examples_collection_spec.rb
+++ b/spec/tooling/rspec_flaky/flaky_examples_collection_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-require 'spec_helper'
+require_relative '../../../tooling/rspec_flaky/flaky_examples_collection'
RSpec.describe RspecFlaky::FlakyExamplesCollection, :aggregate_failures do
let(:collection_hash) do
diff --git a/spec/lib/rspec_flaky/listener_spec.rb b/spec/tooling/rspec_flaky/listener_spec.rb
index 10ed724d4de..429724a20cf 100644
--- a/spec/lib/rspec_flaky/listener_spec.rb
+++ b/spec/tooling/rspec_flaky/listener_spec.rb
@@ -1,8 +1,14 @@
# frozen_string_literal: true
-require 'spec_helper'
+require 'active_support/testing/time_helpers'
+require_relative '../../support/helpers/stub_env'
+
+require_relative '../../../tooling/rspec_flaky/listener'
RSpec.describe RspecFlaky::Listener, :aggregate_failures do
+ include ActiveSupport::Testing::TimeHelpers
+ include StubENV
+
let(:already_flaky_example_uid) { '6e869794f4cfd2badd93eb68719371d1' }
let(:suite_flaky_example_report) do
{
@@ -130,14 +136,13 @@ RSpec.describe RspecFlaky::Listener, :aggregate_failures do
it 'changes the flaky examples hash' do
new_example = RspecFlaky::Example.new(rspec_example)
- now = Time.now
- Timecop.freeze(now) do
+ travel_to(Time.now + 42) do
+ the_future = Time.now
expect { listener.example_passed(notification) }
.to change { listener.flaky_examples[new_example.uid].to_h }
+ expect(listener.flaky_examples[new_example.uid].to_h)
+ .to eq(expected_flaky_example.merge(last_flaky_at: the_future))
end
-
- expect(listener.flaky_examples[new_example.uid].to_h)
- .to eq(expected_flaky_example.merge(last_flaky_at: now))
end
end
@@ -157,14 +162,13 @@ RSpec.describe RspecFlaky::Listener, :aggregate_failures do
it 'changes the all flaky examples hash' do
new_example = RspecFlaky::Example.new(rspec_example)
- now = Time.now
- Timecop.freeze(now) do
+ travel_to(Time.now + 42) do
+ the_future = Time.now
expect { listener.example_passed(notification) }
.to change { listener.flaky_examples[new_example.uid].to_h }
+ expect(listener.flaky_examples[new_example.uid].to_h)
+ .to eq(expected_flaky_example.merge(first_flaky_at: the_future, last_flaky_at: the_future))
end
-
- expect(listener.flaky_examples[new_example.uid].to_h)
- .to eq(expected_flaky_example.merge(first_flaky_at: now, last_flaky_at: now))
end
end
@@ -198,6 +202,10 @@ RSpec.describe RspecFlaky::Listener, :aggregate_failures do
let(:notification_new_flaky_rspec_example) { double(example: new_flaky_rspec_example) }
let(:notification_already_flaky_rspec_example) { double(example: already_flaky_rspec_example) }
+ before do
+ allow(Kernel).to receive(:warn)
+ end
+
context 'when a report file path is set by FLAKY_RSPEC_REPORT_PATH' do
it 'delegates the writes to RspecFlaky::Report' do
listener.example_passed(notification_new_flaky_rspec_example)
diff --git a/spec/lib/rspec_flaky/report_spec.rb b/spec/tooling/rspec_flaky/report_spec.rb
index 5cacfdb82fb..6c364cd5cd3 100644
--- a/spec/lib/rspec_flaky/report_spec.rb
+++ b/spec/tooling/rspec_flaky/report_spec.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
-require 'spec_helper'
+require 'tempfile'
+
+require_relative '../../../tooling/rspec_flaky/report'
RSpec.describe RspecFlaky::Report, :aggregate_failures do
let(:thirty_one_days) { 3600 * 24 * 31 }
@@ -30,10 +32,14 @@ RSpec.describe RspecFlaky::Report, :aggregate_failures do
let(:flaky_examples) { RspecFlaky::FlakyExamplesCollection.new(collection_hash) }
let(:report) { described_class.new(flaky_examples) }
+ before do
+ allow(Kernel).to receive(:warn)
+ end
+
describe '.load' do
let!(:report_file) do
Tempfile.new(%w[rspec_flaky_report .json]).tap do |f|
- f.write(Gitlab::Json.pretty_generate(suite_flaky_example_report))
+ f.write(JSON.pretty_generate(suite_flaky_example_report)) # rubocop:disable Gitlab/Json
f.rewind
end
end
@@ -50,7 +56,7 @@ RSpec.describe RspecFlaky::Report, :aggregate_failures do
describe '.load_json' do
let(:report_json) do
- Gitlab::Json.pretty_generate(suite_flaky_example_report)
+ JSON.pretty_generate(suite_flaky_example_report) # rubocop:disable Gitlab/Json
end
it 'loads the report file' do
@@ -73,7 +79,7 @@ RSpec.describe RspecFlaky::Report, :aggregate_failures do
end
describe '#write' do
- let(:report_file_path) { Rails.root.join('tmp', 'rspec_flaky_report.json') }
+ let(:report_file_path) { File.join('tmp', 'rspec_flaky_report.json') }
before do
FileUtils.rm(report_file_path) if File.exist?(report_file_path)
@@ -105,7 +111,7 @@ RSpec.describe RspecFlaky::Report, :aggregate_failures do
expect(File.exist?(report_file_path)).to be(true)
expect(File.read(report_file_path))
- .to eq(Gitlab::Json.pretty_generate(report.flaky_examples.to_h))
+ .to eq(JSON.pretty_generate(report.flaky_examples.to_h)) # rubocop:disable Gitlab/Json
end
end
end
diff --git a/spec/uploaders/dependency_proxy/file_uploader_spec.rb b/spec/uploaders/dependency_proxy/file_uploader_spec.rb
index 724a9c42f47..6e94a661d6d 100644
--- a/spec/uploaders/dependency_proxy/file_uploader_spec.rb
+++ b/spec/uploaders/dependency_proxy/file_uploader_spec.rb
@@ -2,25 +2,43 @@
require 'spec_helper'
RSpec.describe DependencyProxy::FileUploader do
- let(:blob) { create(:dependency_proxy_blob) }
- let(:uploader) { described_class.new(blob, :file) }
- let(:path) { Gitlab.config.dependency_proxy.storage_path }
+ describe 'DependencyProxy::Blob uploader' do
+ let_it_be(:blob) { create(:dependency_proxy_blob) }
+ let_it_be(:path) { Gitlab.config.dependency_proxy.storage_path }
+ let(:uploader) { described_class.new(blob, :file) }
- subject { uploader }
+ subject { uploader }
- it_behaves_like "builds correct paths",
- store_dir: %r[\h{2}/\h{2}],
- cache_dir: %r[/dependency_proxy/tmp/cache],
- work_dir: %r[/dependency_proxy/tmp/work]
+ it_behaves_like "builds correct paths",
+ store_dir: %r[\h{2}/\h{2}],
+ cache_dir: %r[/dependency_proxy/tmp/cache],
+ work_dir: %r[/dependency_proxy/tmp/work]
+
+ context 'object store is remote' do
+ before do
+ stub_dependency_proxy_object_storage
+ end
- context 'object store is remote' do
- before do
- stub_dependency_proxy_object_storage
+ include_context 'with storage', described_class::Store::REMOTE
+
+ it_behaves_like "builds correct paths",
+ store_dir: %r[\h{2}/\h{2}]
end
+ end
- include_context 'with storage', described_class::Store::REMOTE
+ describe 'DependencyProxy::Manifest uploader' do
+ let_it_be(:manifest) { create(:dependency_proxy_manifest) }
+ let_it_be(:initial_content_type) { 'application/json' }
+ let_it_be(:fixture_file) { fixture_file_upload('spec/fixtures/dependency_proxy/manifest', initial_content_type) }
+ let(:uploader) { described_class.new(manifest, :file) }
- it_behaves_like "builds correct paths",
- store_dir: %r[\h{2}/\h{2}]
+ subject { uploader }
+
+ it 'will change upload file content type to match the model content type', :aggregate_failures do
+ uploader.cache!(fixture_file)
+
+ expect(uploader.file.content_type).to eq(manifest.content_type)
+ expect(uploader.file.content_type).not_to eq(initial_content_type)
+ end
end
end
diff --git a/spec/validators/zoom_url_validator_spec.rb b/spec/validators/gitlab/utils/zoom_url_validator_spec.rb
index 7d5c94bc249..bc8236a2f5c 100644
--- a/spec/validators/zoom_url_validator_spec.rb
+++ b/spec/validators/gitlab/utils/zoom_url_validator_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ZoomUrlValidator do
+RSpec.describe Gitlab::Utils::ZoomUrlValidator do
let(:zoom_meeting) { build(:zoom_meeting) }
describe 'validations' do
diff --git a/spec/views/admin/application_settings/_package_registry.html.haml_spec.rb b/spec/views/admin/application_settings/_package_registry.html.haml_spec.rb
index ef40829c29b..e0aa2fc8d56 100644
--- a/spec/views/admin/application_settings/_package_registry.html.haml_spec.rb
+++ b/spec/views/admin/application_settings/_package_registry.html.haml_spec.rb
@@ -30,8 +30,8 @@ RSpec.describe 'admin/application_settings/_package_registry' do
expect(rendered).to have_field('Maximum Maven package file size in bytes', type: 'number')
expect(page.find_field('Maximum Maven package file size in bytes').value).to eq(default_plan_limits.maven_max_file_size.to_s)
- expect(rendered).to have_field('Maximum NPM package file size in bytes', type: 'number')
- expect(page.find_field('Maximum NPM package file size in bytes').value).to eq(default_plan_limits.npm_max_file_size.to_s)
+ expect(rendered).to have_field('Maximum npm package file size in bytes', type: 'number')
+ expect(page.find_field('Maximum npm package file size in bytes').value).to eq(default_plan_limits.npm_max_file_size.to_s)
expect(rendered).to have_field('Maximum NuGet package file size in bytes', type: 'number')
expect(page.find_field('Maximum NuGet package file size in bytes').value).to eq(default_plan_limits.nuget_max_file_size.to_s)
@@ -48,18 +48,18 @@ RSpec.describe 'admin/application_settings/_package_registry' do
end
context 'with multiple plans' do
- let_it_be(:plan) { create(:plan, name: 'Gold') }
- let_it_be(:gold_plan_limits) { create(:plan_limits, :with_package_file_sizes, plan: plan) }
+ let_it_be(:plan) { create(:plan, name: 'Ultimate') }
+ let_it_be(:ultimate_plan_limits) { create(:plan_limits, :with_package_file_sizes, plan: plan) }
before do
- assign(:plans, [default_plan_limits.plan, gold_plan_limits.plan])
+ 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('Gold')
+ expect(page).to have_content('Ultimate')
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 2915fe1964f..dc8f259eb56 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
@@ -3,34 +3,49 @@
require 'spec_helper'
RSpec.describe 'admin/application_settings/_repository_storage.html.haml' do
- let(:app_settings) { create(:application_setting) }
- let(:repository_storages_weighted_attributes) { [:repository_storages_weighted_default, :repository_storages_weighted_mepmep, :repository_storages_weighted_foobar]}
- let(:repository_storages_weighted) do
- {
- "default" => 100,
- "mepmep" => 50
- }
- end
+ let(:app_settings) { build(:application_setting, repository_storages_weighted: repository_storages_weighted) }
before do
- allow(app_settings).to receive(:repository_storages_weighted).and_return(repository_storages_weighted)
- allow(app_settings).to receive(:repository_storages_weighted_mepmep).and_return(100)
- allow(app_settings).to receive(:repository_storages_weighted_foobar).and_return(50)
+ stub_storage_settings({ 'default': {}, 'mepmep': {}, 'foobar': {} })
assign(:application_setting, app_settings)
- allow(ApplicationSetting).to receive(:repository_storages_weighted_attributes).and_return(repository_storages_weighted_attributes)
end
- context 'when multiple storages are available' do
+ context 'additional storage config' do
+ let(:repository_storages_weighted) do
+ {
+ 'default' => 100,
+ 'mepmep' => 50
+ }
+ end
+
it 'lists them all' do
render
- # lists storages that are saved with weights
- repository_storages_weighted.each do |storage_name, storage_weight|
+ Gitlab.config.repositories.storages.keys.each do |storage_name|
expect(rendered).to have_content(storage_name)
end
- # lists storage not saved with weight
expect(rendered).to have_content('foobar')
end
end
+
+ context 'fewer storage configs' do
+ let(:repository_storages_weighted) do
+ {
+ 'default' => 100,
+ 'mepmep' => 50,
+ 'something_old' => 100
+ }
+ 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')
+ 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 a53aab43c18..00000000000
--- a/spec/views/groups/show.html.haml_spec.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'groups/show.html.haml' do
- let_it_be(:user) { build(:user) }
- let_it_be(:group) { create(:group) }
-
- before do
- assign(:group, group)
- end
-
- context 'when rendering with the layout' do
- subject(:render_page) { render template: 'groups/show.html.haml', layout: 'layouts/group' }
-
- describe 'invite team members' do
- before do
- allow(view).to receive(:session).and_return({})
- allow(view).to receive(:current_user_mode).and_return(Gitlab::Auth::CurrentUserMode.new(user))
- allow(view).to receive(:current_user).and_return(user)
- allow(view).to receive(:experiment_enabled?).and_return(false)
- allow(view).to receive(:group_path).and_return('')
- allow(view).to receive(:group_shared_path).and_return('')
- allow(view).to receive(:group_archived_path).and_return('')
- end
-
- context 'when invite team members is not available in sidebar' do
- before do
- allow(view).to receive(:can_invite_members_for_group?).and_return(false)
- end
-
- it 'does not display the js-invite-members-trigger' do
- render_page
-
- expect(rendered).not_to have_selector('.js-invite-members-trigger')
- end
- end
-
- context 'when invite team members is available' do
- before do
- allow(view).to receive(:can_invite_members_for_group?).and_return(true)
- end
-
- it 'includes the div for js-invite-members-trigger' do
- render_page
-
- expect(rendered).to have_selector('.js-invite-members-trigger')
- end
- end
- end
- end
-end
diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
index e34d8b91b38..99d7dfc8acb 100644
--- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
@@ -204,7 +204,7 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
it 'does not show the ci/cd settings tab' do
render
- expect(rendered).not_to have_link('CI / CD', href: project_settings_ci_cd_path(project))
+ expect(rendered).not_to have_link('CI/CD', href: project_settings_ci_cd_path(project))
end
end
@@ -214,7 +214,7 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
it 'shows the ci/cd settings tab' do
render
- expect(rendered).to have_link('CI / CD', href: project_settings_ci_cd_path(project))
+ expect(rendered).to have_link('CI/CD', href: project_settings_ci_cd_path(project))
end
end
end
diff --git a/spec/views/notify/change_in_merge_request_draft_status_email.html.haml_spec.rb b/spec/views/notify/change_in_merge_request_draft_status_email.html.haml_spec.rb
new file mode 100644
index 00000000000..6c25eba03b9
--- /dev/null
+++ b/spec/views/notify/change_in_merge_request_draft_status_email.html.haml_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'notify/change_in_merge_request_draft_status_email.html.haml' do
+ let(:user) { create(:user) }
+ let(:merge_request) { create(:merge_request) }
+
+ before do
+ assign(:updated_by_user, user)
+ assign(:merge_request, merge_request)
+ end
+
+ it 'renders the email correctly' do
+ render
+
+ expect(rendered).to have_content("#{user.name} changed the draft status of merge request #{merge_request.to_reference}")
+ end
+end
diff --git a/spec/views/notify/change_in_merge_request_draft_status_email.text.erb_spec.rb b/spec/views/notify/change_in_merge_request_draft_status_email.text.erb_spec.rb
new file mode 100644
index 00000000000..a05c20fd8c4
--- /dev/null
+++ b/spec/views/notify/change_in_merge_request_draft_status_email.text.erb_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe 'notify/change_in_merge_request_draft_status_email.text.erb' do
+ let(:user) { create(:user) }
+ let(:merge_request) { create(:merge_request) }
+
+ before do
+ assign(:updated_by_user, user)
+ assign(:merge_request, merge_request)
+ end
+
+ it_behaves_like 'renders plain text email correctly'
+
+ it 'renders the email correctly' do
+ render
+
+ expect(rendered).to have_content("#{user.name} changed the draft status of merge request #{merge_request.to_reference}")
+ end
+end
diff --git a/spec/views/projects/_home_panel.html.haml_spec.rb b/spec/views/projects/_home_panel.html.haml_spec.rb
index cc0eb9919da..d329c57af00 100644
--- a/spec/views/projects/_home_panel.html.haml_spec.rb
+++ b/spec/views/projects/_home_panel.html.haml_spec.rb
@@ -9,7 +9,6 @@ RSpec.describe 'projects/_home_panel' do
let(:project) { create(:project) }
before do
- stub_feature_flags(vue_notification_dropdown: false)
assign(:project, project)
allow(view).to receive(:current_user).and_return(user)
@@ -25,11 +24,10 @@ RSpec.describe 'projects/_home_panel' do
assign(:notification_setting, notification_settings)
end
- it 'makes it possible to set notification level' do
+ it 'renders Vue app root' do
render
- expect(view).to render_template('shared/notifications/_new_button')
- expect(rendered).to have_selector('.notification-dropdown')
+ expect(rendered).to have_selector('.js-vue-notification-dropdown')
end
end
@@ -40,10 +38,10 @@ RSpec.describe 'projects/_home_panel' do
assign(:notification_setting, nil)
end
- it 'is not possible to set notification level' do
+ it 'does not render Vue app root' do
render
- expect(rendered).not_to have_selector('.notification_dropdown')
+ expect(rendered).not_to have_selector('.js-vue-notification-dropdown')
end
end
end
diff --git a/spec/views/projects/commit/_commit_box.html.haml_spec.rb b/spec/views/projects/commit/_commit_box.html.haml_spec.rb
index 9c97696493e..9d18519ade6 100644
--- a/spec/views/projects/commit/_commit_box.html.haml_spec.rb
+++ b/spec/views/projects/commit/_commit_box.html.haml_spec.rb
@@ -21,12 +21,37 @@ RSpec.describe 'projects/commit/_commit_box.html.haml' do
end
context 'when there is a pipeline present' do
+ context 'when pipeline has stages' do
+ before do
+ pipeline = create(:ci_pipeline, project: project, sha: project.commit.id, status: 'success')
+ create(:ci_build, pipeline: pipeline, stage: 'build')
+
+ assign(:last_pipeline, project.commit.last_pipeline)
+ end
+
+ it 'shows pipeline stages in vue' do
+ render
+
+ expect(rendered).to have_selector('.js-commit-pipeline-mini-graph')
+ end
+
+ it 'shows pipeline stages in haml when feature flag is disabled' do
+ stub_feature_flags(ci_commit_pipeline_mini_graph_vue: false)
+
+ render
+
+ expect(rendered).to have_selector('.js-commit-pipeline-graph')
+ end
+ end
+
context 'when there are multiple pipelines for a commit' do
it 'shows the last pipeline' do
create(:ci_pipeline, project: project, sha: project.commit.id, status: 'success')
create(:ci_pipeline, project: project, sha: project.commit.id, status: 'canceled')
third_pipeline = create(:ci_pipeline, project: project, sha: project.commit.id, status: 'failed')
+ assign(:last_pipeline, third_pipeline)
+
render
expect(rendered).to have_text("Pipeline ##{third_pipeline.id} failed")
@@ -40,6 +65,8 @@ RSpec.describe 'projects/commit/_commit_box.html.haml' do
end
it 'shows correct pipeline description' do
+ assign(:last_pipeline, pipeline)
+
render
expect(rendered).to have_text "Pipeline ##{pipeline.id} " \
diff --git a/spec/views/projects/empty.html.haml_spec.rb b/spec/views/projects/empty.html.haml_spec.rb
index 6762dcd22d5..de83722160e 100644
--- a/spec/views/projects/empty.html.haml_spec.rb
+++ b/spec/views/projects/empty.html.haml_spec.rb
@@ -79,41 +79,4 @@ RSpec.describe 'projects/empty' do
it_behaves_like 'no invite member info'
end
end
-
- context 'when rendering with the layout' do
- subject(:render_page) { render template: 'projects/empty.html.haml', layout: 'layouts/project' }
-
- describe 'invite team members' do
- before do
- allow(view).to receive(:session).and_return({})
- allow(view).to receive(:current_user_mode).and_return(Gitlab::Auth::CurrentUserMode.new(user))
- allow(view).to receive(:current_user).and_return(user)
- allow(view).to receive(:experiment_enabled?).and_return(false)
- end
-
- context 'when invite team members is not available in sidebar' do
- before do
- allow(view).to receive(:can_invite_members_for_project?).and_return(false)
- end
-
- it 'does not display the js-invite-members-trigger' do
- render_page
-
- expect(rendered).not_to have_selector('.js-invite-members-trigger')
- end
- end
-
- context 'when invite team members is available' do
- before do
- allow(view).to receive(:can_invite_members_for_project?).and_return(true)
- end
-
- it 'includes the div for js-invite-members-trigger' do
- render_page
-
- expect(rendered).to have_selector('.js-invite-members-trigger')
- end
- end
- end
- end
end
diff --git a/spec/views/projects/issues/import_csv/_button.html.haml_spec.rb b/spec/views/projects/issues/import_csv/_button.html.haml_spec.rb
deleted file mode 100644
index 8bc0a00d71c..00000000000
--- a/spec/views/projects/issues/import_csv/_button.html.haml_spec.rb
+++ /dev/null
@@ -1,43 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'projects/issues/import_csv/_button' do
- include Devise::Test::ControllerHelpers
-
- context 'when the user does not have edit permissions' do
- before do
- render
- end
-
- it 'shows a dropdown button to import CSV' do
- expect(rendered).to have_text('Import CSV')
- end
-
- it 'does not show a button to import from Jira' do
- expect(rendered).not_to have_text('Import from Jira')
- end
- end
-
- context 'when the user has edit permissions' do
- let(:project) { create(:project) }
- let(:current_user) { create(:user, maintainer_projects: [project]) }
-
- before do
- allow(view).to receive(:project_import_jira_path).and_return('import/jira')
- allow(view).to receive(:current_user).and_return(current_user)
-
- assign(:project, project)
-
- render
- end
-
- it 'shows a dropdown button to import CSV' do
- expect(rendered).to have_text('Import CSV')
- end
-
- it 'shows a button to import from Jira' do
- expect(rendered).to have_text('Import from Jira')
- end
- end
-end
diff --git a/spec/views/projects/merge_requests/show.html.haml_spec.rb b/spec/views/projects/merge_requests/show.html.haml_spec.rb
index db41c9b5374..40d11342ec4 100644
--- a/spec/views/projects/merge_requests/show.html.haml_spec.rb
+++ b/spec/views/projects/merge_requests/show.html.haml_spec.rb
@@ -2,16 +2,14 @@
require 'spec_helper'
-RSpec.describe 'projects/merge_requests/show.html.haml' do
- include Spec::Support::Helpers::Features::MergeRequestHelpers
+RSpec.describe 'projects/merge_requests/show.html.haml', :aggregate_failures do
+ include_context 'merge request show action'
before do
- allow(view).to receive(:experiment_enabled?).and_return(false)
+ merge_request.reload
end
context 'when the merge request is open' do
- include_context 'open merge request show action'
-
it 'shows the "Mark as draft" button' do
render
@@ -22,20 +20,8 @@ RSpec.describe 'projects/merge_requests/show.html.haml' do
end
context 'when the merge request is closed' do
- include_context 'closed merge request show action'
-
- describe 'merge request assignee sidebar' do
- context 'when assignee is allowed to merge' do
- it 'does not show a warning icon' do
- closed_merge_request.update!(assignee_id: user.id)
- project.add_maintainer(user)
- assign(:issuable_sidebar, serialize_issuable_sidebar(user, project, closed_merge_request))
-
- render
-
- expect(rendered).not_to have_css('.merge-icon')
- end
- end
+ before do
+ merge_request.close!
end
it 'shows the "Reopen" button' do
@@ -46,15 +32,15 @@ RSpec.describe 'projects/merge_requests/show.html.haml' do
expect(rendered).to have_css('a', visible: false, text: 'Close')
end
- it 'does not show the "Reopen" button when the source project does not exist' do
- unlink_project.execute
- closed_merge_request.reload
- preload_view_requirements(closed_merge_request, note)
+ context 'when source project does not exist' do
+ it 'does not show the "Reopen" button' do
+ allow(merge_request).to receive(:source_project).and_return(nil)
- render
+ render
- expect(rendered).to have_css('a', visible: false, text: 'Reopen')
- expect(rendered).to have_css('a', visible: false, text: 'Close')
+ expect(rendered).to have_css('a', visible: false, text: 'Reopen')
+ expect(rendered).to have_css('a', visible: false, text: 'Close')
+ end
end
end
end
diff --git a/spec/views/projects/settings/operations/show.html.haml_spec.rb b/spec/views/projects/settings/operations/show.html.haml_spec.rb
index a22853d40d8..b2dd3556098 100644
--- a/spec/views/projects/settings/operations/show.html.haml_spec.rb
+++ b/spec/views/projects/settings/operations/show.html.haml_spec.rb
@@ -59,7 +59,7 @@ RSpec.describe 'projects/settings/operations/show' do
expect(rendered).to have_content _('Prometheus')
expect(rendered).to have_content _('Link Prometheus monitoring to GitLab.')
- expect(rendered).to have_content _('To enable the installation of Prometheus on your clusters, deactivate the manual configuration below')
+ expect(rendered).to have_content _('To enable the installation of Prometheus on your clusters, deactivate the manual configuration.')
end
end
@@ -71,7 +71,7 @@ RSpec.describe 'projects/settings/operations/show' do
it 'renders the Operations Settings page' do
render
- expect(rendered).not_to have_content _('Select the Active checkbox to override the Auto Configuration with custom settings. If unchecked, Auto Configuration settings are used.')
+ expect(rendered).not_to have_content _('Auto configuration settings are used unless you override their values here.')
end
end
end
diff --git a/spec/views/projects/show.html.haml_spec.rb b/spec/views/projects/show.html.haml_spec.rb
deleted file mode 100644
index 995e31e83af..00000000000
--- a/spec/views/projects/show.html.haml_spec.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'projects/show.html.haml' do
- let_it_be(:user) { build(:user) }
- let_it_be(:project) { ProjectPresenter.new(create(:project, :repository), current_user: user) }
-
- before do
- assign(:project, project)
- end
-
- context 'when rendering with the layout' do
- subject(:render_page) { render template: 'projects/show.html.haml', layout: 'layouts/project' }
-
- describe 'invite team members' do
- before do
- allow(view).to receive(:event_filter_link)
- allow(view).to receive(:session).and_return({})
- allow(view).to receive(:current_user_mode).and_return(Gitlab::Auth::CurrentUserMode.new(user))
- allow(view).to receive(:current_user).and_return(user)
- allow(view).to receive(:experiment_enabled?).and_return(false)
- allow(view).to receive(:add_page_startup_graphql_call)
- end
-
- context 'when invite team members is not available in sidebar' do
- before do
- allow(view).to receive(:can_invite_members_for_project?).and_return(false)
- end
-
- it 'does not display the js-invite-members-trigger' do
- render_page
-
- expect(rendered).not_to have_selector('.js-invite-members-trigger')
- end
- end
-
- context 'when invite team members is available' do
- before do
- allow(view).to receive(:can_invite_members_for_project?).and_return(true)
- end
-
- it 'includes the div for js-invite-members-trigger' do
- render_page
-
- expect(rendered).to have_selector('.js-invite-members-trigger')
- end
- end
- end
- end
-end
diff --git a/spec/views/shared/snippets/_snippet.html.haml_spec.rb b/spec/views/shared/snippets/_snippet.html.haml_spec.rb
new file mode 100644
index 00000000000..712021ec1e1
--- /dev/null
+++ b/spec/views/shared/snippets/_snippet.html.haml_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'shared/snippets/_snippet.html.haml' do
+ let_it_be(:snippet) { create(:snippet) }
+
+ before do
+ allow(view).to receive(:current_application_settings).and_return(Gitlab::CurrentSettings.current_application_settings)
+ allow(view).to receive(:can?) { true }
+
+ @noteable_meta_data = Class.new { include Gitlab::NoteableMetadata }.new.noteable_meta_data([snippet], 'Snippet')
+ end
+
+ context 'snippet with statistics' do
+ it 'renders correct file count and tooltip' do
+ snippet.statistics.file_count = 3
+
+ render 'shared/snippets/snippet', snippet: snippet
+
+ expect(rendered).to have_selector("span.file_count", text: '3')
+ expect(rendered).to have_selector("span.file_count[title=\"3 files\"]")
+ end
+
+ it 'renders correct file count and tooltip when file_count is 1' do
+ snippet.statistics.file_count = 1
+
+ render 'shared/snippets/snippet', snippet: snippet
+
+ expect(rendered).to have_selector("span.file_count", text: '1')
+ expect(rendered).to have_selector("span.file_count[title=\"1 file\"]")
+ end
+
+ it 'does not render file count when file count is 0' do
+ snippet.statistics.file_count = 0
+
+ render 'shared/snippets/snippet', snippet: snippet
+
+ expect(rendered).not_to have_selector('span.file_count')
+ end
+ end
+
+ context 'snippet without statistics' do
+ it 'does not render file count if statistics are not present' do
+ snippet.statistics = nil
+
+ render 'shared/snippets/snippet', snippet: snippet
+
+ expect(rendered).not_to have_selector('span.file_count')
+ end
+ end
+end
diff --git a/spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb b/spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb
index c7de8553d86..da0cbe37400 100644
--- a/spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb
+++ b/spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb
@@ -6,12 +6,12 @@ RSpec.describe Analytics::InstanceStatistics::CountJobTriggerWorker do
it_behaves_like 'an idempotent worker'
context 'triggers a job for each measurement identifiers' do
- let(:expected_count) { Analytics::InstanceStatistics::Measurement.identifier_query_mapping.keys.size }
+ let(:expected_count) { Analytics::UsageTrends::Measurement.identifier_query_mapping.keys.size }
it 'triggers CounterJobWorker jobs' do
subject.perform
- expect(Analytics::InstanceStatistics::CounterJobWorker.jobs.count).to eq(expected_count)
+ expect(Analytics::UsageTrends::CounterJobWorker.jobs.count).to eq(expected_count)
end
end
end
diff --git a/spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb b/spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb
index 667ec0bcb75..4994fec44ab 100644
--- a/spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb
+++ b/spec/workers/analytics/instance_statistics/counter_job_worker_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Analytics::InstanceStatistics::CounterJobWorker do
let_it_be(:user_1) { create(:user) }
let_it_be(:user_2) { create(:user) }
- let(:users_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:users) }
+ let(:users_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:users) }
let(:recorded_at) { Time.zone.now }
let(:job_args) { [users_measurement_identifier, user_1.id, user_2.id, recorded_at] }
@@ -18,7 +18,7 @@ RSpec.describe Analytics::InstanceStatistics::CounterJobWorker do
it 'counts a scope and stores the result' do
subject
- measurement = Analytics::InstanceStatistics::Measurement.users.first
+ measurement = Analytics::UsageTrends::Measurement.users.first
expect(measurement.recorded_at).to be_like_time(recorded_at)
expect(measurement.identifier).to eq('users')
expect(measurement.count).to eq(2)
@@ -26,14 +26,14 @@ RSpec.describe Analytics::InstanceStatistics::CounterJobWorker do
end
context 'when no records are in the database' do
- let(:users_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:groups) }
+ let(:users_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:groups) }
subject { described_class.new.perform(users_measurement_identifier, nil, nil, recorded_at) }
it 'sets 0 as the count' do
subject
- measurement = Analytics::InstanceStatistics::Measurement.groups.first
+ measurement = Analytics::UsageTrends::Measurement.groups.first
expect(measurement.recorded_at).to be_like_time(recorded_at)
expect(measurement.identifier).to eq('groups')
expect(measurement.count).to eq(0)
@@ -49,19 +49,19 @@ RSpec.describe Analytics::InstanceStatistics::CounterJobWorker do
it 'does not insert anything when BatchCount returns error' do
allow(Gitlab::Database::BatchCount).to receive(:batch_count).and_return(Gitlab::Database::BatchCounter::FALLBACK)
- expect { subject }.not_to change { Analytics::InstanceStatistics::Measurement.count }
+ expect { subject }.not_to change { Analytics::UsageTrends::Measurement.count }
end
context 'when pipelines_succeeded identifier is passed' do
let_it_be(:pipeline) { create(:ci_pipeline, :success) }
- let(:successful_pipelines_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:pipelines_succeeded) }
+ let(:successful_pipelines_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:pipelines_succeeded) }
let(:job_args) { [successful_pipelines_measurement_identifier, pipeline.id, pipeline.id, recorded_at] }
it 'counts successful pipelines' do
subject
- measurement = Analytics::InstanceStatistics::Measurement.pipelines_succeeded.first
+ measurement = Analytics::UsageTrends::Measurement.pipelines_succeeded.first
expect(measurement.recorded_at).to be_like_time(recorded_at)
expect(measurement.identifier).to eq('pipelines_succeeded')
expect(measurement.count).to eq(1)
diff --git a/spec/workers/analytics/usage_trends/count_job_trigger_worker_spec.rb b/spec/workers/analytics/usage_trends/count_job_trigger_worker_spec.rb
new file mode 100644
index 00000000000..735e4a214a9
--- /dev/null
+++ b/spec/workers/analytics/usage_trends/count_job_trigger_worker_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Analytics::UsageTrends::CountJobTriggerWorker do
+ it_behaves_like 'an idempotent worker'
+
+ context 'triggers a job for each measurement identifiers' do
+ let(:expected_count) { Analytics::UsageTrends::Measurement.identifier_query_mapping.keys.size }
+
+ it 'triggers CounterJobWorker jobs' do
+ subject.perform
+
+ expect(Analytics::UsageTrends::CounterJobWorker.jobs.count).to eq(expected_count)
+ end
+ end
+end
diff --git a/spec/workers/analytics/usage_trends/counter_job_worker_spec.rb b/spec/workers/analytics/usage_trends/counter_job_worker_spec.rb
new file mode 100644
index 00000000000..9e4c82ee981
--- /dev/null
+++ b/spec/workers/analytics/usage_trends/counter_job_worker_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Analytics::UsageTrends::CounterJobWorker do
+ let_it_be(:user_1) { create(:user) }
+ let_it_be(:user_2) { create(:user) }
+
+ let(:users_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:users) }
+ let(:recorded_at) { Time.zone.now }
+ let(:job_args) { [users_measurement_identifier, user_1.id, user_2.id, recorded_at] }
+
+ before do
+ allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false)
+ end
+
+ include_examples 'an idempotent worker' do
+ it 'counts a scope and stores the result' do
+ subject
+
+ measurement = Analytics::UsageTrends::Measurement.users.first
+ expect(measurement.recorded_at).to be_like_time(recorded_at)
+ expect(measurement.identifier).to eq('users')
+ expect(measurement.count).to eq(2)
+ end
+ end
+
+ context 'when no records are in the database' do
+ let(:users_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:groups) }
+
+ subject { described_class.new.perform(users_measurement_identifier, nil, nil, recorded_at) }
+
+ it 'sets 0 as the count' do
+ subject
+
+ measurement = Analytics::UsageTrends::Measurement.groups.first
+ expect(measurement.recorded_at).to be_like_time(recorded_at)
+ expect(measurement.identifier).to eq('groups')
+ expect(measurement.count).to eq(0)
+ end
+ end
+
+ it 'does not raise error when inserting duplicated measurement' do
+ subject
+
+ expect { subject }.not_to raise_error
+ end
+
+ it 'does not insert anything when BatchCount returns error' do
+ allow(Gitlab::Database::BatchCount).to receive(:batch_count).and_return(Gitlab::Database::BatchCounter::FALLBACK)
+
+ expect { subject }.not_to change { Analytics::UsageTrends::Measurement.count }
+ end
+
+ context 'when pipelines_succeeded identifier is passed' do
+ let_it_be(:pipeline) { create(:ci_pipeline, :success) }
+
+ let(:successful_pipelines_measurement_identifier) { ::Analytics::UsageTrends::Measurement.identifiers.fetch(:pipelines_succeeded) }
+ let(:job_args) { [successful_pipelines_measurement_identifier, pipeline.id, pipeline.id, recorded_at] }
+
+ it 'counts successful pipelines' do
+ subject
+
+ measurement = Analytics::UsageTrends::Measurement.pipelines_succeeded.first
+ expect(measurement.recorded_at).to be_like_time(recorded_at)
+ expect(measurement.identifier).to eq('pipelines_succeeded')
+ expect(measurement.count).to eq(1)
+ end
+ end
+end
diff --git a/spec/workers/emails_on_push_worker_spec.rb b/spec/workers/emails_on_push_worker_spec.rb
index fac463b4dd4..6c37c422aed 100644
--- a/spec/workers/emails_on_push_worker_spec.rb
+++ b/spec/workers/emails_on_push_worker_spec.rb
@@ -97,7 +97,7 @@ RSpec.describe EmailsOnPushWorker, :mailer do
end
it "gracefully handles an input SMTP error" do
- expect(ActionMailer::Base.deliveries.count).to eq(0)
+ expect(ActionMailer::Base.deliveries).to be_empty
end
end
@@ -112,6 +112,16 @@ RSpec.describe EmailsOnPushWorker, :mailer do
end
end
+ context "with mixed-case recipient" do
+ let(:recipients) { user.email.upcase }
+
+ it "retains the case" do
+ perform
+
+ expect(email_recipients).to contain_exactly(recipients)
+ end
+ end
+
context "when the recipient addresses are a list of email addresses" do
let(:recipients) do
1.upto(5).map { |i| user.email.sub('@', "+#{i}@") }.join("\n")
@@ -120,7 +130,6 @@ RSpec.describe EmailsOnPushWorker, :mailer do
it "sends the mail to each of the recipients" do
perform
- expect(ActionMailer::Base.deliveries.count).to eq(5)
expect(email_recipients).to contain_exactly(*recipients.split)
end
@@ -132,13 +141,22 @@ RSpec.describe EmailsOnPushWorker, :mailer do
end
end
+ context "when recipients are invalid" do
+ let(:recipients) { "invalid\n\nrecipients" }
+
+ it "ignores them" do
+ perform
+
+ expect(ActionMailer::Base.deliveries).to be_empty
+ end
+ end
+
context "when the recipient addresses contains angle brackets and are separated by spaces" do
let(:recipients) { "John Doe <johndoe@example.com> Jane Doe <janedoe@example.com>" }
it "accepts emails separated by whitespace" do
perform
- expect(ActionMailer::Base.deliveries.count).to eq(2)
expect(email_recipients).to contain_exactly("johndoe@example.com", "janedoe@example.com")
end
end
@@ -149,7 +167,6 @@ RSpec.describe EmailsOnPushWorker, :mailer do
it "accepts both kind of emails" do
perform
- expect(ActionMailer::Base.deliveries.count).to eq(2)
expect(email_recipients).to contain_exactly("johndoe@example.com", "janedoe@example.com")
end
end
@@ -160,10 +177,19 @@ RSpec.describe EmailsOnPushWorker, :mailer do
it "accepts emails separated by newlines" do
perform
- expect(ActionMailer::Base.deliveries.count).to eq(2)
expect(email_recipients).to contain_exactly("johndoe@example.com", "janedoe@example.com")
end
end
+
+ context 'when the recipient addresses contains duplicates' do
+ let(:recipients) { 'non@dubplicate.com Duplic@te.com duplic@te.com Duplic@te.com duplic@Te.com' }
+
+ it 'deduplicates recipients while treating the domain part as case-insensitive' do
+ perform
+
+ expect(email_recipients).to contain_exactly('non@dubplicate.com', 'Duplic@te.com')
+ end
+ end
end
end
end
diff --git a/spec/workers/error_tracking_issue_link_worker_spec.rb b/spec/workers/error_tracking_issue_link_worker_spec.rb
index 5be568c2dad..90e747c8788 100644
--- a/spec/workers/error_tracking_issue_link_worker_spec.rb
+++ b/spec/workers/error_tracking_issue_link_worker_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe ErrorTrackingIssueLinkWorker do
describe '#perform' do
it 'creates a link between an issue and a Sentry issue in Sentry' do
- expect_next_instance_of(Sentry::Client) do |client|
+ expect_next_instance_of(ErrorTracking::SentryClient) do |client|
expect(client).to receive(:repos).with('sentry-org').and_return([repo])
expect(client)
.to receive(:create_issue_link)
@@ -33,8 +33,8 @@ RSpec.describe ErrorTrackingIssueLinkWorker do
shared_examples_for 'makes no external API requests' do
it 'takes no action' do
- expect_any_instance_of(Sentry::Client).not_to receive(:repos)
- expect_any_instance_of(Sentry::Client).not_to receive(:create_issue_link)
+ expect_any_instance_of(ErrorTracking::SentryClient).not_to receive(:repos)
+ expect_any_instance_of(ErrorTracking::SentryClient).not_to receive(:create_issue_link)
expect(subject).to be nil
end
@@ -42,7 +42,7 @@ RSpec.describe ErrorTrackingIssueLinkWorker do
shared_examples_for 'attempts to create a link via plugin' do
it 'takes no action' do
- expect_next_instance_of(Sentry::Client) do |client|
+ expect_next_instance_of(ErrorTracking::SentryClient) do |client|
expect(client).to receive(:repos).with('sentry-org').and_return([repo])
expect(client)
.to receive(:create_issue_link)
@@ -98,8 +98,8 @@ RSpec.describe ErrorTrackingIssueLinkWorker do
context 'when Sentry repos request errors' do
it 'falls back to creating a link via plugin' do
- expect_next_instance_of(Sentry::Client) do |client|
- expect(client).to receive(:repos).with('sentry-org').and_raise(Sentry::Client::Error)
+ expect_next_instance_of(ErrorTracking::SentryClient) do |client|
+ expect(client).to receive(:repos).with('sentry-org').and_raise(ErrorTracking::SentryClient::Error)
expect(client)
.to receive(:create_issue_link)
.with(nil, sentry_issue.sentry_issue_identifier, issue)
diff --git a/spec/workers/expire_job_cache_worker_spec.rb b/spec/workers/expire_job_cache_worker_spec.rb
index b4f8f56563b..95c54a762a4 100644
--- a/spec/workers/expire_job_cache_worker_spec.rb
+++ b/spec/workers/expire_job_cache_worker_spec.rb
@@ -13,7 +13,6 @@ RSpec.describe ExpireJobCacheWorker do
include_examples 'an idempotent worker' do
it 'invalidates Etag caching for the job path' do
- pipeline_path = "/#{project.full_path}/-/pipelines/#{pipeline.id}.json"
job_path = "/#{project.full_path}/builds/#{job.id}.json"
spy_store = Gitlab::EtagCaching::Store.new
@@ -22,13 +21,12 @@ RSpec.describe ExpireJobCacheWorker do
expect(spy_store).to receive(:touch)
.exactly(worker_exec_times).times
- .with(pipeline_path)
+ .with(job_path)
.and_call_original
- expect(spy_store).to receive(:touch)
+ expect(ExpirePipelineCacheWorker).to receive(:perform_async)
+ .with(pipeline.id)
.exactly(worker_exec_times).times
- .with(job_path)
- .and_call_original
subject
end
diff --git a/spec/workers/expire_pipeline_cache_worker_spec.rb b/spec/workers/expire_pipeline_cache_worker_spec.rb
index fb6ee67311c..a8c21aa9f83 100644
--- a/spec/workers/expire_pipeline_cache_worker_spec.rb
+++ b/spec/workers/expire_pipeline_cache_worker_spec.rb
@@ -25,15 +25,6 @@ RSpec.describe ExpirePipelineCacheWorker do
subject.perform(617748)
end
- it "doesn't do anything if the pipeline cannot be cached" do
- allow_any_instance_of(Ci::Pipeline).to receive(:cacheable?).and_return(false)
-
- expect_any_instance_of(Ci::ExpirePipelineCacheService).not_to receive(:execute)
- expect_any_instance_of(Gitlab::EtagCaching::Store).not_to receive(:touch)
-
- subject.perform(pipeline.id)
- end
-
it_behaves_like 'an idempotent worker' do
let(:job_args) { [pipeline.id] }
end
diff --git a/spec/workers/jira_connect/sync_project_worker_spec.rb b/spec/workers/jira_connect/sync_project_worker_spec.rb
index f7fa565d534..04cc3bec3af 100644
--- a/spec/workers/jira_connect/sync_project_worker_spec.rb
+++ b/spec/workers/jira_connect/sync_project_worker_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe JiraConnect::SyncProjectWorker, factory_default: :keep do
describe '#perform' do
- let_it_be(:project) { create_default(:project) }
+ let_it_be(:project) { create_default(:project).freeze }
let!(:mr_with_jira_title) { create(:merge_request, :unique_branches, title: 'TEST-123') }
let!(:mr_with_jira_description) { create(:merge_request, :unique_branches, description: 'TEST-323') }
let!(:mr_with_other_title) { create(:merge_request, :unique_branches) }
diff --git a/spec/workers/merge_requests/delete_source_branch_worker_spec.rb b/spec/workers/merge_requests/delete_source_branch_worker_spec.rb
new file mode 100644
index 00000000000..957adbbbd6e
--- /dev/null
+++ b/spec/workers/merge_requests/delete_source_branch_worker_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe MergeRequests::DeleteSourceBranchWorker do
+ let_it_be(:merge_request) { create(:merge_request) }
+ let_it_be(:user) { create(:user) }
+
+ let(:sha) { merge_request.source_branch_sha }
+ let(:worker) { described_class.new }
+
+ describe '#perform' do
+ context 'with a non-existing merge request' do
+ it 'does nothing' do
+ expect(::Branches::DeleteService).not_to receive(:new)
+ expect(::MergeRequests::RetargetChainService).not_to receive(:new)
+
+ worker.perform(non_existing_record_id, sha, user.id)
+ end
+ end
+
+ context 'with a non-existing user' do
+ it 'does nothing' do
+ expect(::Branches::DeleteService).not_to receive(:new)
+ expect(::MergeRequests::RetargetChainService).not_to receive(:new)
+
+ worker.perform(merge_request.id, sha, non_existing_record_id)
+ end
+ end
+
+ context 'with existing user and merge request' do
+ it 'calls service to delete source branch' do
+ expect_next_instance_of(::Branches::DeleteService) do |instance|
+ expect(instance).to receive(:execute).with(merge_request.source_branch)
+ end
+
+ worker.perform(merge_request.id, sha, user.id)
+ end
+
+ it 'calls service to try retarget merge requests' do
+ expect_next_instance_of(::MergeRequests::RetargetChainService) do |instance|
+ expect(instance).to receive(:execute).with(merge_request)
+ end
+
+ worker.perform(merge_request.id, sha, user.id)
+ end
+
+ context 'source branch sha does not match' do
+ it 'does nothing' do
+ expect(::Branches::DeleteService).not_to receive(:new)
+ expect(::MergeRequests::RetargetChainService).not_to receive(:new)
+
+ worker.perform(merge_request.id, 'new-source-branch-sha', user.id)
+ end
+ end
+ end
+
+ it_behaves_like 'an idempotent worker' do
+ let(:merge_request) { create(:merge_request) }
+ let(:job_args) { [merge_request.id, sha, user.id] }
+ end
+ end
+end
diff --git a/spec/workers/merge_worker_spec.rb b/spec/workers/merge_worker_spec.rb
index 97e8aeb616e..417e6edce96 100644
--- a/spec/workers/merge_worker_spec.rb
+++ b/spec/workers/merge_worker_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe MergeWorker do
source_project.repository.expire_branches_cache
end
- it 'clears cache of source repo after removing source branch' do
+ it 'clears cache of source repo after removing source branch', :sidekiq_inline do
expect(source_project.repository.branch_names).to include('markdown')
described_class.new.perform(
diff --git a/spec/workers/namespaces/in_product_marketing_emails_worker_spec.rb b/spec/workers/namespaces/in_product_marketing_emails_worker_spec.rb
index 722ecfc1dec..24143e8cf8a 100644
--- a/spec/workers/namespaces/in_product_marketing_emails_worker_spec.rb
+++ b/spec/workers/namespaces/in_product_marketing_emails_worker_spec.rb
@@ -3,25 +3,43 @@
require 'spec_helper'
RSpec.describe Namespaces::InProductMarketingEmailsWorker, '#perform' do
- context 'when the experiment is inactive' do
+ context 'when the application setting is enabled' do
before do
- stub_experiment(in_product_marketing_emails: false)
+ stub_application_setting(in_product_marketing_emails_enabled: true)
end
- it 'does not execute the in product marketing emails service' do
- expect(Namespaces::InProductMarketingEmailsService).not_to receive(:send_for_all_tracks_and_intervals)
+ context 'when the experiment is inactive' do
+ before do
+ stub_experiment(in_product_marketing_emails: false)
+ end
- subject.perform
+ it 'does not execute the in product marketing emails service' do
+ expect(Namespaces::InProductMarketingEmailsService).not_to receive(:send_for_all_tracks_and_intervals)
+
+ subject.perform
+ end
+ end
+
+ context 'when the experiment is active' do
+ before do
+ stub_experiment(in_product_marketing_emails: true)
+ end
+
+ it 'calls the send_for_all_tracks_and_intervals method on the in product marketing emails service' do
+ expect(Namespaces::InProductMarketingEmailsService).to receive(:send_for_all_tracks_and_intervals)
+
+ subject.perform
+ end
end
end
- context 'when the experiment is active' do
+ context 'when the application setting is disabled' do
before do
- stub_experiment(in_product_marketing_emails: true)
+ stub_application_setting(in_product_marketing_emails_enabled: false)
end
- it 'calls the send_for_all_tracks_and_intervals method on the in product marketing emails service' do
- expect(Namespaces::InProductMarketingEmailsService).to receive(:send_for_all_tracks_and_intervals)
+ it 'does not execute the in product marketing emails service' do
+ expect(Namespaces::InProductMarketingEmailsService).not_to receive(:send_for_all_tracks_and_intervals)
subject.perform
end
diff --git a/spec/workers/namespaces/onboarding_issue_created_worker_spec.rb b/spec/workers/namespaces/onboarding_issue_created_worker_spec.rb
new file mode 100644
index 00000000000..459e4f953d0
--- /dev/null
+++ b/spec/workers/namespaces/onboarding_issue_created_worker_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Namespaces::OnboardingIssueCreatedWorker, '#perform' do
+ let_it_be(:issue) { create(:issue) }
+ let(:namespace) { issue.namespace }
+
+ it_behaves_like 'records an onboarding progress action', :issue_created do
+ subject { described_class.new.perform(namespace.id) }
+ end
+
+ it_behaves_like 'does not record an onboarding progress action' do
+ subject { described_class.new.perform(nil) }
+ end
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { [namespace.id] }
+
+ it 'sets the onboarding progress action' do
+ OnboardingProgress.onboard(namespace)
+
+ subject
+
+ expect(OnboardingProgress.completed?(namespace, :issue_created)).to eq(true)
+ end
+ end
+end
diff --git a/spec/workers/namespaces/onboarding_progress_worker_spec.rb b/spec/workers/namespaces/onboarding_progress_worker_spec.rb
new file mode 100644
index 00000000000..76ac078ddcf
--- /dev/null
+++ b/spec/workers/namespaces/onboarding_progress_worker_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Namespaces::OnboardingProgressWorker, '#perform' do
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:action) { 'git_pull' }
+
+ it_behaves_like 'records an onboarding progress action', :git_pull do
+ include_examples 'an idempotent worker' do
+ subject { described_class.new.perform(namespace.id, action) }
+ end
+ end
+
+ it_behaves_like 'does not record an onboarding progress action' do
+ subject { described_class.new.perform(namespace.id, nil) }
+ end
+
+ it_behaves_like 'does not record an onboarding progress action' do
+ subject { described_class.new.perform(nil, action) }
+ end
+end
diff --git a/spec/workers/new_issue_worker_spec.rb b/spec/workers/new_issue_worker_spec.rb
index 7cba3487603..ec129ad3380 100644
--- a/spec/workers/new_issue_worker_spec.rb
+++ b/spec/workers/new_issue_worker_spec.rb
@@ -38,21 +38,48 @@ RSpec.describe NewIssueWorker do
end
end
- context 'when everything is ok' do
- let_it_be(:user) { create_default(:user) }
+ context 'with a user' do
let_it_be(:project) { create(:project, :public) }
let_it_be(:mentioned) { create(:user) }
+ let_it_be(:user) { nil }
let_it_be(:issue) { create(:issue, project: project, description: "issue for #{mentioned.to_reference}") }
- it 'creates a new event record' do
- expect { worker.perform(issue.id, user.id) }.to change { Event.count }.from(0).to(1)
+ shared_examples 'a new issue where the current_user cannot trigger notifications' do
+ it 'does not create a notification for the mentioned user' do
+ expect(Notify).not_to receive(:new_issue_email)
+ .with(mentioned.id, issue.id, NotificationReason::MENTIONED)
+
+ expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: user.id, klass: issue.class, object_id: issue.id)
+
+ worker.perform(issue.id, user.id)
+ end
+ end
+
+ context 'when the new issue author is blocked' do
+ let_it_be(:user) { create_default(:user, :blocked) }
+
+ it_behaves_like 'a new issue where the current_user cannot trigger notifications'
end
- it 'creates a notification for the mentioned user' do
- expect(Notify).to receive(:new_issue_email).with(mentioned.id, issue.id, NotificationReason::MENTIONED)
- .and_return(double(deliver_later: true))
+ context 'when the new issue author is a ghost' do
+ let_it_be(:user) { create_default(:user, :ghost) }
+
+ it_behaves_like 'a new issue where the current_user cannot trigger notifications'
+ end
+
+ context 'when everything is ok' do
+ let_it_be(:user) { create_default(:user) }
+
+ it 'creates a new event record' do
+ expect { worker.perform(issue.id, user.id) }.to change { Event.count }.from(0).to(1)
+ end
+
+ it 'creates a notification for the mentioned user' do
+ expect(Notify).to receive(:new_issue_email).with(mentioned.id, issue.id, NotificationReason::MENTIONED)
+ .and_return(double(deliver_later: true))
- worker.perform(issue.id, user.id)
+ worker.perform(issue.id, user.id)
+ end
end
end
end
diff --git a/spec/workers/new_merge_request_worker_spec.rb b/spec/workers/new_merge_request_worker_spec.rb
index 310fde4c7e1..0d64973b0fa 100644
--- a/spec/workers/new_merge_request_worker_spec.rb
+++ b/spec/workers/new_merge_request_worker_spec.rb
@@ -40,24 +40,51 @@ RSpec.describe NewMergeRequestWorker do
end
end
- context 'when everything is ok' do
+ context 'with a user' do
let(:project) { create(:project, :public) }
let(:mentioned) { create(:user) }
- let(:user) { create(:user) }
+ let(:user) { nil }
let(:merge_request) do
create(:merge_request, source_project: project, description: "mr for #{mentioned.to_reference}")
end
- it 'creates a new event record' do
- expect { worker.perform(merge_request.id, user.id) }.to change { Event.count }.from(0).to(1)
+ shared_examples 'a new merge request where the author cannot trigger notifications' do
+ it 'does not create a notification for the mentioned user' do
+ expect(Notify).not_to receive(:new_merge_request_email)
+ .with(mentioned.id, merge_request.id, NotificationReason::MENTIONED)
+
+ expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: user.id, klass: merge_request.class, object_id: merge_request.id)
+
+ worker.perform(merge_request.id, user.id)
+ end
+ end
+
+ context 'when the merge request author is blocked' do
+ let(:user) { create(:user, :blocked) }
+
+ it_behaves_like 'a new merge request where the author cannot trigger notifications'
end
- it 'creates a notification for the mentioned user' do
- expect(Notify).to receive(:new_merge_request_email)
- .with(mentioned.id, merge_request.id, NotificationReason::MENTIONED)
- .and_return(double(deliver_later: true))
+ context 'when the merge request author is a ghost' do
+ let(:user) { create(:user, :ghost) }
+
+ it_behaves_like 'a new merge request where the author cannot trigger notifications'
+ end
+
+ context 'when everything is ok' do
+ let(:user) { create(:user) }
+
+ it 'creates a new event record' do
+ expect { worker.perform(merge_request.id, user.id) }.to change { Event.count }.from(0).to(1)
+ end
+
+ it 'creates a notification for the mentioned user' do
+ expect(Notify).to receive(:new_merge_request_email)
+ .with(mentioned.id, merge_request.id, NotificationReason::MENTIONED)
+ .and_return(double(deliver_later: true))
- worker.perform(merge_request.id, user.id)
+ worker.perform(merge_request.id, user.id)
+ end
end
end
end
diff --git a/spec/workers/packages/composer/cache_update_worker_spec.rb b/spec/workers/packages/composer/cache_update_worker_spec.rb
new file mode 100644
index 00000000000..cc6b48c80eb
--- /dev/null
+++ b/spec/workers/packages/composer/cache_update_worker_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::Composer::CacheUpdateWorker, type: :worker do
+ describe '#perform' do
+ let_it_be(:package_name) { 'sample-project' }
+ let_it_be(:json) { { 'name' => package_name } }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, :custom_repo, files: { 'composer.json' => json.to_json }, group: group) }
+ let(:last_sha) { nil }
+ let!(:package) { create(:composer_package, :with_metadatum, project: project, name: package_name, version: '1.0.0', json: json) }
+ let(:job_args) { [project.id, package_name, last_sha] }
+
+ subject { described_class.new.perform(*job_args) }
+
+ before do
+ stub_composer_cache_object_storage
+ end
+
+ include_examples 'an idempotent worker' do
+ context 'creating a package' do
+ it 'updates the cache' do
+ expect { subject }.to change { Packages::Composer::CacheFile.count }.by(1)
+ end
+ end
+
+ context 'deleting a package' do
+ let!(:last_sha) do
+ Gitlab::Composer::Cache.new(project: project, name: package_name).execute
+ package.reload.composer_metadatum.version_cache_sha
+ end
+
+ before do
+ package.destroy!
+ end
+
+ it 'marks the file for deletion' do
+ expect { subject }.not_to change { Packages::Composer::CacheFile.count }
+
+ cache_file = Packages::Composer::CacheFile.last
+
+ expect(cache_file.reload.delete_at).not_to be_nil
+ end
+ end
+ end
+ end
+end
diff --git a/spec/workers/packages/maven/metadata/sync_worker_spec.rb b/spec/workers/packages/maven/metadata/sync_worker_spec.rb
new file mode 100644
index 00000000000..7e0f3616491
--- /dev/null
+++ b/spec/workers/packages/maven/metadata/sync_worker_spec.rb
@@ -0,0 +1,253 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::Maven::Metadata::SyncWorker, type: :worker do
+ let_it_be(:versionless_package_for_versions) { create(:maven_package, name: 'MyDummyMavenPkg', version: nil) }
+ let_it_be(:metadata_package_file) { create(:package_file, :xml, package: versionless_package_for_versions) }
+
+ let(:versions) { %w[1.2 1.1 2.1 3.0-SNAPSHOT] }
+ let(:worker) { described_class.new }
+
+ describe '#perform' do
+ let(:user) { create(:user) }
+ let(:project) { versionless_package_for_versions.project }
+ let(:package_name) { versionless_package_for_versions.name }
+ let(:role) { :maintainer }
+ let(:most_recent_metadata_file_for_versions) { versionless_package_for_versions.package_files.recent.with_file_name(Packages::Maven::Metadata.filename).first }
+
+ before do
+ project.send("add_#{role}", user)
+ end
+
+ subject { worker.perform(user.id, project.id, package_name) }
+
+ context 'with a jar' do
+ context 'with a valid package name' do
+ before do
+ metadata_package_file.update!(
+ file: CarrierWaveStringFile.new_file(
+ file_content: versions_xml_content,
+ filename: 'maven-metadata.xml',
+ content_type: 'application/xml'
+ )
+ )
+
+ versions.each do |version|
+ create(:maven_package, name: versionless_package_for_versions.name, version: version, project: versionless_package_for_versions.project)
+ end
+ end
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { [user.id, project.id, package_name] }
+
+ it 'creates the updated metadata files', :aggregate_failures do
+ expect { subject }.to change { ::Packages::PackageFile.count }.by(5)
+
+ most_recent_versions = versions_from(most_recent_metadata_file_for_versions.file.read)
+ expect(most_recent_versions.latest).to eq('3.0-SNAPSHOT')
+ expect(most_recent_versions.release).to eq('2.1')
+ expect(most_recent_versions.versions).to match_array(versions)
+ end
+ end
+
+ it 'logs the message from the service' do
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:message, 'New metadata package file created')
+
+ subject
+ end
+
+ context 'not in the passed project' do
+ let(:project) { create(:project) }
+
+ it 'does not create the updated metadata files' do
+ expect { subject }
+ .to change { ::Packages::PackageFile.count }.by(0)
+ .and raise_error(described_class::SyncError, 'Non existing versionless package')
+ end
+ end
+
+ context 'with a user with not enough permissions' do
+ let(:role) { :guest }
+
+ it 'does not create the updated metadata files' do
+ expect { subject }
+ .to change { ::Packages::PackageFile.count }.by(0)
+ .and raise_error(described_class::SyncError, 'Not allowed')
+ end
+ end
+ end
+ end
+
+ context 'with a maven plugin' do
+ let_it_be(:versionless_package_name_for_plugins) { versionless_package_for_versions.maven_metadatum.app_group.tr('.', '/') }
+ let_it_be(:versionless_package_for_versions) { create(:maven_package, name: "#{versionless_package_name_for_plugins}/one-maven-plugin", version: nil) }
+ let_it_be(:metadata_package_file) { create(:package_file, :xml, package: versionless_package_for_versions) }
+
+ let_it_be(:versionless_package_for_plugins) { create(:maven_package, name: versionless_package_name_for_plugins, version: nil, project: versionless_package_for_versions.project) }
+ let_it_be(:metadata_package_file_for_plugins) { create(:package_file, :xml, package: versionless_package_for_plugins) }
+
+ let_it_be(:addtional_maven_package_for_same_group_id) { create(:maven_package, name: "#{versionless_package_name_for_plugins}/maven-package", project: versionless_package_for_versions.project) }
+
+ let(:plugins) { %w[one-maven-plugin three-maven-plugin] }
+ let(:most_recent_metadata_file_for_plugins) { versionless_package_for_plugins.package_files.recent.with_file_name(Packages::Maven::Metadata.filename).first }
+
+ context 'with a valid package name' do
+ before do
+ versionless_package_for_versions.update!(name: package_name)
+
+ metadata_package_file.update!(
+ file: CarrierWaveStringFile.new_file(
+ file_content: versions_xml_content,
+ filename: 'maven-metadata.xml',
+ content_type: 'application/xml'
+ )
+ )
+
+ metadata_package_file_for_plugins.update!(
+ file: CarrierWaveStringFile.new_file(
+ file_content: plugins_xml_content,
+ filename: 'maven-metadata.xml',
+ content_type: 'application/xml'
+ )
+ )
+
+ plugins.each do |plugin|
+ versions.each do |version|
+ pkg = create(:maven_package, name: "#{versionless_package_name_for_plugins}/#{plugin}", version: version, project: versionless_package_for_versions.project)
+ pkg.maven_metadatum.update!(app_name: plugin)
+ end
+ end
+ end
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { [user.id, project.id, package_name] }
+
+ it 'creates the updated metadata files', :aggregate_failures do
+ expect { subject }.to change { ::Packages::PackageFile.count }.by(5 * 2) # the two xml files are updated
+
+ most_recent_versions = versions_from(most_recent_metadata_file_for_versions.file.read)
+ expect(most_recent_versions.latest).to eq('3.0-SNAPSHOT')
+ expect(most_recent_versions.release).to eq('2.1')
+ expect(most_recent_versions.versions).to match_array(versions)
+
+ plugins_from_xml = plugins_from(most_recent_metadata_file_for_plugins.file.read)
+ expect(plugins_from_xml).to match_array(plugins)
+ end
+ end
+
+ it 'logs the message from the service' do
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:message, 'New metadata package file created')
+
+ subject
+ end
+
+ context 'not in the passed project' do
+ let(:project) { create(:project) }
+
+ it 'does not create the updated metadata files' do
+ expect { subject }
+ .to change { ::Packages::PackageFile.count }.by(0)
+ .and raise_error(described_class::SyncError, 'Non existing versionless package')
+ end
+ end
+
+ context 'with a user with not enough permissions' do
+ let(:role) { :guest }
+
+ it 'does not create the updated metadata files' do
+ expect { subject }
+ .to change { ::Packages::PackageFile.count }.by(0)
+ .and raise_error(described_class::SyncError, 'Not allowed')
+ end
+ end
+ end
+ end
+
+ context 'with no package name' do
+ subject { worker.perform(user.id, project.id, nil) }
+
+ it 'does not run' do
+ expect(::Packages::Maven::Metadata::SyncService).not_to receive(:new)
+ expect { subject }.not_to change { ::Packages::PackageFile.count }
+ end
+ end
+
+ context 'with no user id' do
+ subject { worker.perform(nil, project.id, package_name) }
+
+ it 'does not run' do
+ expect(::Packages::Maven::Metadata::SyncService).not_to receive(:new)
+ expect { subject }.not_to change { ::Packages::PackageFile.count }
+ end
+ end
+
+ context 'with no project id' do
+ subject { worker.perform(user.id, nil, package_name) }
+
+ it 'does not run' do
+ expect(::Packages::Maven::Metadata::SyncService).not_to receive(:new)
+ expect { subject }.not_to change { ::Packages::PackageFile.count }
+ end
+ end
+ end
+
+ def versions_from(xml_content)
+ xml_doc = Nokogiri::XML(xml_content)
+
+ OpenStruct.new(
+ release: xml_doc.xpath('//metadata/versioning/release').first.content,
+ latest: xml_doc.xpath('//metadata/versioning/latest').first.content,
+ versions: xml_doc.xpath('//metadata/versioning/versions/version').map(&:content)
+ )
+ end
+
+ def plugins_from(xml_content)
+ xml_doc = Nokogiri::XML(xml_content)
+
+ xml_doc.xpath('//metadata/plugins/plugin/name').map(&:content)
+ end
+
+ def versions_xml_content
+ Nokogiri::XML::Builder.new do |xml|
+ xml.metadata do
+ xml.groupId(versionless_package_for_versions.maven_metadatum.app_group)
+ xml.artifactId(versionless_package_for_versions.maven_metadatum.app_name)
+ xml.versioning do
+ xml.release('1.3')
+ xml.latest('1.3')
+ xml.lastUpdated('20210113130531')
+ xml.versions do
+ xml.version('1.1')
+ xml.version('1.2')
+ xml.version('1.3')
+ end
+ end
+ end
+ end.to_xml
+ end
+
+ def plugins_xml_content
+ Nokogiri::XML::Builder.new do |xml|
+ xml.metadata do
+ xml.plugins do
+ xml.plugin do
+ xml.name('one-maven-plugin')
+ xml.prefix('one')
+ xml.artifactId('one-maven-plugin')
+ end
+ xml.plugin do
+ xml.name('two-maven-plugin')
+ xml.prefix('two')
+ xml.artifactId('two-maven-plugin')
+ end
+ xml.plugin do
+ xml.name('three-maven-plugin')
+ xml.prefix('three')
+ xml.artifactId('three-maven-plugin')
+ end
+ end
+ end
+ end.to_xml
+ end
+end
diff --git a/spec/workers/pages_update_configuration_worker_spec.rb b/spec/workers/pages_update_configuration_worker_spec.rb
index 87bbff1a28b..ff3727646c7 100644
--- a/spec/workers/pages_update_configuration_worker_spec.rb
+++ b/spec/workers/pages_update_configuration_worker_spec.rb
@@ -2,9 +2,9 @@
require "spec_helper"
RSpec.describe PagesUpdateConfigurationWorker do
- describe "#perform" do
- let_it_be(:project) { create(:project) }
+ let_it_be(:project) { create(:project) }
+ describe "#perform" do
it "does not break if the project doesn't exist" do
expect { subject.perform(-1) }.not_to raise_error
end
@@ -42,4 +42,22 @@ RSpec.describe PagesUpdateConfigurationWorker do
end
end
end
+
+ describe '#perform_async' do
+ it "calls the correct service", :sidekiq_inline do
+ expect_next_instance_of(Projects::UpdatePagesConfigurationService, project) do |service|
+ expect(service).to receive(:execute).and_return(status: :success)
+ end
+
+ described_class.perform_async(project.id)
+ end
+
+ it "doesn't schedule a worker if updates on legacy storage are disabled", :sidekiq_inline do
+ stub_feature_flags(pages_update_legacy_storage: false)
+
+ expect(Projects::UpdatePagesConfigurationService).not_to receive(:new)
+
+ described_class.perform_async(project.id)
+ end
+ end
end
diff --git a/spec/workers/personal_access_tokens/expiring_worker_spec.rb b/spec/workers/personal_access_tokens/expiring_worker_spec.rb
index c8bdf02f4d3..7fa777b911a 100644
--- a/spec/workers/personal_access_tokens/expiring_worker_spec.rb
+++ b/spec/workers/personal_access_tokens/expiring_worker_spec.rb
@@ -7,18 +7,23 @@ RSpec.describe PersonalAccessTokens::ExpiringWorker, type: :worker do
describe '#perform' do
context 'when a token needs to be notified' do
- let_it_be(:pat) { create(:personal_access_token, expires_at: 5.days.from_now) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:expiring_token) { create(:personal_access_token, user: user, expires_at: 5.days.from_now) }
+ let_it_be(:expiring_token2) { create(:personal_access_token, user: user, expires_at: 3.days.from_now) }
+ let_it_be(:notified_token) { create(:personal_access_token, user: user, expires_at: 5.days.from_now, expire_notification_delivered: true) }
+ let_it_be(:not_expiring_token) { create(:personal_access_token, user: user, expires_at: 1.month.from_now) }
+ let_it_be(:impersonation_token) { create(:personal_access_token, user: user, expires_at: 5.days.from_now, impersonation: true) }
it 'uses notification service to send the email' do
expect_next_instance_of(NotificationService) do |notification_service|
- expect(notification_service).to receive(:access_token_about_to_expire).with(pat.user)
+ expect(notification_service).to receive(:access_token_about_to_expire).with(user, match_array([expiring_token.name, expiring_token2.name]))
end
worker.perform
end
it 'marks the notification as delivered' do
- expect { worker.perform }.to change { pat.reload.expire_notification_delivered }.from(false).to(true)
+ expect { worker.perform }.to change { expiring_token.reload.expire_notification_delivered }.from(false).to(true)
end
end
@@ -27,7 +32,7 @@ RSpec.describe PersonalAccessTokens::ExpiringWorker, type: :worker do
it "doesn't use notification service to send the email" do
expect_next_instance_of(NotificationService) do |notification_service|
- expect(notification_service).not_to receive(:access_token_about_to_expire).with(pat.user)
+ expect(notification_service).not_to receive(:access_token_about_to_expire).with(pat.user, [pat.name])
end
worker.perform
@@ -43,7 +48,7 @@ RSpec.describe PersonalAccessTokens::ExpiringWorker, type: :worker do
it "doesn't use notification service to send the email" do
expect_next_instance_of(NotificationService) do |notification_service|
- expect(notification_service).not_to receive(:access_token_about_to_expire).with(pat.user)
+ expect(notification_service).not_to receive(:access_token_about_to_expire).with(pat.user, [pat.name])
end
worker.perform
diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb
index aaae0988602..be501318920 100644
--- a/spec/workers/post_receive_spec.rb
+++ b/spec/workers/post_receive_spec.rb
@@ -93,6 +93,29 @@ RSpec.describe PostReceive do
perform
end
+
+ it 'tracks an event for the empty_repo_upload experiment', :snowplow do
+ allow_next_instance_of(ApplicationExperiment) do |e|
+ allow(e).to receive(:should_track?).and_return(true)
+ allow(e).to receive(:track_initial_writes)
+ end
+
+ perform
+
+ expect_snowplow_event(category: 'empty_repo_upload', action: 'initial_write', context: [{ schema: 'iglu:com.gitlab/gitlab_experiment/jsonschema/0-3-0', data: anything }])
+ end
+
+ it 'does not track an event for the empty_repo_upload experiment when project is not empty', :snowplow do
+ allow(empty_project).to receive(:empty_repo?).and_return(false)
+ allow_next_instance_of(ApplicationExperiment) do |e|
+ allow(e).to receive(:should_track?).and_return(true)
+ allow(e).to receive(:track_initial_writes)
+ end
+
+ perform
+
+ expect_no_snowplow_event
+ end
end
shared_examples 'not updating remote mirrors' do
@@ -159,7 +182,7 @@ RSpec.describe PostReceive do
end
it 'expires the status cache' do
- expect(project.repository).to receive(:empty?).and_return(true)
+ expect(project.repository).to receive(:empty?).at_least(:once).and_return(true)
expect(project.repository).to receive(:expire_status_cache)
perform
diff --git a/spec/workers/project_schedule_bulk_repository_shard_moves_worker_spec.rb b/spec/workers/project_schedule_bulk_repository_shard_moves_worker_spec.rb
index fb762593d75..f284e1ab8c6 100644
--- a/spec/workers/project_schedule_bulk_repository_shard_moves_worker_spec.rb
+++ b/spec/workers/project_schedule_bulk_repository_shard_moves_worker_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe ProjectScheduleBulkRepositoryShardMovesWorker do
it_behaves_like 'schedules bulk repository shard moves' do
let_it_be_with_reload(:container) { create(:project, :repository).tap { |project| project.track_project_repository } }
- let(:move_service_klass) { ProjectRepositoryStorageMove }
- let(:worker_klass) { ProjectUpdateRepositoryStorageWorker }
+ let(:move_service_klass) { Projects::RepositoryStorageMove }
+ let(:worker_klass) { Projects::UpdateRepositoryStorageWorker }
end
end
diff --git a/spec/workers/project_update_repository_storage_worker_spec.rb b/spec/workers/project_update_repository_storage_worker_spec.rb
index 490f1f5a2ad..6924e8a93a3 100644
--- a/spec/workers/project_update_repository_storage_worker_spec.rb
+++ b/spec/workers/project_update_repository_storage_worker_spec.rb
@@ -10,6 +10,6 @@ RSpec.describe ProjectUpdateRepositoryStorageWorker do
let_it_be(:repository_storage_move) { create(:project_repository_storage_move) }
let(:service_klass) { Projects::UpdateRepositoryStorageService }
- let(:repository_storage_move_klass) { ProjectRepositoryStorageMove }
+ let(:repository_storage_move_klass) { Projects::RepositoryStorageMove }
end
end
diff --git a/spec/workers/projects/schedule_bulk_repository_shard_moves_worker_spec.rb b/spec/workers/projects/schedule_bulk_repository_shard_moves_worker_spec.rb
new file mode 100644
index 00000000000..24957a35b72
--- /dev/null
+++ b/spec/workers/projects/schedule_bulk_repository_shard_moves_worker_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::ScheduleBulkRepositoryShardMovesWorker do
+ it_behaves_like 'schedules bulk repository shard moves' do
+ let_it_be_with_reload(:container) { create(:project, :repository).tap { |project| project.track_project_repository } }
+
+ let(:move_service_klass) { Projects::RepositoryStorageMove }
+ let(:worker_klass) { Projects::UpdateRepositoryStorageWorker }
+ end
+end
diff --git a/spec/workers/projects/update_repository_storage_worker_spec.rb b/spec/workers/projects/update_repository_storage_worker_spec.rb
new file mode 100644
index 00000000000..7570d706325
--- /dev/null
+++ b/spec/workers/projects/update_repository_storage_worker_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::UpdateRepositoryStorageWorker do
+ subject { described_class.new }
+
+ it_behaves_like 'an update storage move worker' do
+ let_it_be_with_refind(:container) { create(:project, :repository) }
+ let_it_be(:repository_storage_move) { create(:project_repository_storage_move) }
+
+ let(:service_klass) { Projects::UpdateRepositoryStorageService }
+ let(:repository_storage_move_klass) { Projects::RepositoryStorageMove }
+ end
+end
diff --git a/spec/workers/purge_dependency_proxy_cache_worker_spec.rb b/spec/workers/purge_dependency_proxy_cache_worker_spec.rb
index 8379b11af8f..53f8d1bf5ba 100644
--- a/spec/workers/purge_dependency_proxy_cache_worker_spec.rb
+++ b/spec/workers/purge_dependency_proxy_cache_worker_spec.rb
@@ -26,19 +26,25 @@ RSpec.describe PurgeDependencyProxyCacheWorker do
end
context 'an admin user' do
- include_examples 'an idempotent worker' do
- let(:job_args) { [user.id, group_id] }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ include_examples 'an idempotent worker' do
+ let(:job_args) { [user.id, group_id] }
- it 'deletes the blobs and returns ok', :aggregate_failures do
- expect(group.dependency_proxy_blobs.size).to eq(1)
- expect(group.dependency_proxy_manifests.size).to eq(1)
+ it 'deletes the blobs and returns ok', :aggregate_failures do
+ expect(group.dependency_proxy_blobs.size).to eq(1)
+ expect(group.dependency_proxy_manifests.size).to eq(1)
- subject
+ subject
- expect(group.dependency_proxy_blobs.size).to eq(0)
- expect(group.dependency_proxy_manifests.size).to eq(0)
+ expect(group.dependency_proxy_blobs.size).to eq(0)
+ expect(group.dependency_proxy_manifests.size).to eq(0)
+ end
end
end
+
+ context 'when admin mode is disabled' do
+ it_behaves_like 'returns nil'
+ end
end
context 'a non-admin user' do
diff --git a/spec/workers/snippet_schedule_bulk_repository_shard_moves_worker_spec.rb b/spec/workers/snippet_schedule_bulk_repository_shard_moves_worker_spec.rb
index 3a09b6ce449..a5f1c6b7b3d 100644
--- a/spec/workers/snippet_schedule_bulk_repository_shard_moves_worker_spec.rb
+++ b/spec/workers/snippet_schedule_bulk_repository_shard_moves_worker_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe SnippetScheduleBulkRepositoryShardMovesWorker do
it_behaves_like 'schedules bulk repository shard moves' do
let_it_be_with_reload(:container) { create(:snippet, :repository).tap { |snippet| snippet.create_repository } }
- let(:move_service_klass) { SnippetRepositoryStorageMove }
- let(:worker_klass) { SnippetUpdateRepositoryStorageWorker }
+ let(:move_service_klass) { Snippets::RepositoryStorageMove }
+ let(:worker_klass) { Snippets::UpdateRepositoryStorageWorker }
end
end
diff --git a/spec/workers/snippet_update_repository_storage_worker_spec.rb b/spec/workers/snippet_update_repository_storage_worker_spec.rb
index a48abe4abf7..205cb2e432f 100644
--- a/spec/workers/snippet_update_repository_storage_worker_spec.rb
+++ b/spec/workers/snippet_update_repository_storage_worker_spec.rb
@@ -10,6 +10,6 @@ RSpec.describe SnippetUpdateRepositoryStorageWorker do
let_it_be(:repository_storage_move) { create(:snippet_repository_storage_move) }
let(:service_klass) { Snippets::UpdateRepositoryStorageService }
- let(:repository_storage_move_klass) { SnippetRepositoryStorageMove }
+ let(:repository_storage_move_klass) { Snippets::RepositoryStorageMove }
end
end
diff --git a/spec/workers/snippets/schedule_bulk_repository_shard_moves_worker_spec.rb b/spec/workers/snippets/schedule_bulk_repository_shard_moves_worker_spec.rb
new file mode 100644
index 00000000000..be7d8ebe2d3
--- /dev/null
+++ b/spec/workers/snippets/schedule_bulk_repository_shard_moves_worker_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Snippets::ScheduleBulkRepositoryShardMovesWorker do
+ it_behaves_like 'schedules bulk repository shard moves' do
+ let_it_be_with_reload(:container) { create(:snippet, :repository).tap { |snippet| snippet.create_repository } }
+
+ let(:move_service_klass) { Snippets::RepositoryStorageMove }
+ let(:worker_klass) { Snippets::UpdateRepositoryStorageWorker }
+ end
+end
diff --git a/spec/workers/snippets/update_repository_storage_worker_spec.rb b/spec/workers/snippets/update_repository_storage_worker_spec.rb
new file mode 100644
index 00000000000..38e9012e9c5
--- /dev/null
+++ b/spec/workers/snippets/update_repository_storage_worker_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Snippets::UpdateRepositoryStorageWorker do
+ subject { described_class.new }
+
+ it_behaves_like 'an update storage move worker' do
+ let_it_be_with_refind(:container) { create(:snippet, :repository) }
+ let_it_be(:repository_storage_move) { create(:snippet_repository_storage_move) }
+
+ let(:service_klass) { Snippets::UpdateRepositoryStorageService }
+ let(:repository_storage_move_klass) { Snippets::RepositoryStorageMove }
+ end
+end