summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-09-20 13:18:24 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-09-20 13:18:24 +0000
commit0653e08efd039a5905f3fa4f6e9cef9f5d2f799c (patch)
tree4dcc884cf6d81db44adae4aa99f8ec1233a41f55 /spec
parent744144d28e3e7fddc117924fef88de5d9674fe4c (diff)
downloadgitlab-ce-0653e08efd039a5905f3fa4f6e9cef9f5d2f799c.tar.gz
Add latest changes from gitlab-org/gitlab@14-3-stable-eev14.3.0-rc42
Diffstat (limited to 'spec')
-rw-r--r--spec/bin/sidekiq_cluster_spec.rb31
-rw-r--r--spec/config/grape_entity_patch_spec.rb21
-rw-r--r--spec/controllers/admin/integrations_controller_spec.rb8
-rw-r--r--spec/controllers/admin/runners_controller_spec.rb28
-rw-r--r--spec/controllers/admin/users_controller_spec.rb4
-rw-r--r--spec/controllers/boards/issues_controller_spec.rb10
-rw-r--r--spec/controllers/explore/projects_controller_spec.rb63
-rw-r--r--spec/controllers/groups/children_controller_spec.rb4
-rw-r--r--spec/controllers/groups/runners_controller_spec.rb13
-rw-r--r--spec/controllers/groups/settings/integrations_controller_spec.rb15
-rw-r--r--spec/controllers/groups_controller_spec.rb51
-rw-r--r--spec/controllers/import/manifest_controller_spec.rb10
-rw-r--r--spec/controllers/invites_controller_spec.rb26
-rw-r--r--spec/controllers/omniauth_callbacks_controller_spec.rb4
-rw-r--r--spec/controllers/profiles/two_factor_auths_controller_spec.rb52
-rw-r--r--spec/controllers/projects/analytics/cycle_analytics/summary_controller_spec.rb36
-rw-r--r--spec/controllers/projects/environments_controller_spec.rb10
-rw-r--r--spec/controllers/projects/feature_flags_controller_spec.rb270
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb20
-rw-r--r--spec/controllers/projects/jobs_controller_spec.rb33
-rw-r--r--spec/controllers/projects/learn_gitlab_controller_spec.rb13
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb9
-rw-r--r--spec/controllers/projects/pipelines_controller_spec.rb69
-rw-r--r--spec/controllers/projects/services_controller_spec.rb12
-rw-r--r--spec/controllers/registrations/experience_levels_controller_spec.rb159
-rw-r--r--spec/controllers/registrations_controller_spec.rb54
-rw-r--r--spec/controllers/search_controller_spec.rb31
-rw-r--r--spec/controllers/user_callouts_controller_spec.rb11
-rw-r--r--spec/db/development/create_base_work_item_types_spec.rb9
-rw-r--r--spec/db/production/create_base_work_item_types_spec.rb9
-rw-r--r--spec/db/schema_spec.rb3
-rw-r--r--spec/deprecation_toolkit_env.rb7
-rw-r--r--spec/experiments/security_reports_mr_widget_prompt_experiment_spec.rb15
-rw-r--r--spec/factories/ci/build_trace_metadata.rb7
-rw-r--r--spec/factories/ci/builds.rb8
-rw-r--r--spec/factories/ci/pending_builds.rb1
-rw-r--r--spec/factories/ci/reports/security/flags.rb15
-rw-r--r--spec/factories/clusters/agents.rb1
-rw-r--r--spec/factories/clusters/agents/group_authorizations.rb10
-rw-r--r--spec/factories/clusters/agents/project_authorizations.rb10
-rw-r--r--spec/factories/compares.rb22
-rw-r--r--spec/factories/customer_relations/contacts.rb14
-rw-r--r--spec/factories/dependency_proxy.rb2
-rw-r--r--spec/factories/dependency_proxy/image_ttl_group_policies.rb10
-rw-r--r--spec/factories/integration_data.rb10
-rw-r--r--spec/factories/integrations.rb32
-rw-r--r--spec/factories/issues.rb2
-rw-r--r--spec/factories/namespaces/project_namespaces.rb12
-rw-r--r--spec/factories/operations/feature_flag_scopes.rb10
-rw-r--r--spec/factories/operations/feature_flags.rb8
-rw-r--r--spec/factories/packages.rb10
-rw-r--r--spec/factories/packages/helm/file_metadatum.rb10
-rw-r--r--spec/factories/packages/package_file.rb3
-rw-r--r--spec/factories/plan_limits.rb2
-rw-r--r--spec/factories/project_topics.rb8
-rw-r--r--spec/factories/topics.rb7
-rw-r--r--spec/factories/users.rb4
-rw-r--r--spec/factories/users/group_user_callouts.rb10
-rw-r--r--spec/factories/work_item/work_item_types.rb11
-rw-r--r--spec/factories_spec.rb1
-rw-r--r--spec/fast_spec_helper.rb2
-rw-r--r--spec/features/admin/admin_runners_spec.rb4
-rw-r--r--spec/features/admin/admin_sees_background_migrations_spec.rb39
-rw-r--r--spec/features/admin/admin_settings_spec.rb87
-rw-r--r--spec/features/admin/admin_users_impersonation_tokens_spec.rb10
-rw-r--r--spec/features/atom/dashboard_issues_spec.rb16
-rw-r--r--spec/features/atom/issues_spec.rb94
-rw-r--r--spec/features/atom/merge_requests_spec.rb88
-rw-r--r--spec/features/boards/multi_select_spec.rb4
-rw-r--r--spec/features/boards/sidebar_labels_spec.rb3
-rw-r--r--spec/features/boards/user_adds_lists_to_board_spec.rb67
-rw-r--r--spec/features/clusters/cluster_health_dashboard_spec.rb4
-rw-r--r--spec/features/commit_spec.rb2
-rw-r--r--spec/features/cycle_analytics_spec.rb99
-rw-r--r--spec/features/global_search_spec.rb70
-rw-r--r--spec/features/groups/board_sidebar_spec.rb26
-rw-r--r--spec/features/groups/issues_spec.rb39
-rw-r--r--spec/features/groups/members/request_access_spec.rb2
-rw-r--r--spec/features/groups/packages_spec.rb8
-rw-r--r--spec/features/groups/settings/packages_and_registries_spec.rb5
-rw-r--r--spec/features/groups/settings/repository_spec.rb2
-rw-r--r--spec/features/groups/show_spec.rb167
-rw-r--r--spec/features/ics/dashboard_issues_spec.rb16
-rw-r--r--spec/features/ics/group_issues_spec.rb16
-rw-r--r--spec/features/ics/project_issues_spec.rb16
-rw-r--r--spec/features/incidents/user_views_incident_spec.rb2
-rw-r--r--spec/features/invites_spec.rb24
-rw-r--r--spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb10
-rw-r--r--spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb2
-rw-r--r--spec/features/issues/csv_spec.rb2
-rw-r--r--spec/features/issues/filtered_search/filter_issues_spec.rb9
-rw-r--r--spec/features/issues/filtered_search/search_bar_spec.rb4
-rw-r--r--spec/features/issues/filtered_search/visual_tokens_spec.rb3
-rw-r--r--spec/features/issues/issue_detail_spec.rb15
-rw-r--r--spec/features/issues/issue_sidebar_spec.rb2
-rw-r--r--spec/features/issues/resource_label_events_spec.rb2
-rw-r--r--spec/features/issues/rss_spec.rb39
-rw-r--r--spec/features/issues/user_edits_issue_spec.rb1
-rw-r--r--spec/features/issues/user_views_issue_spec.rb2
-rw-r--r--spec/features/issues/user_views_issues_spec.rb6
-rw-r--r--spec/features/labels_hierarchy_spec.rb124
-rw-r--r--spec/features/merge_request/batch_comments_spec.rb4
-rw-r--r--spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb2
-rw-r--r--spec/features/merge_requests/rss_spec.rb46
-rw-r--r--spec/features/merge_requests/user_sees_empty_state_spec.rb21
-rw-r--r--spec/features/profiles/personal_access_tokens_spec.rb14
-rw-r--r--spec/features/profiles/user_edit_profile_spec.rb29
-rw-r--r--spec/features/projects/ci/editor_spec.rb4
-rw-r--r--spec/features/projects/commits/user_browses_commits_spec.rb8
-rw-r--r--spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb16
-rw-r--r--spec/features/projects/import_export/import_file_spec.rb1
-rw-r--r--spec/features/projects/jobs/permissions_spec.rb2
-rw-r--r--spec/features/projects/jobs/user_browses_job_spec.rb2
-rw-r--r--spec/features/projects/members/user_requests_access_spec.rb2
-rw-r--r--spec/features/projects/new_project_spec.rb4
-rw-r--r--spec/features/projects/package_files_spec.rb14
-rw-r--r--spec/features/projects/packages_spec.rb8
-rw-r--r--spec/features/projects/services/user_activates_slack_slash_command_spec.rb4
-rw-r--r--spec/features/projects/settings/access_tokens_spec.rb14
-rw-r--r--spec/features/projects/settings/monitor_settings_spec.rb27
-rw-r--r--spec/features/projects/settings/repository_settings_spec.rb2
-rw-r--r--spec/features/projects/settings/service_desk_setting_spec.rb1
-rw-r--r--spec/features/projects/settings/user_manages_project_members_spec.rb11
-rw-r--r--spec/features/projects/user_creates_project_spec.rb1
-rw-r--r--spec/features/registrations/experience_level_spec.rb44
-rw-r--r--spec/features/users/anonymous_sessions_spec.rb24
-rw-r--r--spec/features/users/login_spec.rb13
-rw-r--r--spec/features/users/show_spec.rb59
-rw-r--r--spec/finders/branches_finder_spec.rb7
-rw-r--r--spec/finders/ci/pipelines_finder_spec.rb37
-rw-r--r--spec/finders/ci/runners_finder_spec.rb126
-rw-r--r--spec/finders/error_tracking/errors_finder_spec.rb18
-rw-r--r--spec/finders/feature_flags_finder_spec.rb8
-rw-r--r--spec/finders/groups/user_groups_finder_spec.rb106
-rw-r--r--spec/finders/issues_finder/params_spec.rb49
-rw-r--r--spec/finders/issues_finder_spec.rb429
-rw-r--r--spec/finders/merge_requests_finder_spec.rb96
-rw-r--r--spec/finders/packages/helm/package_files_finder_spec.rb35
-rw-r--r--spec/finders/packages/helm/packages_finder_spec.rb74
-rw-r--r--spec/finders/packages/npm/package_finder_spec.rb61
-rw-r--r--spec/finders/projects_finder_spec.rb28
-rw-r--r--spec/finders/repositories/tree_finder_spec.rb95
-rw-r--r--spec/fixtures/api/schemas/feature_flag.json7
-rw-r--r--spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric.yml2
-rw-r--r--spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_ee.yml2
-rw-r--r--spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_name_suggestions.yml2
-rw-r--r--spec/frontend/__helpers__/emoji.js5
-rw-r--r--spec/frontend/__helpers__/local_storage_helper.js4
-rw-r--r--spec/frontend/__helpers__/mock_window_location_helper.js4
-rw-r--r--spec/frontend/__helpers__/test_apollo_link.js46
-rw-r--r--spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap4
-rw-r--r--spec/frontend/api/projects_api_spec.js62
-rw-r--r--spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js2
-rw-r--r--spec/frontend/autosave_spec.js8
-rw-r--r--spec/frontend/batch_comments/components/review_bar_spec.js42
-rw-r--r--spec/frontend/batch_comments/create_batch_comments_store.js15
-rw-r--r--spec/frontend/blob/notebook/notebook_viever_spec.js3
-rw-r--r--spec/frontend/boards/board_card_inner_spec.js33
-rw-r--r--spec/frontend/boards/board_list_deprecated_spec.js274
-rw-r--r--spec/frontend/boards/board_new_issue_deprecated_spec.js211
-rw-r--r--spec/frontend/boards/boards_store_spec.js1013
-rw-r--r--spec/frontend/boards/components/board_add_new_column_spec.js1
-rw-r--r--spec/frontend/boards/components/board_app_spec.js54
-rw-r--r--spec/frontend/boards/components/board_card_deprecated_spec.js219
-rw-r--r--spec/frontend/boards/components/board_card_layout_deprecated_spec.js158
-rw-r--r--spec/frontend/boards/components/board_column_deprecated_spec.js106
-rw-r--r--spec/frontend/boards/components/board_content_spec.js75
-rw-r--r--spec/frontend/boards/components/board_filtered_search_spec.js22
-rw-r--r--spec/frontend/boards/components/board_list_header_deprecated_spec.js174
-rw-r--r--spec/frontend/boards/components/board_settings_sidebar_spec.js142
-rw-r--r--spec/frontend/boards/components/boards_selector_deprecated_spec.js214
-rw-r--r--spec/frontend/boards/components/issue_time_estimate_deprecated_spec.js64
-rw-r--r--spec/frontend/boards/issue_card_deprecated_spec.js332
-rw-r--r--spec/frontend/boards/issue_spec.js162
-rw-r--r--spec/frontend/boards/list_spec.js230
-rw-r--r--spec/frontend/boards/mock_data.js89
-rw-r--r--spec/frontend/boards/project_select_deprecated_spec.js263
-rw-r--r--spec/frontend/boards/stores/actions_spec.js23
-rw-r--r--spec/frontend/boards/stores/getters_spec.js16
-rw-r--r--spec/frontend/boards/stores/mutations_spec.js6
-rw-r--r--spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap18
-rw-r--r--spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap26
-rw-r--r--spec/frontend/confidential_merge_request/components/project_form_group_spec.js42
-rw-r--r--spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap9
-rw-r--r--spec/frontend/content_editor/components/content_editor_spec.js10
-rw-r--r--spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js193
-rw-r--r--spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js37
-rw-r--r--spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js37
-rw-r--r--spec/frontend/content_editor/extensions/attachment_spec.js112
-rw-r--r--spec/frontend/content_editor/extensions/blockquote_spec.js19
-rw-r--r--spec/frontend/content_editor/extensions/code_block_highlight_spec.js17
-rw-r--r--spec/frontend/content_editor/markdown_processing_examples.js9
-rw-r--r--spec/frontend/content_editor/markdown_processing_spec.js5
-rw-r--r--spec/frontend/content_editor/services/mark_utils_spec.js38
-rw-r--r--spec/frontend/content_editor/services/markdown_serializer_spec.js1008
-rw-r--r--spec/frontend/content_editor/services/markdown_sourcemap_spec.js81
-rw-r--r--spec/frontend/content_editor/test_utils.js4
-rw-r--r--spec/frontend/cycle_analytics/banner_spec.js47
-rw-r--r--spec/frontend/cycle_analytics/base_spec.js28
-rw-r--r--spec/frontend/cycle_analytics/stage_table_spec.js19
-rw-r--r--spec/frontend/cycle_analytics/store/actions_spec.js61
-rw-r--r--spec/frontend/cycle_analytics/store/mutations_spec.js11
-rw-r--r--spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js26
-rw-r--r--spec/frontend/deploy_freeze/helpers.js2
-rw-r--r--spec/frontend/deploy_freeze/store/actions_spec.js45
-rw-r--r--spec/frontend/deploy_freeze/store/mutations_spec.js6
-rw-r--r--spec/frontend/deprecated_jquery_dropdown_spec.js2
-rw-r--r--spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap4
-rw-r--r--spec/frontend/design_management/components/design_scaler_spec.js20
-rw-r--r--spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap8
-rw-r--r--spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap8
-rw-r--r--spec/frontend/design_management/pages/design/index_spec.js27
-rw-r--r--spec/frontend/design_management/pages/index_spec.js17
-rw-r--r--spec/frontend/design_management/utils/design_management_utils_spec.js8
-rw-r--r--spec/frontend/diffs/components/app_spec.js22
-rw-r--r--spec/frontend/diffs/components/diff_file_spec.js38
-rw-r--r--spec/frontend/diffs/create_diffs_store.js6
-rw-r--r--spec/frontend/diffs/store/actions_spec.js7
-rw-r--r--spec/frontend/diffs/store/mutations_spec.js6
-rw-r--r--spec/frontend/diffs/utils/preferences_spec.js32
-rw-r--r--spec/frontend/dropzone_input_spec.js48
-rw-r--r--spec/frontend/emoji/index_spec.js13
-rw-r--r--spec/frontend/emoji/support/unicode_support_map_spec.js6
-rw-r--r--spec/frontend/environments/edit_environment_spec.js52
-rw-r--r--spec/frontend/environments/environment_form_spec.js48
-rw-r--r--spec/frontend/environments/environment_item_spec.js7
-rw-r--r--spec/frontend/environments/environment_table_spec.js11
-rw-r--r--spec/frontend/environments/environments_app_spec.js51
-rw-r--r--spec/frontend/environments/environments_detail_header_spec.js8
-rw-r--r--spec/frontend/environments/environments_folder_view_spec.js1
-rw-r--r--spec/frontend/environments/folder/environments_folder_view_spec.js1
-rw-r--r--spec/frontend/error_tracking_settings/components/app_spec.js80
-rw-r--r--spec/frontend/error_tracking_settings/mock.js4
-rw-r--r--spec/frontend/error_tracking_settings/store/actions_spec.js6
-rw-r--r--spec/frontend/error_tracking_settings/store/mutation_spec.js8
-rw-r--r--spec/frontend/error_tracking_settings/utils_spec.js2
-rw-r--r--spec/frontend/experimentation/utils_spec.js44
-rw-r--r--spec/frontend/filtered_search/services/recent_searches_service_spec.js6
-rw-r--r--spec/frontend/fixtures/api_markdown.yml114
-rw-r--r--spec/frontend/fixtures/freeze_period.rb9
-rw-r--r--spec/frontend/fixtures/runner.rb47
-rw-r--r--spec/frontend/fixtures/startup_css.rb15
-rw-r--r--spec/frontend/fixtures/static/pipeline_graph.html24
-rw-r--r--spec/frontend/fixtures/timezones.rb22
-rw-r--r--spec/frontend/frequent_items/store/actions_spec.js2
-rw-r--r--spec/frontend/groups/components/app_spec.js7
-rw-r--r--spec/frontend/groups/components/groups_spec.js13
-rw-r--r--spec/frontend/groups/components/invite_members_banner_spec.js77
-rw-r--r--spec/frontend/groups/components/item_stats_spec.js30
-rw-r--r--spec/frontend/header_search/components/app_spec.js159
-rw-r--r--spec/frontend/header_search/components/header_search_default_items_spec.js81
-rw-r--r--spec/frontend/header_search/components/header_search_scoped_items_spec.js61
-rw-r--r--spec/frontend/header_search/mock_data.js83
-rw-r--r--spec/frontend/header_search/store/actions_spec.js28
-rw-r--r--spec/frontend/header_search/store/getters_spec.js211
-rw-r--r--spec/frontend/header_search/store/mutations_spec.js20
-rw-r--r--spec/frontend/header_spec.js4
-rw-r--r--spec/frontend/ide/components/repo_editor_spec.js33
-rw-r--r--spec/frontend/ide/services/terminals_spec.js51
-rw-r--r--spec/frontend/ide/utils_spec.js8
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js90
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_source_cell_spec.js59
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_table_spec.js8
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js29
-rw-r--r--spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js38
-rw-r--r--spec/frontend/import_entities/import_groups/graphql/fixtures.js4
-rw-r--r--spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js2
-rw-r--r--spec/frontend/import_entities/import_projects/store/actions_spec.js29
-rw-r--r--spec/frontend/invite_members/components/import_a_project_modal_spec.js167
-rw-r--r--spec/frontend/invite_members/components/invite_members_trigger_spec.js4
-rw-r--r--spec/frontend/invite_members/components/project_select_spec.js105
-rw-r--r--spec/frontend/invite_members/mock_data/api_response_data.js13
-rw-r--r--spec/frontend/issue_show/components/app_spec.js44
-rw-r--r--spec/frontend/issues_list/components/issues_list_app_spec.js137
-rw-r--r--spec/frontend/issues_list/mock_data.js11
-rw-r--r--spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap36
-rw-r--r--spec/frontend/jobs/components/job_app_spec.js4
-rw-r--r--spec/frontend/jobs/components/table/cells/actions_cell_spec.js126
-rw-r--r--spec/frontend/jobs/components/table/cells/duration_cell_spec.js (renamed from spec/frontend/jobs/components/table/cells.vue/duration_cell_spec.js)0
-rw-r--r--spec/frontend/jobs/components/table/cells/job_cell_spec.js (renamed from spec/frontend/jobs/components/table/cells.vue/job_cell_spec.js)0
-rw-r--r--spec/frontend/jobs/components/table/cells/pipeline_cell_spec.js (renamed from spec/frontend/jobs/components/table/cells.vue/pipeline_cell_spec.js)0
-rw-r--r--spec/frontend/jobs/mock_data.js182
-rw-r--r--spec/frontend/learn_gitlab/track_learn_gitlab_spec.js21
-rw-r--r--spec/frontend/lib/apollo/instrumentation_link_spec.js54
-rw-r--r--spec/frontend/lib/dompurify_spec.js25
-rw-r--r--spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap16
-rw-r--r--spec/frontend/lib/logger/hello_deferred_spec.js17
-rw-r--r--spec/frontend/lib/logger/hello_spec.js20
-rw-r--r--spec/frontend/lib/logger/index_spec.js23
-rw-r--r--spec/frontend/lib/utils/accessor_spec.js65
-rw-r--r--spec/frontend/lib/utils/datetime/date_format_utility_spec.js120
-rw-r--r--spec/frontend/lib/utils/dom_utils_spec.js15
-rw-r--r--spec/frontend/lib/utils/text_markdown_spec.js19
-rw-r--r--spec/frontend/lib/utils/url_utility_spec.js23
-rw-r--r--spec/frontend/members/components/modals/leave_modal_spec.js8
-rw-r--r--spec/frontend/merge_request_tabs_spec.js82
-rw-r--r--spec/frontend/milestones/stores/mutations_spec.js58
-rw-r--r--spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap4
-rw-r--r--spec/frontend/notebook/cells/markdown_spec.js140
-rw-r--r--spec/frontend/notebook/cells/output/html_sanitize_fixtures.js3
-rw-r--r--spec/frontend/notebook/index_spec.js31
-rw-r--r--spec/frontend/notes/components/comment_form_spec.js13
-rw-r--r--spec/frontend/notes/components/comment_type_dropdown_spec.js64
-rw-r--r--spec/frontend/notes/deprecated_notes_spec.js (renamed from spec/frontend/notes/old_notes_spec.js)7
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap9
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js86
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/metadata/composer_spec.js58
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/metadata/conan_spec.js48
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/metadata/maven_spec.js52
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js55
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js48
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js20
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/packages_list_app_spec.js.snap68
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/packages_list_app_spec.js273
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js217
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js128
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js71
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js48
-rw-r--r--spec/frontend/packages_and_registries/package_registry/mock_data.js4
-rw-r--r--spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js2
-rw-r--r--spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap4
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap604
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap (renamed from spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_a_spec.js.snap)16
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js38
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js (renamed from spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js)6
-rw-r--r--spec/frontend/pages/projects/new/components/new_project_url_select_spec.js122
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js5
-rw-r--r--spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js4
-rw-r--r--spec/frontend/pipeline_editor/components/commit/commit_section_spec.js19
-rw-r--r--spec/frontend/pipeline_editor/components/editor/text_editor_spec.js9
-rw-r--r--spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js9
-rw-r--r--spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js18
-rw-r--r--spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js8
-rw-r--r--spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js13
-rw-r--r--spec/frontend/pipeline_editor/mock_data.js51
-rw-r--r--spec/frontend/pipeline_editor/pipeline_editor_app_spec.js132
-rw-r--r--spec/frontend/pipeline_new/components/pipeline_new_form_spec.js17
-rw-r--r--spec/frontend/pipelines/__snapshots__/utils_spec.js.snap (renamed from spec/frontend/pipelines/__snapshots__/parsing_utils_spec.js.snap)0
-rw-r--r--spec/frontend/pipelines/components/pipelines_filtered_search_spec.js2
-rw-r--r--spec/frontend/pipelines/graph/graph_component_spec.js5
-rw-r--r--spec/frontend/pipelines/graph/job_item_spec.js36
-rw-r--r--spec/frontend/pipelines/graph/linked_pipeline_spec.js44
-rw-r--r--spec/frontend/pipelines/graph/linked_pipelines_column_spec.js4
-rw-r--r--spec/frontend/pipelines/graph/linked_pipelines_mock_data.js3812
-rw-r--r--spec/frontend/pipelines/header_component_spec.js2
-rw-r--r--spec/frontend/pipelines/pipeline_multi_actions_spec.js12
-rw-r--r--spec/frontend/pipelines/pipelines_spec.js2
-rw-r--r--spec/frontend/pipelines/tokens/pipeline_source_token_spec.js3
-rw-r--r--spec/frontend/pipelines/utils_spec.js (renamed from spec/frontend/pipelines/parsing_utils_spec.js)2
-rw-r--r--spec/frontend/pipelines_spec.js17
-rw-r--r--spec/frontend/popovers/components/popovers_spec.js25
-rw-r--r--spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js2
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js23
-rw-r--r--spec/frontend/projects/storage_counter/components/app_spec.js150
-rw-r--r--spec/frontend/projects/storage_counter/components/storage_table_spec.js62
-rw-r--r--spec/frontend/projects/storage_counter/mock_data.js109
-rw-r--r--spec/frontend/projects/storage_counter/utils_spec.js17
-rw-r--r--spec/frontend/projects/terraform_notification/terraform_notification_spec.js70
-rw-r--r--spec/frontend/repository/components/blob_content_viewer_spec.js24
-rw-r--r--spec/frontend/repository/components/blob_viewers/image_viewer_spec.js25
-rw-r--r--spec/frontend/repository/components/tree_content_spec.js7
-rw-r--r--spec/frontend/runner/admin_runners/admin_runners_app_spec.js97
-rw-r--r--spec/frontend/runner/components/runner_filtered_search_bar_spec.js33
-rw-r--r--spec/frontend/runner/components/runner_list_spec.js2
-rw-r--r--spec/frontend/runner/components/runner_update_form_spec.js14
-rw-r--r--spec/frontend/runner/group_runners/group_runners_app_spec.js239
-rw-r--r--spec/frontend/runner/mock_data.js16
-rw-r--r--spec/frontend/search/highlight_blob_search_result_spec.js2
-rw-r--r--spec/frontend/search/store/actions_spec.js8
-rw-r--r--spec/frontend/search/store/utils_spec.js2
-rw-r--r--spec/frontend/shortcuts_spec.js3
-rw-r--r--spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js2
-rw-r--r--spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js10
-rw-r--r--spec/frontend/sidebar/sidebar_labels_spec.js2
-rw-r--r--spec/frontend/sidebar/sidebar_mediator_spec.js17
-rw-r--r--spec/frontend/sidebar/sidebar_store_spec.js39
-rw-r--r--spec/frontend/sidebar/track_invite_members_spec.js2
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap1
-rw-r--r--spec/frontend/tracking_spec.js149
-rw-r--r--spec/frontend/vue_mr_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap37
-rw-r--r--spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js49
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js4
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js2
-rw-r--r--spec/frontend/vue_mr_widget/components/states/new_ready_to_merge_spec.js31
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap4
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap4
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap1
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap4
-rw-r--r--spec/frontend/vue_shared/components/commit_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js176
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js22
-rw-r--r--spec/frontend/vue_shared/components/header_ci_component_spec.js32
-rw-r--r--spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js74
-rw-r--r--spec/frontend/vue_shared/components/markdown/field_spec.js13
-rw-r--r--spec/frontend/vue_shared/components/registry/title_area_spec.js11
-rw-r--r--spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/settings/__snapshots__/settings_block_spec.js.snap23
-rw-r--r--spec/frontend/vue_shared/components/settings/settings_block_spec.js54
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js18
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js12
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js91
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js15
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js79
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js152
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js61
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js48
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js189
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js8
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js85
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js59
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js115
-rw-r--r--spec/frontend/vue_shared/components/storage_counter/usage_graph_spec.js137
-rw-r--r--spec/frontend/vue_shared/components/user_popover/user_popover_spec.js17
-rw-r--r--spec/frontend/zen_mode_spec.js6
-rw-r--r--spec/frontend_integration/README.md27
-rw-r--r--spec/frontend_integration/fly_out_nav_browser_spec.js (renamed from spec/javascripts/fly_out_nav_browser_spec.js)121
-rw-r--r--spec/frontend_integration/lib/utils/browser_spec.js (renamed from spec/javascripts/lib/utils/browser_spec.js)43
-rw-r--r--spec/graphql/mutations/custom_emoji/destroy_spec.rb79
-rw-r--r--spec/graphql/mutations/customer_relations/organizations/create_spec.rb74
-rw-r--r--spec/graphql/mutations/customer_relations/organizations/update_spec.rb74
-rw-r--r--spec/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb110
-rw-r--r--spec/graphql/resolvers/board_list_issues_resolver_spec.rb41
-rw-r--r--spec/graphql/resolvers/ci/group_runners_resolver_spec.rb94
-rw-r--r--spec/graphql/resolvers/ci/runners_resolver_spec.rb209
-rw-r--r--spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb26
-rw-r--r--spec/graphql/resolvers/group_resolver_spec.rb7
-rw-r--r--spec/graphql/resolvers/issues_resolver_spec.rb48
-rw-r--r--spec/graphql/resolvers/merge_requests_resolver_spec.rb10
-rw-r--r--spec/graphql/resolvers/project_resolver_spec.rb5
-rw-r--r--spec/graphql/resolvers/users/groups_resolver_spec.rb106
-rw-r--r--spec/graphql/types/ci/job_type_spec.rb1
-rw-r--r--spec/graphql/types/customer_relations/contact_type_spec.rb11
-rw-r--r--spec/graphql/types/customer_relations/organization_type_spec.rb11
-rw-r--r--spec/graphql/types/dependency_proxy/blob_type_spec.rb13
-rw-r--r--spec/graphql/types/dependency_proxy/group_setting_type_spec.rb13
-rw-r--r--spec/graphql/types/dependency_proxy/image_ttl_group_policy_type_spec.rb17
-rw-r--r--spec/graphql/types/dependency_proxy/manifest_type_spec.rb13
-rw-r--r--spec/graphql/types/group_type_spec.rb6
-rw-r--r--spec/graphql/types/issue_type_spec.rb52
-rw-r--r--spec/graphql/types/merge_requests/reviewer_type_spec.rb1
-rw-r--r--spec/graphql/types/project_statistics_type_spec.rb2
-rw-r--r--spec/graphql/types/terraform/state_version_type_spec.rb58
-rw-r--r--spec/graphql/types/user_type_spec.rb1
-rw-r--r--spec/helpers/analytics/cycle_analytics_helper_spec.rb61
-rw-r--r--spec/helpers/application_settings_helper_spec.rb6
-rw-r--r--spec/helpers/blob_helper_spec.rb24
-rw-r--r--spec/helpers/ci/pipeline_editor_helper_spec.rb5
-rw-r--r--spec/helpers/ci/runners_helper_spec.rb13
-rw-r--r--spec/helpers/environment_helper_spec.rb1
-rw-r--r--spec/helpers/groups_helper_spec.rb134
-rw-r--r--spec/helpers/issuables_description_templates_helper_spec.rb21
-rw-r--r--spec/helpers/issuables_helper_spec.rb37
-rw-r--r--spec/helpers/issues_helper_spec.rb92
-rw-r--r--spec/helpers/learn_gitlab_helper_spec.rb56
-rw-r--r--spec/helpers/nav/new_dropdown_helper_spec.rb24
-rw-r--r--spec/helpers/nav/top_nav_helper_spec.rb4
-rw-r--r--spec/helpers/notify_helper_spec.rb49
-rw-r--r--spec/helpers/packages_helper_spec.rb32
-rw-r--r--spec/helpers/profiles_helper_spec.rb33
-rw-r--r--spec/helpers/projects_helper_spec.rb2
-rw-r--r--spec/helpers/recaptcha_helper_spec.rb4
-rw-r--r--spec/helpers/routing/pseudonymization_helper_spec.rb141
-rw-r--r--spec/helpers/user_callouts_helper_spec.rb93
-rw-r--r--spec/initializers/validate_database_config_spec.rb166
-rw-r--r--spec/javascripts/.eslintrc.yml39
-rw-r--r--spec/javascripts/fixtures/.gitignore2
-rw-r--r--spec/javascripts/lib/utils/mock_data.js1
-rw-r--r--spec/javascripts/test_bundle.js145
-rw-r--r--spec/javascripts/test_constants.js1
-rw-r--r--spec/lib/api/entities/clusters/agent_authorization_spec.rb17
-rw-r--r--spec/lib/backup/gitaly_backup_spec.rb15
-rw-r--r--spec/lib/backup/manager_spec.rb71
-rw-r--r--spec/lib/banzai/filter/audio_link_filter_spec.rb16
-rw-r--r--spec/lib/banzai/filter/video_link_filter_spec.rb16
-rw-r--r--spec/lib/banzai/reference_parser/base_parser_spec.rb12
-rw-r--r--spec/lib/banzai/reference_parser/mentioned_group_parser_spec.rb6
-rw-r--r--spec/lib/banzai/reference_parser/mentioned_project_parser_spec.rb6
-rw-r--r--spec/lib/banzai/reference_parser/mentioned_user_parser_spec.rb6
-rw-r--r--spec/lib/banzai/reference_parser/project_parser_spec.rb8
-rw-r--r--spec/lib/bulk_imports/common/pipelines/entity_finisher_spec.rb (renamed from spec/lib/bulk_imports/groups/pipelines/entity_finisher_spec.rb)2
-rw-r--r--spec/lib/bulk_imports/groups/graphql/get_projects_query_spec.rb43
-rw-r--r--spec/lib/bulk_imports/groups/pipelines/project_entities_pipeline_spec.rb69
-rw-r--r--spec/lib/bulk_imports/groups/stage_spec.rb (renamed from spec/lib/bulk_imports/stage_spec.rb)23
-rw-r--r--spec/lib/bulk_imports/projects/pipelines/project_pipeline_spec.rb95
-rw-r--r--spec/lib/bulk_imports/projects/stage_spec.rb18
-rw-r--r--spec/lib/bulk_imports/projects/transformers/project_attributes_transformer_spec.rb83
-rw-r--r--spec/lib/error_tracking/collector/dsn_spec.rb26
-rw-r--r--spec/lib/gitlab/action_cable/request_store_callbacks_spec.rb20
-rw-r--r--spec/lib/gitlab/background_migration/backfill_projects_with_coverage_spec.rb94
-rw-r--r--spec/lib/gitlab/background_migration/extract_project_topics_into_separate_table_spec.rb45
-rw-r--r--spec/lib/gitlab/background_migration/migrate_merge_request_diff_commit_users_spec.rb12
-rw-r--r--spec/lib/gitlab/background_migration/migrate_pages_metadata_spec.rb14
-rw-r--r--spec/lib/gitlab/background_migration/steal_migrate_merge_request_diff_commit_users_spec.rb50
-rw-r--r--spec/lib/gitlab/changelog/config_spec.rb110
-rw-r--r--spec/lib/gitlab/changelog/release_spec.rb24
-rw-r--r--spec/lib/gitlab/chat/command_spec.rb2
-rw-r--r--spec/lib/gitlab/checks/changes_access_spec.rb153
-rw-r--r--spec/lib/gitlab/ci/config/entry/job_spec.rb103
-rw-r--r--spec/lib/gitlab/ci/config/entry/rules_spec.rb8
-rw-r--r--spec/lib/gitlab/ci/config/entry/tags_spec.rb63
-rw-r--r--spec/lib/gitlab/ci/cron_parser_spec.rb61
-rw-r--r--spec/lib/gitlab/ci/parsers/security/common_spec.rb20
-rw-r--r--spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb3
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/build/associations_spec.rb108
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/build_spec.rb101
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb8
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/command_spec.rb36
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb21
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/sequence_spec.rb15
-rw-r--r--spec/lib/gitlab/ci/pipeline/metrics_spec.rb27
-rw-r--r--spec/lib/gitlab/ci/pipeline/seed/build_spec.rb21
-rw-r--r--spec/lib/gitlab/ci/pipeline/seed/pipeline_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/reports/security/flag_spec.rb33
-rw-r--r--spec/lib/gitlab/ci/trace/backoff_spec.rb62
-rw-r--r--spec/lib/gitlab/ci/trace_spec.rb14
-rw-r--r--spec/lib/gitlab/ci/variables/collection/sort_spec.rb44
-rw-r--r--spec/lib/gitlab/ci/variables/collection_spec.rb99
-rw-r--r--spec/lib/gitlab/ci/yaml_processor_spec.rb24
-rw-r--r--spec/lib/gitlab/config/loader/yaml_spec.rb18
-rw-r--r--spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb117
-rw-r--r--spec/lib/gitlab/database/async_indexes/migration_helpers_spec.rb17
-rw-r--r--spec/lib/gitlab/database/background_migration/batched_migration_spec.rb36
-rw-r--r--spec/lib/gitlab/database/connection_spec.rb81
-rw-r--r--spec/lib/gitlab/database/load_balancing/action_cable_callbacks_spec.rb30
-rw-r--r--spec/lib/gitlab/database/load_balancing/configuration_spec.rb175
-rw-r--r--spec/lib/gitlab/database/load_balancing/connection_proxy_spec.rb40
-rw-r--r--spec/lib/gitlab/database/load_balancing/host_list_spec.rb7
-rw-r--r--spec/lib/gitlab/database/load_balancing/host_spec.rb7
-rw-r--r--spec/lib/gitlab/database/load_balancing/load_balancer_spec.rb46
-rw-r--r--spec/lib/gitlab/database/load_balancing/primary_host_spec.rb126
-rw-r--r--spec/lib/gitlab/database/load_balancing/service_discovery_spec.rb108
-rw-r--r--spec/lib/gitlab/database/load_balancing/sidekiq_client_middleware_spec.rb71
-rw-r--r--spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb37
-rw-r--r--spec/lib/gitlab/database/load_balancing_spec.rb233
-rw-r--r--spec/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers_spec.rb58
-rw-r--r--spec/lib/gitlab/database/migration_helpers/v2_spec.rb104
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb105
-rw-r--r--spec/lib/gitlab/database/migration_spec.rb68
-rw-r--r--spec/lib/gitlab/database/migrations/lock_retry_mixin_spec.rb129
-rw-r--r--spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb29
-rw-r--r--spec/lib/gitlab/database/partitioning/multi_database_partition_manager_spec.rb36
-rw-r--r--spec/lib/gitlab/database/partitioning/partition_manager_spec.rb80
-rw-r--r--spec/lib/gitlab/database/partitioning_migration_helpers/foreign_key_helpers_spec.rb11
-rw-r--r--spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb21
-rw-r--r--spec/lib/gitlab/database/partitioning_spec.rb36
-rw-r--r--spec/lib/gitlab/database/postgresql_adapter/dump_schema_versions_mixin_spec.rb8
-rw-r--r--spec/lib/gitlab/database/schema_migrations/context_spec.rb8
-rw-r--r--spec/lib/gitlab/database/shared_model_spec.rb55
-rw-r--r--spec/lib/gitlab/database/transaction/context_spec.rb42
-rw-r--r--spec/lib/gitlab/database/transaction/observer_spec.rb3
-rw-r--r--spec/lib/gitlab/database/with_lock_retries_spec.rb54
-rw-r--r--spec/lib/gitlab/database_importers/work_items/base_type_importer_spec.rb9
-rw-r--r--spec/lib/gitlab/database_spec.rb50
-rw-r--r--spec/lib/gitlab/devise_failure_spec.rb35
-rw-r--r--spec/lib/gitlab/diff/highlight_cache_spec.rb9
-rw-r--r--spec/lib/gitlab/email/handler/service_desk_handler_spec.rb9
-rw-r--r--spec/lib/gitlab/encoding_helper_spec.rb30
-rw-r--r--spec/lib/gitlab/experimentation/controller_concern_spec.rb20
-rw-r--r--spec/lib/gitlab/experimentation/experiment_spec.rb1
-rw-r--r--spec/lib/gitlab/experimentation_spec.rb103
-rw-r--r--spec/lib/gitlab/git/commit_spec.rb36
-rw-r--r--spec/lib/gitlab/git/repository_spec.rb288
-rw-r--r--spec/lib/gitlab/git/tag_spec.rb14
-rw-r--r--spec/lib/gitlab/git/tree_spec.rb107
-rw-r--r--spec/lib/gitlab/gitaly_client/blob_service_spec.rb23
-rw-r--r--spec/lib/gitlab/gitaly_client/commit_service_spec.rb35
-rw-r--r--spec/lib/gitlab/gitaly_client/ref_service_spec.rb63
-rw-r--r--spec/lib/gitlab/gitaly_client/repository_service_spec.rb13
-rw-r--r--spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb7
-rw-r--r--spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb1
-rw-r--r--spec/lib/gitlab/github_import/importer/single_endpoint_diff_notes_importer_spec.rb75
-rw-r--r--spec/lib/gitlab/github_import/importer/single_endpoint_issue_notes_importer_spec.rb74
-rw-r--r--spec/lib/gitlab/github_import/importer/single_endpoint_merge_request_notes_importer_spec.rb75
-rw-r--r--spec/lib/gitlab/github_import/issuable_finder_spec.rb66
-rw-r--r--spec/lib/gitlab/github_import/representation/diff_note_spec.rb4
-rw-r--r--spec/lib/gitlab/github_import/sequential_importer_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/user_finder_spec.rb12
-rw-r--r--spec/lib/gitlab/github_import_spec.rb49
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml13
-rw-r--r--spec/lib/gitlab/import_export/attributes_permitter_spec.rb69
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml2
-rw-r--r--spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb26
-rw-r--r--spec/lib/gitlab/instrumentation/redis_spec.rb7
-rw-r--r--spec/lib/gitlab/issuables_count_for_state_spec.rb102
-rw-r--r--spec/lib/gitlab/issues/rebalancing/state_spec.rb223
-rw-r--r--spec/lib/gitlab/kas/client_spec.rb27
-rw-r--r--spec/lib/gitlab/middleware/sidekiq_web_static_spec.rb40
-rw-r--r--spec/lib/gitlab/pagination/cursor_based_keyset_spec.rb48
-rw-r--r--spec/lib/gitlab/pagination/gitaly_keyset_pager_spec.rb3
-rw-r--r--spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb21
-rw-r--r--spec/lib/gitlab/pagination/keyset/cursor_based_request_context_spec.rb68
-rw-r--r--spec/lib/gitlab/pagination/keyset/cursor_pager_spec.rb63
-rw-r--r--spec/lib/gitlab/pagination/keyset/in_operator_optimization/array_scope_columns_spec.rb19
-rw-r--r--spec/lib/gitlab/pagination/keyset/in_operator_optimization/column_data_spec.rb23
-rw-r--r--spec/lib/gitlab/pagination/keyset/in_operator_optimization/order_by_columns_spec.rb37
-rw-r--r--spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb225
-rw-r--r--spec/lib/gitlab/pagination/keyset/order_spec.rb41
-rw-r--r--spec/lib/gitlab/pagination/offset_pagination_spec.rb37
-rw-r--r--spec/lib/gitlab/patch/legacy_database_config_spec.rb123
-rw-r--r--spec/lib/gitlab/path_regex_spec.rb10
-rw-r--r--spec/lib/gitlab/rack_attack/request_spec.rb16
-rw-r--r--spec/lib/gitlab/rack_attack_spec.rb26
-rw-r--r--spec/lib/gitlab/reference_extractor_spec.rb55
-rw-r--r--spec/lib/gitlab/regex_spec.rb21
-rw-r--r--spec/lib/gitlab/repository_cache/preloader_spec.rb54
-rw-r--r--spec/lib/gitlab/search_results_spec.rb4
-rw-r--r--spec/lib/gitlab/seeder_spec.rb33
-rw-r--r--spec/lib/gitlab/sidekiq_cluster/cli_spec.rb12
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb252
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executed_spec.rb4
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb4
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb103
-rw-r--r--spec/lib/gitlab/sidekiq_middleware_spec.rb8
-rw-r--r--spec/lib/gitlab/sidekiq_queue_spec.rb30
-rw-r--r--spec/lib/gitlab/tracking/snowplow_schema_validation_spec.rb33
-rw-r--r--spec/lib/gitlab/tracking/standard_context_spec.rb22
-rw-r--r--spec/lib/gitlab/tracking_spec.rb8
-rw-r--r--spec/lib/gitlab/url_builder_spec.rb21
-rw-r--r--spec/lib/gitlab/usage/metric_definition_spec.rb11
-rw-r--r--spec/lib/gitlab/usage/metric_spec.rb2
-rw-r--r--spec/lib/gitlab/usage/metrics/instrumentations/service_ping_features_metric_spec.rb20
-rw-r--r--spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb17
-rw-r--r--spec/lib/gitlab/usage_data_counters/code_review_events_spec.rb2
-rw-r--r--spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb2
-rw-r--r--spec/lib/gitlab/usage_data_counters/merge_request_activity_unique_counter_spec.rb30
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb9
-rw-r--r--spec/lib/gitlab/x509/tag_spec.rb18
-rw-r--r--spec/lib/gitlab/zentao/client_spec.rb105
-rw-r--r--spec/lib/marginalia_spec.rb36
-rw-r--r--spec/lib/object_storage/config_spec.rb4
-rw-r--r--spec/lib/sidebars/menu_spec.rb23
-rw-r--r--spec/lib/sidebars/projects/menus/learn_gitlab_menu_spec.rb11
-rw-r--r--spec/lib/sidebars/projects/menus/monitor_menu_spec.rb19
-rw-r--r--spec/lib/sidebars/projects/menus/settings_menu_spec.rb26
-rw-r--r--spec/lib/system_check/incoming_email_check_spec.rb54
-rw-r--r--spec/mailers/emails/in_product_marketing_spec.rb2
-rw-r--r--spec/mailers/notify_spec.rb66
-rw-r--r--spec/migrations/20210804150320_create_base_work_item_types_spec.rb10
-rw-r--r--spec/migrations/20210818185845_backfill_projects_with_coverage_spec.rb71
-rw-r--r--spec/migrations/20210819145000_drop_temporary_columns_and_triggers_for_ci_builds_runner_session_spec.rb21
-rw-r--r--spec/migrations/20210831203408_upsert_base_work_item_types_spec.rb54
-rw-r--r--spec/migrations/20210902144144_drop_temporary_columns_and_triggers_for_ci_build_needs_spec.rb21
-rw-r--r--spec/migrations/20210906100316_drop_temporary_columns_and_triggers_for_ci_build_trace_chunks_spec.rb21
-rw-r--r--spec/migrations/active_record/schema_spec.rb2
-rw-r--r--spec/migrations/add_default_project_approval_rules_vuln_allowed_spec.rb35
-rw-r--r--spec/migrations/add_triggers_to_integrations_type_new_spec.rb14
-rw-r--r--spec/migrations/backfill_cadence_id_for_boards_scoped_to_iteration_spec.rb109
-rw-r--r--spec/migrations/backfill_stage_event_hash_spec.rb103
-rw-r--r--spec/migrations/cleanup_remaining_orphan_invites_spec.rb37
-rw-r--r--spec/migrations/disable_job_token_scope_when_unused_spec.rb44
-rw-r--r--spec/migrations/remove_duplicate_dast_site_tokens_spec.rb53
-rw-r--r--spec/migrations/remove_duplicate_dast_site_tokens_with_same_token_spec.rb53
-rw-r--r--spec/migrations/replace_external_wiki_triggers_spec.rb132
-rw-r--r--spec/migrations/set_default_job_token_scope_true_spec.rb33
-rw-r--r--spec/migrations/slice_merge_request_diff_commit_migrations_spec.rb69
-rw-r--r--spec/migrations/steal_merge_request_diff_commit_users_migration_spec.rb29
-rw-r--r--spec/migrations/update_integrations_trigger_type_new_on_insert_spec.rb102
-rw-r--r--spec/migrations/update_minimum_password_length_spec.rb2
-rw-r--r--spec/models/analytics/cycle_analytics/issue_stage_event_spec.rb11
-rw-r--r--spec/models/analytics/cycle_analytics/merge_request_stage_event_spec.rb11
-rw-r--r--spec/models/application_record_spec.rb81
-rw-r--r--spec/models/application_setting_spec.rb31
-rw-r--r--spec/models/bulk_imports/entity_spec.rb53
-rw-r--r--spec/models/bulk_imports/tracker_spec.rb4
-rw-r--r--spec/models/ci/bridge_spec.rb8
-rw-r--r--spec/models/ci/build_spec.rb57
-rw-r--r--spec/models/ci/build_trace_chunks/fog_spec.rb42
-rw-r--r--spec/models/ci/build_trace_metadata_spec.rb124
-rw-r--r--spec/models/ci/pending_build_spec.rb111
-rw-r--r--spec/models/ci/pipeline_schedule_spec.rb44
-rw-r--r--spec/models/ci/pipeline_spec.rb77
-rw-r--r--spec/models/ci/pipeline_variable_spec.rb2
-rw-r--r--spec/models/ci/runner_spec.rb4
-rw-r--r--spec/models/clusters/agent_spec.rb4
-rw-r--r--spec/models/clusters/agents/group_authorization_spec.rb10
-rw-r--r--spec/models/clusters/agents/implicit_authorization_spec.rb14
-rw-r--r--spec/models/clusters/agents/project_authorization_spec.rb10
-rw-r--r--spec/models/clusters/cluster_spec.rb14
-rw-r--r--spec/models/commit_status_spec.rb9
-rw-r--r--spec/models/concerns/approvable_base_spec.rb28
-rw-r--r--spec/models/concerns/calloutable_spec.rb26
-rw-r--r--spec/models/concerns/featurable_spec.rb5
-rw-r--r--spec/models/concerns/issuable_spec.rb17
-rw-r--r--spec/models/concerns/loose_foreign_key_spec.rb83
-rw-r--r--spec/models/concerns/partitioned_table_spec.rb6
-rw-r--r--spec/models/concerns/sanitizable_spec.rb101
-rw-r--r--spec/models/concerns/taggable_queries_spec.rb9
-rw-r--r--spec/models/customer_relations/contact_spec.rb37
-rw-r--r--spec/models/customer_relations/organization_spec.rb2
-rw-r--r--spec/models/dependency_proxy/blob_spec.rb3
-rw-r--r--spec/models/dependency_proxy/image_ttl_group_policy_spec.rb23
-rw-r--r--spec/models/dependency_proxy/manifest_spec.rb3
-rw-r--r--spec/models/design_management/action_spec.rb60
-rw-r--r--spec/models/diff_note_spec.rb27
-rw-r--r--spec/models/environment_spec.rb245
-rw-r--r--spec/models/error_tracking/error_spec.rb56
-rw-r--r--spec/models/error_tracking/project_error_tracking_setting_spec.rb11
-rw-r--r--spec/models/group_spec.rb92
-rw-r--r--spec/models/hooks/web_hook_spec.rb30
-rw-r--r--spec/models/instance_configuration_spec.rb44
-rw-r--r--spec/models/integration_spec.rb16
-rw-r--r--spec/models/integrations/datadog_spec.rb6
-rw-r--r--spec/models/integrations/pipelines_email_spec.rb2
-rw-r--r--spec/models/integrations/prometheus_spec.rb2
-rw-r--r--spec/models/integrations/zentao_spec.rb53
-rw-r--r--spec/models/integrations/zentao_tracker_data_spec.rb21
-rw-r--r--spec/models/internal_id_spec.rb309
-rw-r--r--spec/models/issue/metrics_spec.rb17
-rw-r--r--spec/models/issue_spec.rb57
-rw-r--r--spec/models/loose_foreign_keys/deleted_record_spec.rb56
-rw-r--r--spec/models/member_spec.rb10
-rw-r--r--spec/models/merge_request_spec.rb37
-rw-r--r--spec/models/milestone_spec.rb9
-rw-r--r--spec/models/namespace_setting_spec.rb10
-rw-r--r--spec/models/namespace_spec.rb117
-rw-r--r--spec/models/namespaces/project_namespace_spec.rb27
-rw-r--r--spec/models/note_spec.rb60
-rw-r--r--spec/models/operations/feature_flag_scope_spec.rb391
-rw-r--r--spec/models/operations/feature_flag_spec.rb118
-rw-r--r--spec/models/packages/package_file_spec.rb67
-rw-r--r--spec/models/packages/package_spec.rb43
-rw-r--r--spec/models/preloaders/commit_status_preloader_spec.rb41
-rw-r--r--spec/models/preloaders/merge_requests_preloader_spec.rb42
-rw-r--r--spec/models/preloaders/user_max_access_level_in_groups_preloader_spec.rb51
-rw-r--r--spec/models/project_ci_cd_setting_spec.rb6
-rw-r--r--spec/models/project_feature_spec.rb17
-rw-r--r--spec/models/project_spec.rb259
-rw-r--r--spec/models/projects/project_topic_spec.rb16
-rw-r--r--spec/models/projects/topic_spec.rb22
-rw-r--r--spec/models/protected_branch_spec.rb24
-rw-r--r--spec/models/repository_spec.rb213
-rw-r--r--spec/models/shard_spec.rb20
-rw-r--r--spec/models/user_callout_spec.rb19
-rw-r--r--spec/models/user_detail_spec.rb25
-rw-r--r--spec/models/user_spec.rb537
-rw-r--r--spec/models/users/group_callout_spec.rb27
-rw-r--r--spec/models/work_item/type_spec.rb6
-rw-r--r--spec/policies/custom_emoji_policy_spec.rb73
-rw-r--r--spec/policies/group_policy_spec.rb59
-rw-r--r--spec/policies/issue_policy_spec.rb149
-rw-r--r--spec/policies/user_policy_spec.rb46
-rw-r--r--spec/presenters/packages/helm/index_presenter_spec.rb80
-rw-r--r--spec/presenters/packages/npm/package_presenter_spec.rb65
-rw-r--r--spec/presenters/project_presenter_spec.rb38
-rw-r--r--spec/presenters/snippet_blob_presenter_spec.rb3
-rw-r--r--spec/rake_helper.rb2
-rw-r--r--spec/requests/admin/background_migrations_controller_spec.rb45
-rw-r--r--spec/requests/api/admin/sidekiq_spec.rb2
-rw-r--r--spec/requests/api/ci/pipelines_spec.rb81
-rw-r--r--spec/requests/api/ci/runners_reset_registration_token_spec.rb149
-rw-r--r--spec/requests/api/commit_statuses_spec.rb36
-rw-r--r--spec/requests/api/commits_spec.rb20
-rw-r--r--spec/requests/api/dependency_proxy_spec.rb74
-rw-r--r--spec/requests/api/error_tracking_client_keys_spec.rb86
-rw-r--r--spec/requests/api/error_tracking_collector_spec.rb97
-rw-r--r--spec/requests/api/feature_flags_spec.rb121
-rw-r--r--spec/requests/api/files_spec.rb13
-rw-r--r--spec/requests/api/generic_packages_spec.rb10
-rw-r--r--spec/requests/api/go_proxy_spec.rb2
-rw-r--r--spec/requests/api/graphql/boards/board_list_issues_query_spec.rb14
-rw-r--r--spec/requests/api/graphql/ci/stages_spec.rb46
-rw-r--r--spec/requests/api/graphql/current_user/groups_query_spec.rb112
-rw-r--r--spec/requests/api/graphql/group/dependency_proxy_blobs_spec.rb127
-rw-r--r--spec/requests/api/graphql/group/dependency_proxy_group_setting_spec.rb78
-rw-r--r--spec/requests/api/graphql/group/dependency_proxy_image_ttl_policy_spec.rb77
-rw-r--r--spec/requests/api/graphql/group/dependency_proxy_manifests_spec.rb119
-rw-r--r--spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb73
-rw-r--r--spec/requests/api/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb70
-rw-r--r--spec/requests/api/graphql/mutations/issues/create_spec.rb5
-rw-r--r--spec/requests/api/graphql/mutations/issues/update_spec.rb13
-rw-r--r--spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb53
-rw-r--r--spec/requests/api/graphql/project/issues_spec.rb28
-rw-r--r--spec/requests/api/graphql/project/pipeline_spec.rb12
-rw-r--r--spec/requests/api/groups_spec.rb121
-rw-r--r--spec/requests/api/helm_packages_spec.rb24
-rw-r--r--spec/requests/api/internal/kubernetes_spec.rb42
-rw-r--r--spec/requests/api/issues/get_group_issues_spec.rb25
-rw-r--r--spec/requests/api/issues/issues_spec.rb128
-rw-r--r--spec/requests/api/lint_spec.rb1
-rw-r--r--spec/requests/api/maven_packages_spec.rb2
-rw-r--r--spec/requests/api/members_spec.rb84
-rw-r--r--spec/requests/api/merge_requests_spec.rb2
-rw-r--r--spec/requests/api/notification_settings_spec.rb2
-rw-r--r--spec/requests/api/npm_project_packages_spec.rb34
-rw-r--r--spec/requests/api/pages/pages_spec.rb7
-rw-r--r--spec/requests/api/project_attributes.yml3
-rw-r--r--spec/requests/api/projects_spec.rb17
-rw-r--r--spec/requests/api/pypi_packages_spec.rb2
-rw-r--r--spec/requests/api/releases_spec.rb2
-rw-r--r--spec/requests/api/repositories_spec.rb27
-rw-r--r--spec/requests/api/rubygem_packages_spec.rb2
-rw-r--r--spec/requests/api/settings_spec.rb57
-rw-r--r--spec/requests/api/terraform/modules/v1/packages_spec.rb2
-rw-r--r--spec/requests/api/unleash_spec.rb19
-rw-r--r--spec/requests/api/users_spec.rb461
-rw-r--r--spec/requests/git_http_spec.rb8
-rw-r--r--spec/requests/jira_connect/installations_controller_spec.rb95
-rw-r--r--spec/requests/members/mailgun/permanent_failure_spec.rb128
-rw-r--r--spec/requests/oauth_tokens_spec.rb24
-rw-r--r--spec/requests/openid_connect_spec.rb28
-rw-r--r--spec/requests/projects/merge_requests_discussions_spec.rb11
-rw-r--r--spec/requests/projects/usage_quotas_spec.rb63
-rw-r--r--spec/requests/rack_attack_global_spec.rb540
-rw-r--r--spec/requests/users/group_callouts_spec.rb58
-rw-r--r--spec/rubocop/cop/gitlab/mark_used_feature_flags_spec.rb1
-rw-r--r--spec/rubocop/cop/migration/add_limit_to_text_columns_spec.rb36
-rw-r--r--spec/rubocop/cop/migration/prevent_index_creation_spec.rb86
-rw-r--r--spec/rubocop/cop/migration/versioned_migration_class_spec.rb81
-rw-r--r--spec/rubocop/cop/performance/active_record_subtransaction_methods_spec.rb29
-rw-r--r--spec/rubocop/cop/performance/active_record_subtransactions_spec.rb62
-rw-r--r--spec/rubocop/cop/sidekiq_load_balancing/worker_data_consistency_spec.rb (renamed from spec/rubocop/cop/worker_data_consistency_spec.rb)4
-rw-r--r--spec/rubocop/cop/sidekiq_load_balancing/worker_data_consistency_with_deduplication_spec.rb166
-rw-r--r--spec/serializers/group_child_entity_spec.rb36
-rw-r--r--spec/serializers/issuable_sidebar_extras_entity_spec.rb8
-rw-r--r--spec/serializers/pipeline_serializer_spec.rb2
-rw-r--r--spec/services/application_settings/update_service_spec.rb71
-rw-r--r--spec/services/auth/container_registry_authentication_service_spec.rb31
-rw-r--r--spec/services/boards/issues/list_service_spec.rb47
-rw-r--r--spec/services/ci/after_requeue_job_service_spec.rb10
-rw-r--r--spec/services/ci/archive_trace_service_spec.rb62
-rw-r--r--spec/services/ci/create_pipeline_service/parent_child_pipeline_spec.rb26
-rw-r--r--spec/services/ci/create_pipeline_service/tags_spec.rb38
-rw-r--r--spec/services/ci/create_pipeline_service_spec.rb99
-rw-r--r--spec/services/ci/external_pull_requests/create_pipeline_service_spec.rb67
-rw-r--r--spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb134
-rw-r--r--spec/services/ci/pipeline_processing/shared_processing_service.rb33
-rw-r--r--spec/services/ci/pipeline_trigger_service_spec.rb22
-rw-r--r--spec/services/ci/pipelines/add_job_service_spec.rb12
-rw-r--r--spec/services/ci/register_job_service_spec.rb84
-rw-r--r--spec/services/ci/stuck_builds/drop_service_spec.rb284
-rw-r--r--spec/services/ci/update_pending_build_service_spec.rb82
-rw-r--r--spec/services/clusters/agents/refresh_authorization_service_spec.rb132
-rw-r--r--spec/services/customer_relations/organizations/create_service_spec.rb33
-rw-r--r--spec/services/customer_relations/organizations/update_service_spec.rb56
-rw-r--r--spec/services/dependency_proxy/image_ttl_group_policies/update_service_spec.rb119
-rw-r--r--spec/services/design_management/delete_designs_service_spec.rb12
-rw-r--r--spec/services/draft_notes/publish_service_spec.rb27
-rw-r--r--spec/services/environments/auto_stop_service_spec.rb11
-rw-r--r--spec/services/environments/stop_service_spec.rb54
-rw-r--r--spec/services/error_tracking/collect_error_service_spec.rb26
-rw-r--r--spec/services/feature_flags/create_service_spec.rb16
-rw-r--r--spec/services/git/base_hooks_service_spec.rb6
-rw-r--r--spec/services/git/branch_hooks_service_spec.rb38
-rw-r--r--spec/services/groups/group_links/create_service_spec.rb18
-rw-r--r--spec/services/groups/open_issues_count_service_spec.rb64
-rw-r--r--spec/services/groups/update_shared_runners_service_spec.rb38
-rw-r--r--spec/services/issue_rebalancing_service_spec.rb173
-rw-r--r--spec/services/issues/build_service_spec.rb52
-rw-r--r--spec/services/issues/close_service_spec.rb9
-rw-r--r--spec/services/issues/create_service_spec.rb15
-rw-r--r--spec/services/issues/relative_position_rebalancing_service_spec.rb166
-rw-r--r--spec/services/issues/reopen_service_spec.rb7
-rw-r--r--spec/services/issues/update_service_spec.rb37
-rw-r--r--spec/services/members/groups/bulk_creator_service_spec.rb10
-rw-r--r--spec/services/members/mailgun/process_webhook_service_spec.rb42
-rw-r--r--spec/services/members/projects/bulk_creator_service_spec.rb10
-rw-r--r--spec/services/merge_requests/merge_service_spec.rb15
-rw-r--r--spec/services/merge_requests/merge_to_ref_service_spec.rb32
-rw-r--r--spec/services/merge_requests/mergeability_check_service_spec.rb9
-rw-r--r--spec/services/merge_requests/squash_service_spec.rb17
-rw-r--r--spec/services/notification_service_spec.rb2
-rw-r--r--spec/services/packages/composer/version_parser_service_spec.rb1
-rw-r--r--spec/services/packages/generic/create_package_file_service_spec.rb31
-rw-r--r--spec/services/packages/maven/find_or_create_package_service_spec.rb13
-rw-r--r--spec/services/packages/nuget/update_package_from_metadata_service_spec.rb341
-rw-r--r--spec/services/pages/delete_service_spec.rb21
-rw-r--r--spec/services/pages/legacy_storage_lease_spec.rb65
-rw-r--r--spec/services/pages/migrate_legacy_storage_to_deployment_service_spec.rb8
-rw-r--r--spec/services/projects/batch_open_issues_count_service_spec.rb45
-rw-r--r--spec/services/projects/create_service_spec.rb24
-rw-r--r--spec/services/projects/fork_service_spec.rb4
-rw-r--r--spec/services/projects/group_links/destroy_service_spec.rb60
-rw-r--r--spec/services/projects/open_issues_count_service_spec.rb109
-rw-r--r--spec/services/projects/operations/update_service_spec.rb4
-rw-r--r--spec/services/projects/transfer_service_spec.rb33
-rw-r--r--spec/services/projects/update_pages_service_spec.rb158
-rw-r--r--spec/services/projects/update_service_spec.rb24
-rw-r--r--spec/services/quick_actions/interpret_service_spec.rb72
-rw-r--r--spec/services/repositories/changelog_service_spec.rb2
-rw-r--r--spec/services/service_ping/submit_service_ping_service_spec.rb28
-rw-r--r--spec/services/suggestions/apply_service_spec.rb16
-rw-r--r--spec/services/suggestions/create_service_spec.rb2
-rw-r--r--spec/services/system_note_service_spec.rb12
-rw-r--r--spec/services/system_notes/issuables_service_spec.rb12
-rw-r--r--spec/services/todos/destroy/design_service_spec.rb40
-rw-r--r--spec/services/users/ban_service_spec.rb2
-rw-r--r--spec/services/users/dismiss_group_callout_service_spec.rb25
-rw-r--r--spec/services/users/dismiss_user_callout_service_spec.rb25
-rw-r--r--spec/services/users/migrate_to_ghost_user_service_spec.rb18
-rw-r--r--spec/services/users/reject_service_spec.rb4
-rw-r--r--spec/services/users/unban_service_spec.rb2
-rw-r--r--spec/services/wiki_pages/event_create_service_spec.rb4
-rw-r--r--spec/spec_helper.rb16
-rw-r--r--spec/support/database/ci_tables.rb22
-rw-r--r--spec/support/database/cross-join-allowlist.yml196
-rw-r--r--spec/support/database/gitlab_schema.rb25
-rw-r--r--spec/support/database/multiple_databases.rb9
-rw-r--r--spec/support/database/prevent_cross_database_modification.rb10
-rw-r--r--spec/support/database/prevent_cross_joins.rb57
-rw-r--r--spec/support/database_cleaner.rb4
-rw-r--r--spec/support/database_load_balancing.rb5
-rw-r--r--spec/support/db_cleaner.rb2
-rw-r--r--spec/support/helpers/bare_repo_operations.rb20
-rw-r--r--spec/support/helpers/cycle_analytics_helpers.rb31
-rw-r--r--spec/support/helpers/email_helpers.rb4
-rw-r--r--spec/support/helpers/features/members_helpers.rb (renamed from spec/support/helpers/features/members_table_helpers.rb)0
-rw-r--r--spec/support/helpers/javascript_fixtures_helpers.rb9
-rw-r--r--spec/support/helpers/live_debugger.rb2
-rw-r--r--spec/support/helpers/migrations_helpers.rb2
-rw-r--r--spec/support/helpers/reference_parser_helpers.rb5
-rw-r--r--spec/support/helpers/session_helpers.rb26
-rw-r--r--spec/support/helpers/stub_gitlab_calls.rb4
-rw-r--r--spec/support/helpers/stub_gitlab_data.rb7
-rw-r--r--spec/support/helpers/test_env.rb4
-rw-r--r--spec/support/services/migrate_to_ghost_user_service_shared_examples.rb31
-rw-r--r--spec/support/shared_contexts/email_shared_context.rb9
-rw-r--r--spec/support/shared_contexts/finders/packages/npm/package_finder_shared_context.rb14
-rw-r--r--spec/support/shared_contexts/graphql/resolvers/runners_resolver_shared_context.rb22
-rw-r--r--spec/support/shared_contexts/issuable/merge_request_shared_context.rb2
-rw-r--r--spec/support/shared_contexts/navbar_structure_context.rb3
-rw-r--r--spec/support/shared_contexts/pages_zip_with_spoofed_size_shared_context.rb41
-rw-r--r--spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb10
-rw-r--r--spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb17
-rw-r--r--spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb14
-rw-r--r--spec/support/shared_examples/controllers/concerns/integrations_actions_shared_examples.rb59
-rw-r--r--spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/controllers/import_controller_status_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/controllers/issuable_anonymous_search_disabled_examples.rb55
-rw-r--r--spec/support/shared_examples/features/atom/issuable_shared_examples.rb12
-rw-r--r--spec/support/shared_examples/features/content_editor_shared_examples.rb14
-rw-r--r--spec/support/shared_examples/features/deploy_token_shared_examples.rb23
-rw-r--r--spec/support/shared_examples/features/discussion_comments_shared_example.rb6
-rw-r--r--spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/features/manage_applications_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/features/rss_shared_examples.rb20
-rw-r--r--spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb124
-rw-r--r--spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb52
-rw-r--r--spec/support/shared_examples/mailers/notify_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/models/concerns/featurable_shared_examples.rb12
-rw-r--r--spec/support/shared_examples/models/concerns/sanitizable_shared_examples.rb41
-rw-r--r--spec/support/shared_examples/models/member_shared_examples.rb56
-rw-r--r--spec/support/shared_examples/models/mentionable_shared_examples.rb36
-rw-r--r--spec/support/shared_examples/namespaces/traversal_scope_examples.rb125
-rw-r--r--spec/support/shared_examples/requests/api/helm_packages_shared_examples.rb21
-rw-r--r--spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb15
-rw-r--r--spec/support/shared_examples/requests/api/packages_shared_examples.rb12
-rw-r--r--spec/support/shared_examples/requests/rack_attack_shared_examples.rb202
-rw-r--r--spec/support/shared_examples/services/dependency_proxy_ttl_policies_shared_examples.rb34
-rw-r--r--spec/support/shared_examples/services/incident_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/services/users/dismiss_user_callout_service_shared_examples.rb37
-rw-r--r--spec/support/shared_examples/work_item_base_types_importer.rb10
-rw-r--r--spec/support_specs/database/prevent_cross_database_modification_spec.rb27
-rw-r--r--spec/support_specs/database/prevent_cross_joins_spec.rb28
-rw-r--r--spec/tasks/gitlab/db_rake_spec.rb7
-rw-r--r--spec/tasks/gitlab/product_intelligence_rake_spec.rb80
-rw-r--r--spec/tooling/danger/project_helper_spec.rb4
-rw-r--r--spec/tooling/graphql/docs/renderer_spec.rb4
-rw-r--r--spec/validators/gitlab/zoom_url_validator_spec.rb (renamed from spec/validators/gitlab/utils/zoom_url_validator_spec.rb)2
-rw-r--r--spec/views/groups/group_members/index.html.haml_spec.rb68
-rw-r--r--spec/views/help/instance_configuration.html.haml_spec.rb9
-rw-r--r--spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb30
-rw-r--r--spec/views/profiles/notifications/show.html.haml_spec.rb29
-rw-r--r--spec/views/projects/diffs/_stats.html.haml_spec.rb58
-rw-r--r--spec/views/projects/empty.html.haml_spec.rb2
-rw-r--r--spec/views/projects/merge_requests/_commits.html.haml_spec.rb8
-rw-r--r--spec/views/projects/project_members/index.html.haml_spec.rb96
-rw-r--r--spec/views/search/_results.html.haml_spec.rb8
-rw-r--r--spec/views/shared/access_tokens/_table.html.haml_spec.rb2
-rw-r--r--spec/workers/authorized_project_update/user_refresh_from_replica_worker_spec.rb15
-rw-r--r--spec/workers/background_migration_worker_spec.rb22
-rw-r--r--spec/workers/bulk_import_worker_spec.rb19
-rw-r--r--spec/workers/bulk_imports/pipeline_worker_spec.rb20
-rw-r--r--spec/workers/ci/external_pull_requests/create_pipeline_worker_spec.rb69
-rw-r--r--spec/workers/concerns/worker_attributes_spec.rb44
-rw-r--r--spec/workers/database/partition_management_worker_spec.rb6
-rw-r--r--spec/workers/deployments/finished_worker_spec.rb65
-rw-r--r--spec/workers/deployments/hooks_worker_spec.rb1
-rw-r--r--spec/workers/deployments/success_worker_spec.rb38
-rw-r--r--spec/workers/environments/auto_stop_worker_spec.rb71
-rw-r--r--spec/workers/every_sidekiq_worker_spec.rb2
-rw-r--r--spec/workers/expire_job_cache_worker_spec.rb41
-rw-r--r--spec/workers/expire_pipeline_cache_worker_spec.rb17
-rw-r--r--spec/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker_spec.rb43
-rw-r--r--spec/workers/gitlab/github_import/stage/import_notes_worker_spec.rb60
-rw-r--r--spec/workers/issue_rebalancing_worker_spec.rb28
-rw-r--r--spec/workers/namespaceless_project_destroy_worker_spec.rb6
-rw-r--r--spec/workers/packages/helm/extraction_worker_spec.rb14
-rw-r--r--spec/workers/pages_remove_worker_spec.rb22
-rw-r--r--spec/workers/post_receive_spec.rb10
-rw-r--r--spec/workers/purge_dependency_proxy_cache_worker_spec.rb17
-rw-r--r--spec/workers/stuck_ci_jobs_worker_spec.rb299
-rw-r--r--spec/workers/todos_destroyer/destroyed_designs_worker_spec.rb14
998 files changed, 30935 insertions, 18308 deletions
diff --git a/spec/bin/sidekiq_cluster_spec.rb b/spec/bin/sidekiq_cluster_spec.rb
index 1bba048a27c..eb014c511e3 100644
--- a/spec/bin/sidekiq_cluster_spec.rb
+++ b/spec/bin/sidekiq_cluster_spec.rb
@@ -1,11 +1,14 @@
# frozen_string_literal: true
-require 'spec_helper'
+require 'fast_spec_helper'
require 'shellwords'
+require 'rspec-parameterized'
-RSpec.describe 'bin/sidekiq-cluster' do
+RSpec.describe 'bin/sidekiq-cluster', :aggregate_failures do
using RSpec::Parameterized::TableSyntax
+ let(:root) { File.expand_path('../..', __dir__) }
+
context 'when selecting some queues and excluding others' do
where(:args, :included, :excluded) do
%w[--negate cronjob] | '-qdefault,1' | '-qcronjob,1'
@@ -13,10 +16,10 @@ RSpec.describe 'bin/sidekiq-cluster' do
end
with_them do
- it 'runs successfully', :aggregate_failures do
+ it 'runs successfully' do
cmd = %w[bin/sidekiq-cluster --dryrun] + args
- output, status = Gitlab::Popen.popen(cmd, Rails.root.to_s)
+ output, status = Gitlab::Popen.popen(cmd, root)
expect(status).to be(0)
expect(output).to include('bundle exec sidekiq')
@@ -31,10 +34,10 @@ RSpec.describe 'bin/sidekiq-cluster' do
%w[*],
%w[--queue-selector *]
].each do |args|
- it "runs successfully with `#{args}`", :aggregate_failures do
+ it "runs successfully with `#{args}`" do
cmd = %w[bin/sidekiq-cluster --dryrun] + args
- output, status = Gitlab::Popen.popen(cmd, Rails.root.to_s)
+ output, status = Gitlab::Popen.popen(cmd, root)
expect(status).to be(0)
expect(output).to include('bundle exec sidekiq')
@@ -43,4 +46,20 @@ RSpec.describe 'bin/sidekiq-cluster' do
end
end
end
+
+ context 'when arguments contain newlines' do
+ it 'raises an error' do
+ [
+ ["default\n"],
+ ["defaul\nt"]
+ ].each do |args|
+ cmd = %w[bin/sidekiq-cluster --dryrun] + args
+
+ output, status = Gitlab::Popen.popen(cmd, root)
+
+ expect(status).to be(1)
+ expect(output).to include('cannot contain newlines')
+ end
+ end
+ end
end
diff --git a/spec/config/grape_entity_patch_spec.rb b/spec/config/grape_entity_patch_spec.rb
new file mode 100644
index 00000000000..7334f270ca1
--- /dev/null
+++ b/spec/config/grape_entity_patch_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Grape::Entity patch' do
+ let(:entity_class) { Class.new(Grape::Entity) }
+
+ describe 'NameError in block exposure with argument' do
+ subject(:represent) { entity_class.represent({}, serializable: true) }
+
+ before do
+ entity_class.expose :raise_no_method_error do |_|
+ foo
+ end
+ end
+
+ it 'propagates the error to the caller' do
+ expect { represent }.to raise_error(NameError)
+ end
+ end
+end
diff --git a/spec/controllers/admin/integrations_controller_spec.rb b/spec/controllers/admin/integrations_controller_spec.rb
index 64ae2a95b4e..1793b3a86d1 100644
--- a/spec/controllers/admin/integrations_controller_spec.rb
+++ b/spec/controllers/admin/integrations_controller_spec.rb
@@ -9,6 +9,14 @@ RSpec.describe Admin::IntegrationsController do
sign_in(admin)
end
+ it_behaves_like IntegrationsActions do
+ let(:integration_attributes) { { instance: true, project: nil } }
+
+ let(:routing_params) do
+ { id: integration.to_param }
+ end
+ end
+
describe '#edit' do
Integration.available_integration_names.each do |integration_name|
context "#{integration_name}" do
diff --git a/spec/controllers/admin/runners_controller_spec.rb b/spec/controllers/admin/runners_controller_spec.rb
index 8e57b4f03a7..996964fdcf0 100644
--- a/spec/controllers/admin/runners_controller_spec.rb
+++ b/spec/controllers/admin/runners_controller_spec.rb
@@ -23,10 +23,6 @@ RSpec.describe Admin::RunnersController do
describe '#show' do
render_views
- before do
- stub_feature_flags(runner_detailed_view_vue_ui: false)
- end
-
let_it_be(:project) { create(:project) }
let_it_be(:project_two) { create(:project) }
@@ -61,30 +57,6 @@ RSpec.describe Admin::RunnersController do
expect(response).to have_gitlab_http_status(:ok)
end
-
- describe 'Cost factors values' do
- context 'when it is Gitlab.com' do
- before do
- expect(Gitlab).to receive(:com?).at_least(:once) { true }
- end
-
- it 'renders cost factors fields' do
- get :show, params: { id: runner.id }
-
- expect(response.body).to match /Private projects Minutes cost factor/
- expect(response.body).to match /Public projects Minutes cost factor/
- end
- end
-
- context 'when it is not Gitlab.com' do
- it 'does not show cost factor fields' do
- get :show, params: { id: runner.id }
-
- expect(response.body).not_to match /Private projects Minutes cost factor/
- expect(response.body).not_to match /Public projects Minutes cost factor/
- end
- end
- end
end
describe '#update' do
diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb
index 6e172f53257..015c36c9335 100644
--- a/spec/controllers/admin/users_controller_spec.rb
+++ b/spec/controllers/admin/users_controller_spec.rb
@@ -146,7 +146,7 @@ RSpec.describe Admin::UsersController do
it 'sends the user a rejection email' do
expect_next_instance_of(NotificationService) do |notification|
- allow(notification).to receive(:user_admin_rejection).with(user.name, user.notification_email)
+ allow(notification).to receive(:user_admin_rejection).with(user.name, user.notification_email_or_default)
end
subject
@@ -165,7 +165,7 @@ RSpec.describe Admin::UsersController do
it 'displays the error' do
subject
- expect(flash[:alert]).to eq('This user does not have a pending request')
+ expect(flash[:alert]).to eq('User does not have a pending request')
end
it 'does not email the user' do
diff --git a/spec/controllers/boards/issues_controller_spec.rb b/spec/controllers/boards/issues_controller_spec.rb
index 48000284264..cc60ab16d2e 100644
--- a/spec/controllers/boards/issues_controller_spec.rb
+++ b/spec/controllers/boards/issues_controller_spec.rb
@@ -428,17 +428,21 @@ RSpec.describe Boards::IssuesController do
describe 'POST create' do
context 'with valid params' do
- it 'returns a successful 200 response' do
+ before do
create_issue user: user, board: board, list: list1, title: 'New issue'
+ end
+ it 'returns a successful 200 response' do
expect(response).to have_gitlab_http_status(:ok)
end
it 'returns the created issue' do
- create_issue user: user, board: board, list: list1, title: 'New issue'
-
expect(response).to match_response_schema('entities/issue_board')
end
+
+ it 'sets the default work_item_type' do
+ expect(Issue.last.work_item_type.base_type).to eq('issue')
+ end
end
context 'with invalid params' do
diff --git a/spec/controllers/explore/projects_controller_spec.rb b/spec/controllers/explore/projects_controller_spec.rb
index a2b62aa49d2..2297198878d 100644
--- a/spec/controllers/explore/projects_controller_spec.rb
+++ b/spec/controllers/explore/projects_controller_spec.rb
@@ -200,6 +200,24 @@ RSpec.describe Explore::ProjectsController do
let(:sorting_param) { 'created_asc' }
end
end
+
+ describe 'GET #index' do
+ let(:controller_action) { :index }
+ let(:params_with_name) { { name: 'some project' } }
+
+ context 'when disable_anonymous_project_search is enabled' do
+ before do
+ stub_feature_flags(disable_anonymous_project_search: true)
+ end
+
+ it 'does not show a flash message' do
+ sign_in(create(:user))
+ get controller_action, params: params_with_name
+
+ expect(flash.now[:notice]).to be_nil
+ end
+ end
+ end
end
context 'when user is not signed in' do
@@ -229,5 +247,50 @@ RSpec.describe Explore::ProjectsController do
expect(response).to redirect_to new_user_session_path
end
end
+
+ describe 'GET #index' do
+ let(:controller_action) { :index }
+ let(:params_with_name) { { name: 'some project' } }
+
+ context 'when disable_anonymous_project_search is enabled' do
+ before do
+ stub_feature_flags(disable_anonymous_project_search: true)
+ end
+
+ it 'shows a flash message' do
+ get controller_action, params: params_with_name
+
+ expect(flash.now[:notice]).to eq('You must sign in to search for specific projects.')
+ end
+
+ context 'when search param is not given' do
+ it 'does not show a flash message' do
+ get controller_action
+
+ expect(flash.now[:notice]).to be_nil
+ end
+ end
+
+ context 'when format is not HTML' do
+ it 'does not show a flash message' do
+ get controller_action, params: params_with_name.merge(format: :atom)
+
+ expect(flash.now[:notice]).to be_nil
+ end
+ end
+ end
+
+ context 'when disable_anonymous_project_search is disabled' do
+ before do
+ stub_feature_flags(disable_anonymous_project_search: false)
+ end
+
+ it 'does not show a flash message' do
+ get controller_action, params: params_with_name
+
+ expect(flash.now[:notice]).to be_nil
+ end
+ end
+ end
end
end
diff --git a/spec/controllers/groups/children_controller_spec.rb b/spec/controllers/groups/children_controller_spec.rb
index e97fe50c468..04cf7785f1e 100644
--- a/spec/controllers/groups/children_controller_spec.rb
+++ b/spec/controllers/groups/children_controller_spec.rb
@@ -227,8 +227,8 @@ RSpec.describe Groups::ChildrenController do
context 'when rendering hierarchies' do
# When loading hierarchies we load the all the ancestors for matched projects
- # in 1 separate query
- let(:extra_queries_for_hierarchies) { 1 }
+ # in 2 separate queries
+ let(:extra_queries_for_hierarchies) { 2 }
def get_filtered_list
get :index, params: { group_id: group.to_param, filter: 'filter' }, format: :json
diff --git a/spec/controllers/groups/runners_controller_spec.rb b/spec/controllers/groups/runners_controller_spec.rb
index 1808969cd60..a8830efe653 100644
--- a/spec/controllers/groups/runners_controller_spec.rb
+++ b/spec/controllers/groups/runners_controller_spec.rb
@@ -3,11 +3,13 @@
require 'spec_helper'
RSpec.describe Groups::RunnersController do
- let(:user) { create(:user) }
- let(:group) { create(:group) }
- let(:runner) { create(:ci_runner, :group, groups: [group]) }
- let(:project) { create(:project, group: group) }
- let(:runner_project) { create(:ci_runner, :project, projects: [project]) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+
+ let!(:runner) { create(:ci_runner, :group, groups: [group]) }
+ let!(:runner_project) { create(:ci_runner, :project, projects: [project]) }
+
let(:params_runner_project) { { group_id: group, id: runner_project } }
let(:params) { { group_id: group, id: runner } }
@@ -26,6 +28,7 @@ RSpec.describe Groups::RunnersController do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:index)
+ expect(assigns(:group_runners_limited_count)).to be(2)
end
end
diff --git a/spec/controllers/groups/settings/integrations_controller_spec.rb b/spec/controllers/groups/settings/integrations_controller_spec.rb
index 931e726850a..31d1946652d 100644
--- a/spec/controllers/groups/settings/integrations_controller_spec.rb
+++ b/spec/controllers/groups/settings/integrations_controller_spec.rb
@@ -10,6 +10,21 @@ RSpec.describe Groups::Settings::IntegrationsController do
sign_in(user)
end
+ it_behaves_like IntegrationsActions do
+ let(:integration_attributes) { { group: group, project: nil } }
+
+ let(:routing_params) do
+ {
+ group_id: group,
+ id: integration.to_param
+ }
+ end
+
+ before do
+ group.add_owner(user)
+ end
+ end
+
describe '#index' do
context 'when user is not owner' do
it 'renders not_found' do
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index 91b11cd46c5..a7625e65603 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -370,6 +370,57 @@ RSpec.describe GroupsController, factory_default: :keep do
end
end
end
+
+ context 'when creating a group with the `role` attribute present' do
+ it 'changes the users role' do
+ sign_in(user)
+
+ expect do
+ post :create, params: { group: { name: 'new_group', path: 'new_group' }, user: { role: 'devops_engineer' } }
+ end.to change { user.reload.role }.to('devops_engineer')
+ end
+ end
+
+ context 'when creating a group with the `setup_for_company` attribute present' do
+ before do
+ sign_in(user)
+ end
+
+ subject do
+ post :create, params: { group: { name: 'new_group', path: 'new_group', setup_for_company: 'false' } }
+ end
+
+ it 'sets the groups `setup_for_company` value' do
+ subject
+ expect(Group.last.setup_for_company).to be(false)
+ end
+
+ context 'when the user already has a value for `setup_for_company`' do
+ before do
+ user.update_attribute(:setup_for_company, true)
+ end
+
+ it 'does not change the users `setup_for_company` value' do
+ expect(Users::UpdateService).not_to receive(:new)
+ expect { subject }.not_to change { user.reload.setup_for_company }.from(true)
+ end
+ end
+
+ context 'when the user has no value for `setup_for_company`' do
+ it 'changes the users `setup_for_company` value' do
+ expect(Users::UpdateService).to receive(:new).and_call_original
+ expect { subject }.to change { user.reload.setup_for_company }.to(false)
+ end
+ end
+ end
+
+ context 'when creating a group with the `jobs_to_be_done` attribute present' do
+ it 'sets the groups `jobs_to_be_done` value' do
+ sign_in(user)
+ post :create, params: { group: { name: 'new_group', path: 'new_group', jobs_to_be_done: 'other' } }
+ expect(Group.last.jobs_to_be_done).to eq('other')
+ end
+ end
end
describe 'GET #index' do
diff --git a/spec/controllers/import/manifest_controller_spec.rb b/spec/controllers/import/manifest_controller_spec.rb
index d5a498e80d9..0111ad9501f 100644
--- a/spec/controllers/import/manifest_controller_spec.rb
+++ b/spec/controllers/import/manifest_controller_spec.rb
@@ -75,16 +75,6 @@ RSpec.describe Import::ManifestController, :clean_gitlab_redis_shared_state do
expect(json_response.dig("provider_repos", 0, "id")).to eq(repo1[:id])
expect(json_response.dig("provider_repos", 1, "id")).to eq(repo2[:id])
end
-
- it "does not show already added project" do
- project = create(:project, import_type: 'manifest', namespace: user.namespace, import_status: :finished, import_url: repo1[:url])
-
- get :status, format: :json
-
- expect(json_response.dig("imported_projects", 0, "id")).to eq(project.id)
- expect(json_response.dig("provider_repos").length).to eq(1)
- expect(json_response.dig("provider_repos", 0, "id")).not_to eq(repo1[:id])
- end
end
context 'when the data is stored via Gitlab::ManifestImport::Metadata' do
diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb
index dc1fb0454df..d4091461062 100644
--- a/spec/controllers/invites_controller_spec.rb
+++ b/spec/controllers/invites_controller_spec.rb
@@ -120,6 +120,29 @@ RSpec.describe InvitesController do
end
end
+ context 'when it is part of the invite_email_from experiment' do
+ let(:extra_params) { { invite_type: 'initial_email', experiment_name: 'invite_email_from' } }
+
+ it 'tracks the initial join click from email' do
+ experiment = double(track: true)
+ allow(controller).to receive(:experiment).with(:invite_email_from, actor: member).and_return(experiment)
+
+ request
+
+ expect(experiment).to have_received(:track).with(:join_clicked)
+ end
+
+ context 'when member does not exist' do
+ let(:raw_invite_token) { '_bogus_token_' }
+
+ it 'does not track the experiment' do
+ expect(controller).not_to receive(:experiment).with(:invite_email_from, actor: member)
+
+ request
+ end
+ end
+ end
+
context 'when member does not exist' do
let(:raw_invite_token) { '_bogus_token_' }
@@ -147,8 +170,9 @@ RSpec.describe InvitesController do
end
context 'when it is not part of our invite email experiment' do
- it 'does not track via experiment' do
+ it 'does not track via experiment', :aggregate_failures do
expect(controller).not_to receive(:experiment).with(:invite_email_preview_text, actor: member)
+ expect(controller).not_to receive(:experiment).with(:invite_email_from, actor: member)
request
end
diff --git a/spec/controllers/omniauth_callbacks_controller_spec.rb b/spec/controllers/omniauth_callbacks_controller_spec.rb
index 9a142559fca..8c8de2f79a3 100644
--- a/spec/controllers/omniauth_callbacks_controller_spec.rb
+++ b/spec/controllers/omniauth_callbacks_controller_spec.rb
@@ -317,7 +317,7 @@ RSpec.describe OmniauthCallbacksController, type: :controller do
it 'denies sign-in if sign-up is enabled, but block_auto_created_users is set' do
post :atlassian_oauth2
- expect(flash[:alert]).to start_with 'Your account has been blocked.'
+ expect(flash[:alert]).to start_with 'Your account is pending approval'
end
it 'accepts sign-in if sign-up is enabled' do
@@ -399,7 +399,7 @@ RSpec.describe OmniauthCallbacksController, type: :controller do
it 'denies login if sign up is enabled, but block_auto_created_users is set' do
post :saml, params: { SAMLResponse: mock_saml_response }
- expect(flash[:alert]).to start_with 'Your account has been blocked.'
+ expect(flash[:alert]).to start_with 'Your account is pending approval'
end
it 'accepts login if sign up is enabled' do
diff --git a/spec/controllers/profiles/two_factor_auths_controller_spec.rb b/spec/controllers/profiles/two_factor_auths_controller_spec.rb
index 818bf2a4ae6..073180cbafd 100644
--- a/spec/controllers/profiles/two_factor_auths_controller_spec.rb
+++ b/spec/controllers/profiles/two_factor_auths_controller_spec.rb
@@ -10,8 +10,33 @@ RSpec.describe Profiles::TwoFactorAuthsController do
allow(subject).to receive(:current_user).and_return(user)
end
+ shared_examples 'user must first verify their primary email address' do
+ before do
+ allow(user).to receive(:primary_email_verified?).and_return(false)
+ end
+
+ it 'redirects to profile_emails_path' do
+ go
+
+ expect(response).to redirect_to(profile_emails_path)
+ end
+
+ it 'displays a notice' do
+ go
+
+ expect(flash[:notice])
+ .to eq _('You need to verify your primary email first before enabling Two-Factor Authentication.')
+ end
+
+ it 'does not redirect when the `ensure_verified_primary_email_for_2fa` feature flag is disabled' do
+ stub_feature_flags(ensure_verified_primary_email_for_2fa: false)
+
+ expect(response).not_to redirect_to(profile_emails_path)
+ end
+ end
+
describe 'GET show' do
- let(:user) { create(:user) }
+ let_it_be_with_reload(:user) { create(:user) }
it 'generates otp_secret for user' do
expect(User).to receive(:generate_otp_secret).with(32).and_call_original.once
@@ -34,11 +59,16 @@ RSpec.describe Profiles::TwoFactorAuthsController do
get :show
end
end
+
+ it_behaves_like 'user must first verify their primary email address' do
+ let(:go) { get :show }
+ end
end
describe 'POST create' do
- let(:user) { create(:user) }
- let(:pin) { 'pin-code' }
+ let_it_be_with_reload(:user) { create(:user) }
+
+ let(:pin) { 'pin-code' }
def go
post :create, params: { pin_code: pin }
@@ -70,8 +100,8 @@ RSpec.describe Profiles::TwoFactorAuthsController do
go
end
- it 'dismisses the `ACCOUNT_RECOVERY_REGULAR_CHECK` callout' do
- expect(controller.helpers).to receive(:dismiss_account_recovery_regular_check)
+ it 'dismisses the `TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK` callout' do
+ expect(controller.helpers).to receive(:dismiss_two_factor_auth_recovery_settings_check)
go
end
@@ -105,10 +135,12 @@ RSpec.describe Profiles::TwoFactorAuthsController do
expect(response).to render_template(:show)
end
end
+
+ it_behaves_like 'user must first verify their primary email address'
end
describe 'POST codes' do
- let(:user) { create(:user, :two_factor) }
+ let_it_be_with_reload(:user) { create(:user, :two_factor) }
it 'presents plaintext codes for the user to save' do
expect(user).to receive(:generate_otp_backup_codes!).and_return(%w(a b c))
@@ -124,8 +156,8 @@ RSpec.describe Profiles::TwoFactorAuthsController do
expect(user.otp_backup_codes).not_to be_empty
end
- it 'dismisses the `ACCOUNT_RECOVERY_REGULAR_CHECK` callout' do
- expect(controller.helpers).to receive(:dismiss_account_recovery_regular_check)
+ it 'dismisses the `TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK` callout' do
+ expect(controller.helpers).to receive(:dismiss_two_factor_auth_recovery_settings_check)
post :codes
end
@@ -135,7 +167,7 @@ RSpec.describe Profiles::TwoFactorAuthsController do
subject { delete :destroy }
context 'for a user that has 2FA enabled' do
- let(:user) { create(:user, :two_factor) }
+ let_it_be_with_reload(:user) { create(:user, :two_factor) }
it 'disables two factor' do
subject
@@ -158,7 +190,7 @@ RSpec.describe Profiles::TwoFactorAuthsController do
end
context 'for a user that does not have 2FA enabled' do
- let(:user) { create(:user) }
+ let_it_be_with_reload(:user) { create(:user) }
it 'redirects to profile_account_path' do
subject
diff --git a/spec/controllers/projects/analytics/cycle_analytics/summary_controller_spec.rb b/spec/controllers/projects/analytics/cycle_analytics/summary_controller_spec.rb
index 1832b84ab6e..a366b2583d4 100644
--- a/spec/controllers/projects/analytics/cycle_analytics/summary_controller_spec.rb
+++ b/spec/controllers/projects/analytics/cycle_analytics/summary_controller_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Projects::Analytics::CycleAnalytics::SummaryController do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
- let(:params) { { namespace_id: project.namespace.to_param, project_id: project.to_param, created_after: '2010-01-01', created_before: '2010-01-02' } }
+ let(:params) { { namespace_id: project.namespace.to_param, project_id: project.to_param, created_after: '2010-01-01', created_before: '2010-02-01' } }
before do
sign_in(user)
@@ -42,5 +42,39 @@ RSpec.describe Projects::Analytics::CycleAnalytics::SummaryController do
expect(response).to have_gitlab_http_status(:not_found)
end
end
+
+ context 'when filters are applied' do
+ let_it_be(:author) { create(:user) }
+ let_it_be(:milestone) { create(:milestone, title: 'milestone 1', project: project) }
+ let_it_be(:issue_with_author) { create(:issue, project: project, author: author, created_at: Date.new(2010, 1, 15)) }
+ let_it_be(:issue_with_other_author) { create(:issue, project: project, author: user, created_at: Date.new(2010, 1, 15)) }
+ let_it_be(:issue_with_milestone) { create(:issue, project: project, milestone: milestone, created_at: Date.new(2010, 1, 15)) }
+
+ before do
+ project.add_reporter(user)
+ end
+
+ it 'filters by author username' do
+ params[:author_username] = author.username
+
+ subject
+
+ expect(response).to be_successful
+
+ issue_count = json_response.first
+ expect(issue_count['value']).to eq('1')
+ end
+
+ it 'filters by milestone title' do
+ params[:milestone_title] = milestone.title
+
+ subject
+
+ expect(response).to be_successful
+
+ issue_count = json_response.first
+ expect(issue_count['value']).to eq('1')
+ end
+ end
end
end
diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb
index 7103d7df5c5..0fcdeb2edde 100644
--- a/spec/controllers/projects/environments_controller_spec.rb
+++ b/spec/controllers/projects/environments_controller_spec.rb
@@ -222,6 +222,16 @@ RSpec.describe Projects::EnvironmentsController do
expect(response).to have_gitlab_http_status(:bad_request)
end
end
+
+ context 'when name is passed' do
+ let(:params) { environment_params.merge(environment: { name: "new name" }) }
+
+ it 'ignores name' do
+ expect do
+ subject
+ end.not_to change { environment.reload.name }
+ end
+ end
end
describe 'PATCH #stop' do
diff --git a/spec/controllers/projects/feature_flags_controller_spec.rb b/spec/controllers/projects/feature_flags_controller_spec.rb
index e038b247eff..fd95aa44568 100644
--- a/spec/controllers/projects/feature_flags_controller_spec.rb
+++ b/spec/controllers/projects/feature_flags_controller_spec.rb
@@ -94,20 +94,6 @@ RSpec.describe Projects::FeatureFlagsController do
is_expected.to match_response_schema('feature_flags')
end
- it 'returns false for active when the feature flag is inactive even if it has an active scope' do
- create(:operations_feature_flag_scope,
- feature_flag: feature_flag_inactive,
- environment_scope: 'production',
- active: true)
-
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- feature_flag_json = json_response['feature_flags'].second
-
- expect(feature_flag_json['active']).to eq(false)
- end
-
it 'returns the feature flag iid' do
subject
@@ -181,7 +167,7 @@ RSpec.describe Projects::FeatureFlagsController do
subject { get(:show, params: params, format: :json) }
let!(:feature_flag) do
- create(:operations_feature_flag, :legacy_flag, project: project)
+ create(:operations_feature_flag, project: project)
end
let(:params) do
@@ -197,7 +183,7 @@ RSpec.describe Projects::FeatureFlagsController do
expect(json_response['name']).to eq(feature_flag.name)
expect(json_response['active']).to eq(feature_flag.active)
- expect(json_response['version']).to eq('legacy_flag')
+ expect(json_response['version']).to eq('new_version_flag')
end
it 'matches json schema' do
@@ -245,46 +231,6 @@ RSpec.describe Projects::FeatureFlagsController do
end
end
- context 'when feature flags have additional scopes' do
- context 'when there is at least one active scope' do
- let!(:feature_flag) do
- create(:operations_feature_flag, project: project, active: false)
- end
-
- let!(:feature_flag_scope_production) do
- create(:operations_feature_flag_scope,
- feature_flag: feature_flag,
- environment_scope: 'review/*',
- active: true)
- end
-
- it 'returns false for active' do
- subject
-
- expect(json_response['active']).to eq(false)
- end
- end
-
- context 'when all scopes are inactive' do
- let!(:feature_flag) do
- create(:operations_feature_flag, project: project, active: false)
- end
-
- let!(:feature_flag_scope_production) do
- create(:operations_feature_flag_scope,
- feature_flag: feature_flag,
- environment_scope: 'production',
- active: false)
- end
-
- it 'recognizes the feature flag as inactive' do
- subject
-
- expect(json_response['active']).to be_falsy
- end
- end
- end
-
context 'with a version 2 feature flag' do
let!(:new_version_feature_flag) do
create(:operations_feature_flag, :new_version_flag, project: project)
@@ -320,22 +266,6 @@ RSpec.describe Projects::FeatureFlagsController do
describe 'GET edit' do
subject { get(:edit, params: params) }
- context 'with legacy flags' do
- let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag, project: project) }
-
- let(:params) do
- {
- namespace_id: project.namespace,
- project_id: project,
- iid: feature_flag.iid
- }
- end
-
- it 'returns not found' do
- is_expected.to have_gitlab_http_status(:not_found)
- end
- end
-
context 'with new version flags' do
let!(:feature_flag) { create(:operations_feature_flag, project: project) }
@@ -378,14 +308,6 @@ RSpec.describe Projects::FeatureFlagsController do
expect(json_response['active']).to be_truthy
end
- it 'creates a default scope' do
- subject
-
- expect(json_response['scopes'].count).to eq(1)
- expect(json_response['scopes'].first['environment_scope']).to eq('*')
- expect(json_response['scopes'].first['active']).to be_truthy
- end
-
it 'matches json schema' do
is_expected.to match_response_schema('feature_flag')
end
@@ -435,119 +357,6 @@ RSpec.describe Projects::FeatureFlagsController do
end
end
- context 'when creates additional scope' do
- let(:params) do
- view_params.merge({
- operations_feature_flag: {
- name: 'my_feature_flag',
- active: true,
- scopes_attributes: [{ environment_scope: '*', active: true },
- { environment_scope: 'production', active: false }]
- }
- })
- end
-
- it 'creates feature flag scopes successfully' do
- expect { subject }.to change { Operations::FeatureFlagScope.count }.by(2)
-
- expect(response).to have_gitlab_http_status(:ok)
- end
-
- it 'creates feature flag scopes in a correct order' do
- subject
-
- expect(json_response['scopes'].first['environment_scope']).to eq('*')
- expect(json_response['scopes'].second['environment_scope']).to eq('production')
- end
-
- context 'when default scope is not placed first' do
- let(:params) do
- view_params.merge({
- operations_feature_flag: {
- name: 'my_feature_flag',
- active: true,
- scopes_attributes: [{ environment_scope: 'production', active: false },
- { environment_scope: '*', active: true }]
- }
- })
- end
-
- it 'returns 400' do
- subject
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['message'])
- .to include('Default scope has to be the first element')
- end
- end
- end
-
- context 'when creates additional scope with a percentage rollout' do
- it 'creates a strategy for the scope' do
- params = view_params.merge({
- operations_feature_flag: {
- name: 'my_feature_flag',
- active: true,
- scopes_attributes: [{ environment_scope: '*', active: true },
- { environment_scope: 'production', active: false,
- strategies: [{ name: 'gradualRolloutUserId',
- parameters: { groupId: 'default', percentage: '42' } }] }]
- }
- })
-
- post(:create, params: params, format: :json)
-
- expect(response).to have_gitlab_http_status(:ok)
- production_strategies_json = json_response['scopes'].second['strategies']
- expect(production_strategies_json).to eq([{
- 'name' => 'gradualRolloutUserId',
- 'parameters' => { "groupId" => "default", "percentage" => "42" }
- }])
- end
- end
-
- context 'when creates additional scope with a userWithId strategy' do
- it 'creates a strategy for the scope' do
- params = view_params.merge({
- operations_feature_flag: {
- name: 'my_feature_flag',
- active: true,
- scopes_attributes: [{ environment_scope: '*', active: true },
- { environment_scope: 'production', active: false,
- strategies: [{ name: 'userWithId',
- parameters: { userIds: '123,4,6722' } }] }]
- }
- })
-
- post(:create, params: params, format: :json)
-
- expect(response).to have_gitlab_http_status(:ok)
- production_strategies_json = json_response['scopes'].second['strategies']
- expect(production_strategies_json).to eq([{
- 'name' => 'userWithId',
- 'parameters' => { "userIds" => "123,4,6722" }
- }])
- end
- end
-
- context 'when creates an additional scope without a strategy' do
- it 'creates a default strategy' do
- params = view_params.merge({
- operations_feature_flag: {
- name: 'my_feature_flag',
- active: true,
- scopes_attributes: [{ environment_scope: '*', active: true }]
- }
- })
-
- post(:create, params: params, format: :json)
-
- expect(response).to have_gitlab_http_status(:ok)
- default_strategies_json = json_response['scopes'].first['strategies']
- expect(default_strategies_json).to eq([{ "name" => "default", "parameters" => {} }])
- end
- end
-
context 'when creating a version 2 feature flag' do
let(:params) do
{
@@ -744,7 +553,7 @@ RSpec.describe Projects::FeatureFlagsController do
describe 'DELETE destroy.json' do
subject { delete(:destroy, params: params, format: :json) }
- let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag, project: project) }
+ let!(:feature_flag) { create(:operations_feature_flag, project: project) }
let(:params) do
{
@@ -762,10 +571,6 @@ RSpec.describe Projects::FeatureFlagsController do
expect { subject }.to change { Operations::FeatureFlag.count }.by(-1)
end
- it 'destroys the default scope' do
- expect { subject }.to change { Operations::FeatureFlagScope.count }.by(-1)
- end
-
it 'matches json schema' do
is_expected.to match_response_schema('feature_flag')
end
@@ -792,14 +597,6 @@ RSpec.describe Projects::FeatureFlagsController do
end
end
- context 'when there is an additional scope' do
- let!(:scope) { create_scope(feature_flag, 'production', false) }
-
- it 'destroys the default scope and production scope' do
- expect { subject }.to change { Operations::FeatureFlagScope.count }.by(-2)
- end
- end
-
context 'with a version 2 flag' do
let!(:new_version_flag) { create(:operations_feature_flag, :new_version_flag, project: project) }
let(:params) do
@@ -828,70 +625,9 @@ RSpec.describe Projects::FeatureFlagsController do
put(:update, params: params, format: :json, as: :json)
end
- context 'with a legacy feature flag' do
- subject { put(:update, params: params, format: :json) }
-
- let!(:feature_flag) do
- create(:operations_feature_flag,
- :legacy_flag,
- name: 'ci_live_trace',
- active: true,
- project: project)
- end
-
- let(:params) do
- {
- namespace_id: project.namespace,
- project_id: project,
- iid: feature_flag.iid,
- operations_feature_flag: {
- name: 'ci_new_live_trace'
- }
- }
- end
-
- context 'when user is reporter' do
- let(:user) { reporter }
-
- it 'returns 404' do
- is_expected.to have_gitlab_http_status(:not_found)
- end
- end
-
- context "when changing default scope's spec" do
- let(:params) do
- {
- namespace_id: project.namespace,
- project_id: project,
- iid: feature_flag.iid,
- operations_feature_flag: {
- scopes_attributes: [
- {
- id: feature_flag.default_scope.id,
- environment_scope: 'review/*'
- }
- ]
- }
- }
- end
-
- it 'returns 400' do
- is_expected.to have_gitlab_http_status(:bad_request)
- end
- end
-
- it 'does not update a legacy feature flag' do
- put_request(feature_flag, name: 'ci_new_live_trace')
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['message']).to eq(["Legacy feature flags are read-only"])
- end
- end
-
context 'with a version 2 feature flag' do
let!(:new_version_flag) do
create(:operations_feature_flag,
- :new_version_flag,
name: 'new-feature',
active: true,
project: project)
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index 0c29280316a..977879b453c 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -109,6 +109,14 @@ RSpec.describe Projects::IssuesController do
end
end
+ it_behaves_like 'issuable list with anonymous search disabled' do
+ let(:params) { { namespace_id: project.namespace, project_id: project } }
+
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+ end
+ end
+
it_behaves_like 'paginated collection' do
let!(:issue_list) { create_list(:issue, 2, project: project) }
let(:collection) { project.issues }
@@ -301,6 +309,8 @@ RSpec.describe Projects::IssuesController do
it 'fills in an issue for a discussion' do
note = create(:note_on_merge_request, project: project)
+ expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter).to receive(:track_resolve_thread_in_issue_action).with(user: user)
+
get :new, params: { namespace_id: project.namespace.path, project_id: project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id }
expect(assigns(:issue).title).not_to be_empty
@@ -1176,12 +1186,22 @@ RSpec.describe Projects::IssuesController do
project.issues.first
end
+ context 'when creating an incident' do
+ it 'sets the correct issue_type' do
+ issue = post_new_issue(issue_type: 'incident')
+
+ expect(issue.issue_type).to eq('incident')
+ expect(issue.work_item_type.base_type).to eq('incident')
+ end
+ end
+
it 'creates the issue successfully', :aggregate_failures do
issue = post_new_issue
expect(issue).to be_a(Issue)
expect(issue.persisted?).to eq(true)
expect(issue.issue_type).to eq('issue')
+ expect(issue.work_item_type.base_type).to eq('issue')
end
context 'resolving discussions in MergeRequest' do
diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb
index e9e7c3c3bb3..06c29e767ad 100644
--- a/spec/controllers/projects/jobs_controller_spec.rb
+++ b/spec/controllers/projects/jobs_controller_spec.rb
@@ -755,23 +755,52 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do
before do
project.add_developer(user)
sign_in(user)
-
- post_retry
end
context 'when job is retryable' do
let(:job) { create(:ci_build, :retryable, pipeline: pipeline) }
it 'redirects to the retried job page' do
+ post_retry
+
expect(response).to have_gitlab_http_status(:found)
expect(response).to redirect_to(namespace_project_job_path(id: Ci::Build.last.id))
end
+
+ shared_examples_for 'retried job has the same attributes' do
+ it 'creates a new build has the same attributes from the previous build' do
+ expect { post_retry }.to change { Ci::Build.count }.by(1)
+
+ retried_build = Ci::Build.last
+
+ Ci::RetryBuildService.clone_accessors.each do |accessor|
+ expect(job.read_attribute(accessor))
+ .to eq(retried_build.read_attribute(accessor)),
+ "Mismatched attribute on \"#{accessor}\". " \
+ "It was \"#{job.read_attribute(accessor)}\" but changed to \"#{retried_build.read_attribute(accessor)}\""
+ end
+ end
+ end
+
+ context 'with branch pipeline' do
+ let!(:job) { create(:ci_build, :retryable, tag: true, when: 'on_success', pipeline: pipeline) }
+
+ it_behaves_like 'retried job has the same attributes'
+ end
+
+ context 'with tag pipeline' do
+ let!(:job) { create(:ci_build, :retryable, tag: false, when: 'on_success', pipeline: pipeline) }
+
+ it_behaves_like 'retried job has the same attributes'
+ end
end
context 'when job is not retryable' do
let(:job) { create(:ci_build, pipeline: pipeline) }
it 'renders unprocessable_entity' do
+ post_retry
+
expect(response).to have_gitlab_http_status(:unprocessable_entity)
end
end
diff --git a/spec/controllers/projects/learn_gitlab_controller_spec.rb b/spec/controllers/projects/learn_gitlab_controller_spec.rb
index f633f7aa246..620982f73be 100644
--- a/spec/controllers/projects/learn_gitlab_controller_spec.rb
+++ b/spec/controllers/projects/learn_gitlab_controller_spec.rb
@@ -7,13 +7,13 @@ RSpec.describe Projects::LearnGitlabController do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, namespace: user.namespace) }
- let(:learn_gitlab_experiment_enabled) { true }
+ let(:learn_gitlab_enabled) { true }
let(:params) { { namespace_id: project.namespace.to_param, project_id: project } }
subject { get :index, params: params }
before do
- allow(controller.helpers).to receive(:learn_gitlab_experiment_enabled?).and_return(learn_gitlab_experiment_enabled)
+ allow(controller.helpers).to receive(:learn_gitlab_enabled?).and_return(learn_gitlab_enabled)
end
context 'unauthenticated user' do
@@ -27,15 +27,8 @@ RSpec.describe Projects::LearnGitlabController do
it { is_expected.to render_template(:index) }
- it 'pushes experiment to frontend' do
- expect(controller).to receive(:push_frontend_experiment).with(:learn_gitlab_a, subject: user)
- expect(controller).to receive(:push_frontend_experiment).with(:learn_gitlab_b, subject: user)
-
- subject
- end
-
context 'learn_gitlab experiment not enabled' do
- let(:learn_gitlab_experiment_enabled) { false }
+ let(:learn_gitlab_enabled) { false }
it { is_expected.to have_gitlab_http_status(:not_found) }
end
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index 7b5a58fe2e5..0da8a30611c 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -349,6 +349,15 @@ RSpec.describe Projects::MergeRequestsController do
end
end
end
+
+ it_behaves_like 'issuable list with anonymous search disabled' do
+ let(:params) { { namespace_id: project.namespace, project_id: project } }
+
+ before do
+ sign_out(user)
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+ end
+ end
end
describe 'PUT update' do
diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb
index 65a563fac7c..1354e894872 100644
--- a/spec/controllers/projects/pipelines_controller_spec.rb
+++ b/spec/controllers/projects/pipelines_controller_spec.rb
@@ -311,23 +311,42 @@ RSpec.describe Projects::PipelinesController do
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
- def create_build_with_artifacts(stage, stage_idx, name)
- create(:ci_build, :artifacts, :tags, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name)
+ def create_build_with_artifacts(stage, stage_idx, name, status)
+ create(:ci_build, :artifacts, :tags, status, user: user, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name)
+ end
+
+ def create_bridge(stage, stage_idx, name, status)
+ create(:ci_bridge, status, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name)
end
before do
- create_build_with_artifacts('build', 0, 'job1')
- create_build_with_artifacts('build', 0, 'job2')
+ create_build_with_artifacts('build', 0, 'job1', :failed)
+ create_build_with_artifacts('build', 0, 'job2', :running)
+ create_build_with_artifacts('build', 0, 'job3', :pending)
+ create_bridge('deploy', 1, 'deploy-a', :failed)
+ create_bridge('deploy', 1, 'deploy-b', :created)
end
- it 'avoids N+1 database queries', :request_store do
- control_count = ActiveRecord::QueryRecorder.new { get_pipeline_html }.count
+ it 'avoids N+1 database queries', :request_store, :use_sql_query_cache do
+ # warm up
+ get_pipeline_html
expect(response).to have_gitlab_http_status(:ok)
- create_build_with_artifacts('build', 0, 'job3')
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ get_pipeline_html
+ expect(response).to have_gitlab_http_status(:ok)
+ end
- expect { get_pipeline_html }.not_to exceed_query_limit(control_count)
- expect(response).to have_gitlab_http_status(:ok)
+ create_build_with_artifacts('build', 0, 'job4', :failed)
+ create_build_with_artifacts('build', 0, 'job5', :running)
+ create_build_with_artifacts('build', 0, 'job6', :pending)
+ create_bridge('deploy', 1, 'deploy-c', :failed)
+ create_bridge('deploy', 1, 'deploy-d', :created)
+
+ expect do
+ get_pipeline_html
+ expect(response).to have_gitlab_http_status(:ok)
+ end.not_to exceed_all_query_limit(control)
end
end
@@ -1273,6 +1292,38 @@ RSpec.describe Projects::PipelinesController do
end
end
+ context 'when project uses external project ci config' do
+ let(:other_project) { create(:project) }
+ let(:sha) { 'master' }
+ let(:service) { ::Ci::ListConfigVariablesService.new(other_project, user) }
+
+ let(:ci_config) do
+ {
+ variables: {
+ KEY1: { value: 'val 1', description: 'description 1' }
+ },
+ test: {
+ stage: 'test',
+ script: 'echo'
+ }
+ }
+ end
+
+ before do
+ project.update!(ci_config_path: ".gitlab-ci.yml@#{other_project.full_path}")
+ synchronous_reactive_cache(service)
+ end
+
+ it 'returns other project config variables' do
+ expect(::Ci::ListConfigVariablesService).to receive(:new).with(other_project, anything).and_return(service)
+
+ get_config_variables
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['KEY1']).to eq({ 'value' => 'val 1', 'description' => 'description 1' })
+ end
+ end
+
private
def stub_gitlab_ci_yml_for_sha(sha, result)
diff --git a/spec/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb
index 419b5c7e101..482ba552f8f 100644
--- a/spec/controllers/projects/services_controller_spec.rb
+++ b/spec/controllers/projects/services_controller_spec.rb
@@ -18,6 +18,18 @@ RSpec.describe Projects::ServicesController do
project.add_maintainer(user)
end
+ it_behaves_like IntegrationsActions do
+ let(:integration_attributes) { { project: project } }
+
+ let(:routing_params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: integration.to_param
+ }
+ end
+ end
+
describe '#test' do
context 'when the integration is not testable' do
it 'renders 404' do
diff --git a/spec/controllers/registrations/experience_levels_controller_spec.rb b/spec/controllers/registrations/experience_levels_controller_spec.rb
deleted file mode 100644
index ad145264bb8..00000000000
--- a/spec/controllers/registrations/experience_levels_controller_spec.rb
+++ /dev/null
@@ -1,159 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Registrations::ExperienceLevelsController do
- include AfterNextHelpers
-
- let_it_be(:namespace) { create(:group, path: 'group-path' ) }
- let_it_be(:user) { create(:user) }
-
- let(:params) { { namespace_path: namespace.to_param } }
-
- describe 'GET #show' do
- subject { get :show, params: params }
-
- context 'with an unauthenticated user' do
- it { is_expected.to have_gitlab_http_status(:redirect) }
- it { is_expected.to redirect_to(new_user_session_path) }
- end
-
- context 'with an authenticated user' do
- before do
- sign_in(user)
- end
-
- it { is_expected.to have_gitlab_http_status(:ok) }
- it { is_expected.to render_template('layouts/minimal') }
- it { is_expected.to render_template(:show) }
- end
- end
-
- describe 'PUT/PATCH #update' do
- subject { patch :update, params: params }
-
- context 'with an unauthenticated user' do
- it { is_expected.to have_gitlab_http_status(:redirect) }
- it { is_expected.to redirect_to(new_user_session_path) }
- end
-
- context 'with an authenticated user' do
- let_it_be(:project) { build(:project, namespace: namespace, creator: user, path: 'project-path') }
- let_it_be(:issues_board) { build(:board, id: 123, project: project) }
-
- before do
- sign_in(user)
- end
-
- context 'when user is successfully updated' do
- context 'when no experience_level is sent' do
- before do
- user.user_preference.update_attribute(:experience_level, :novice)
- end
-
- it 'will unset the user’s experience level' do
- expect { subject }.to change { user.reload.experience_level }.to(nil)
- end
- end
-
- context 'when an expected experience level is sent' do
- let(:params) { super().merge(experience_level: :novice) }
-
- it 'sets the user’s experience level' do
- expect { subject }.to change { user.reload.experience_level }.from(nil).to('novice')
- end
- end
-
- context 'when an unexpected experience level is sent' do
- let(:params) { super().merge(experience_level: :nonexistent) }
-
- it 'raises an exception' do
- expect { subject }.to raise_error(ArgumentError, "'nonexistent' is not a valid experience_level")
- end
- end
-
- context 'when "Learn GitLab" project exists' do
- let(:learn_gitlab_available?) { true }
-
- before do
- allow_next_instance_of(LearnGitlab::Project) do |learn_gitlab|
- allow(learn_gitlab).to receive(:available?).and_return(learn_gitlab_available?)
- allow(learn_gitlab).to receive(:project).and_return(project)
- allow(learn_gitlab).to receive(:board).and_return(issues_board)
- allow(learn_gitlab).to receive(:label).and_return(double(id: 1))
- end
- end
-
- context 'redirection' do
- context 'when namespace_path param is missing' do
- let(:params) { super().merge(namespace_path: nil) }
-
- where(
- learn_gitlab_available?: [true, false]
- )
-
- with_them do
- it { is_expected.to redirect_to('/') }
- end
- end
-
- context 'when we have a namespace_path param' do
- using RSpec::Parameterized::TableSyntax
-
- where(:learn_gitlab_available?, :path) do
- true | '/group-path/project-path/-/boards/123'
- false | '/group-path'
- end
-
- with_them do
- it { is_expected.to redirect_to(path) }
- end
- end
- end
-
- context 'when novice' do
- let(:params) { super().merge(experience_level: :novice) }
-
- it 'adds a BoardLabel' do
- expect_next(Boards::UpdateService).to receive(:execute)
-
- subject
- end
- end
-
- context 'when experienced' do
- let(:params) { super().merge(experience_level: :experienced) }
-
- it 'does not add a BoardLabel' do
- expect(Boards::UpdateService).not_to receive(:new)
-
- subject
- end
- end
- end
-
- context 'when no "Learn GitLab" project exists' do
- let(:params) { super().merge(experience_level: :novice) }
-
- before do
- allow_next(LearnGitlab::Project).to receive(:available?).and_return(false)
- end
-
- it 'does not add a BoardLabel' do
- expect(Boards::UpdateService).not_to receive(:new)
-
- subject
- end
- end
- end
-
- context 'when user update fails' do
- before do
- allow_any_instance_of(User).to receive(:save).and_return(false)
- end
-
- it { is_expected.to render_template(:show) }
- end
- end
- end
-end
diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb
index 301c60e89c8..5edd60ebc79 100644
--- a/spec/controllers/registrations_controller_spec.rb
+++ b/spec/controllers/registrations_controller_spec.rb
@@ -227,6 +227,40 @@ RSpec.describe RegistrationsController do
end
end
end
+
+ context 'with the invite_email_preview_text experiment', :experiment do
+ let(:extra_session_params) { { invite_email_experiment_name: 'invite_email_from' } }
+
+ context 'when member and invite_email_experiment_name exists from the session key value' do
+ it 'tracks the invite acceptance' do
+ expect(experiment(:invite_email_from)).to track(:accepted)
+ .with_context(actor: member)
+ .on_next_instance
+
+ subject
+ end
+ end
+
+ context 'when member does not exist from the session key value' do
+ let(:originating_member_id) { -1 }
+
+ it 'does not track invite acceptance' do
+ expect(experiment(:invite_email_from)).not_to track(:accepted)
+
+ subject
+ end
+ end
+
+ context 'when invite_email_experiment_name does not exist from the session key value' do
+ let(:extra_session_params) { {} }
+
+ it 'does not track invite acceptance' do
+ expect(experiment(:invite_email_from)).not_to track(:accepted)
+
+ subject
+ end
+ end
+ end
end
context 'when invite email matches email used on registration' do
@@ -249,6 +283,26 @@ RSpec.describe RegistrationsController do
end
end
+ context 'when the registration fails' do
+ let_it_be(:member) { create(:project_member, :invited) }
+ let_it_be(:missing_user_params) do
+ { username: '', email: member.invite_email, password: 'Any_password' }
+ end
+
+ let_it_be(:user_params) { { user: missing_user_params } }
+
+ let(:session_params) { { invite_email: member.invite_email } }
+
+ subject { post(:create, params: user_params, session: session_params) }
+
+ it 'does not delete the invitation or register the new user' do
+ subject
+
+ expect(member.invite_token).not_to be_nil
+ expect(controller.current_user).to be_nil
+ end
+ end
+
context 'when soft email confirmation is enabled' do
before do
stub_feature_flags(soft_email_confirmation: true)
diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb
index e0870e17d99..4e87a9fc1ba 100644
--- a/spec/controllers/search_controller_spec.rb
+++ b/spec/controllers/search_controller_spec.rb
@@ -182,6 +182,37 @@ RSpec.describe SearchController do
end
end
end
+
+ context 'tab feature flags' do
+ subject { get :show, params: { scope: scope, search: 'term' }, format: :html }
+
+ where(:feature_flag, :scope) do
+ :global_search_code_tab | 'blobs'
+ :global_search_issues_tab | 'issues'
+ :global_search_merge_requests_tab | 'merge_requests'
+ :global_search_wiki_tab | 'wiki_blobs'
+ :global_search_commits_tab | 'commits'
+ end
+
+ with_them do
+ it 'returns 200 if flag is enabled' do
+ stub_feature_flags(feature_flag => true)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'redirects with alert if flag is disabled' do
+ stub_feature_flags(feature_flag => false)
+
+ subject
+
+ expect(response).to redirect_to search_path
+ expect(controller).to set_flash[:alert].to(/Global Search is disabled for this scope/)
+ end
+ end
+ end
end
it 'finds issue comments' do
diff --git a/spec/controllers/user_callouts_controller_spec.rb b/spec/controllers/user_callouts_controller_spec.rb
index 279f825e40f..3bb8d78a6b0 100644
--- a/spec/controllers/user_callouts_controller_spec.rb
+++ b/spec/controllers/user_callouts_controller_spec.rb
@@ -3,14 +3,16 @@
require 'spec_helper'
RSpec.describe UserCalloutsController do
- let(:user) { create(:user) }
+ let_it_be(:user) { create(:user) }
before do
sign_in(user)
end
describe "POST #create" do
- subject { post :create, params: { feature_name: feature_name }, format: :json }
+ let(:params) { { feature_name: feature_name } }
+
+ subject { post :create, params: params, format: :json }
context 'with valid feature name' do
let(:feature_name) { UserCallout.feature_names.each_key.first }
@@ -30,9 +32,8 @@ RSpec.describe UserCalloutsController do
context 'when callout entry already exists' do
let!(:callout) { create(:user_callout, feature_name: UserCallout.feature_names.each_key.first, user: user) }
- it 'returns success' do
- subject
-
+ it 'returns success', :aggregate_failures do
+ expect { subject }.not_to change { UserCallout.count }
expect(response).to have_gitlab_http_status(:ok)
end
end
diff --git a/spec/db/development/create_base_work_item_types_spec.rb b/spec/db/development/create_base_work_item_types_spec.rb
new file mode 100644
index 00000000000..914b84d8668
--- /dev/null
+++ b/spec/db/development/create_base_work_item_types_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Create base work item types in development' do
+ subject { load Rails.root.join('db', 'fixtures', 'development', '001_create_base_work_item_types.rb') }
+
+ it_behaves_like 'work item base types importer'
+end
diff --git a/spec/db/production/create_base_work_item_types_spec.rb b/spec/db/production/create_base_work_item_types_spec.rb
new file mode 100644
index 00000000000..81d80104bb4
--- /dev/null
+++ b/spec/db/production/create_base_work_item_types_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Create base work item types in production' do
+ subject { load Rails.root.join('db', 'fixtures', 'production', '003_create_base_work_item_types.rb') }
+
+ it_behaves_like 'work item base types importer'
+end
diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb
index 7e4b8c53885..c7739e2ff5f 100644
--- a/spec/db/schema_spec.rb
+++ b/spec/db/schema_spec.rb
@@ -18,6 +18,8 @@ RSpec.describe 'Database schema' do
approvals: %w[user_id],
approver_groups: %w[target_id],
approvers: %w[target_id user_id],
+ analytics_cycle_analytics_merge_request_stage_events: %w[author_id group_id merge_request_id milestone_id project_id stage_event_hash_id],
+ analytics_cycle_analytics_issue_stage_events: %w[author_id group_id issue_id milestone_id project_id stage_event_hash_id],
audit_events: %w[author_id entity_id target_id],
award_emoji: %w[awardable_id user_id],
aws_roles: %w[role_external_id],
@@ -33,6 +35,7 @@ RSpec.describe 'Database schema' do
cluster_providers_gcp: %w[gcp_project_id operation_id],
compliance_management_frameworks: %w[group_id],
commit_user_mentions: %w[commit_id],
+ dep_ci_build_trace_sections: %w[build_id],
deploy_keys_projects: %w[deploy_key_id],
deployments: %w[deployable_id user_id],
draft_notes: %w[discussion_id commit_id],
diff --git a/spec/deprecation_toolkit_env.rb b/spec/deprecation_toolkit_env.rb
index f4db2c30094..5e7ff34463c 100644
--- a/spec/deprecation_toolkit_env.rb
+++ b/spec/deprecation_toolkit_env.rb
@@ -60,13 +60,12 @@ module DeprecationToolkitEnv
# - ruby/lib/grpc/generic/interceptors.rb: https://gitlab.com/gitlab-org/gitlab/-/issues/339305
def self.allowed_kwarg_warning_paths
%w[
- actionpack-6.1.3.2/lib/action_dispatch/routing/route_set.rb
- ruby/lib/grpc/generic/interceptors.rb
- ]
+ ruby/lib/grpc/generic/interceptors.rb
+ ]
end
def self.configure!
- # Enable ruby deprecations for keywords, it's suppressed by default in Ruby 2.7.2
+ # Enable ruby deprecations for keywords, it's suppressed by default in Ruby 2.7
Warning[:deprecated] = true
DeprecationToolkit::Configuration.test_runner = :rspec
diff --git a/spec/experiments/security_reports_mr_widget_prompt_experiment_spec.rb b/spec/experiments/security_reports_mr_widget_prompt_experiment_spec.rb
new file mode 100644
index 00000000000..4328ff12d42
--- /dev/null
+++ b/spec/experiments/security_reports_mr_widget_prompt_experiment_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe SecurityReportsMrWidgetPromptExperiment do
+ it "defines a control and candidate" do
+ expect(subject.behaviors.keys).to match_array(%w[control candidate])
+ end
+
+ it "publishes to the database" do
+ expect(subject).to receive(:publish_to_database)
+
+ subject.publish
+ end
+end
diff --git a/spec/factories/ci/build_trace_metadata.rb b/spec/factories/ci/build_trace_metadata.rb
new file mode 100644
index 00000000000..e5f8ae40cc5
--- /dev/null
+++ b/spec/factories/ci/build_trace_metadata.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :ci_build_trace_metadata, class: 'Ci::BuildTraceMetadata' do
+ build factory: :ci_build
+ end
+end
diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb
index f3500301e22..1108c606df3 100644
--- a/spec/factories/ci/builds.rb
+++ b/spec/factories/ci/builds.rb
@@ -534,6 +534,14 @@ FactoryBot.define do
end
end
+ trait :coverage_fuzzing do
+ options do
+ {
+ artifacts: { reports: { coverage_fuzzing: 'gl-coverage-fuzzing-report.json' } }
+ }
+ end
+ end
+
trait :license_scanning do
options do
{
diff --git a/spec/factories/ci/pending_builds.rb b/spec/factories/ci/pending_builds.rb
index fbd76e07d8e..31e42e1bc9e 100644
--- a/spec/factories/ci/pending_builds.rb
+++ b/spec/factories/ci/pending_builds.rb
@@ -8,5 +8,6 @@ FactoryBot.define do
instance_runners_enabled { true }
namespace { project.namespace }
minutes_exceeded { false }
+ tag_ids { build.tags_ids }
end
end
diff --git a/spec/factories/ci/reports/security/flags.rb b/spec/factories/ci/reports/security/flags.rb
new file mode 100644
index 00000000000..7efe72276c9
--- /dev/null
+++ b/spec/factories/ci/reports/security/flags.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :ci_reports_security_flag, class: '::Gitlab::Ci::Reports::Security::Flag' do
+ type { 'flagged-as-likely-false-positive' }
+ origin { 'post analyzer X' }
+ description { 'static string to sink' }
+
+ skip_create
+
+ initialize_with do
+ ::Gitlab::Ci::Reports::Security::Flag.new(**attributes)
+ end
+ end
+end
diff --git a/spec/factories/clusters/agents.rb b/spec/factories/clusters/agents.rb
index 334671f69f0..4dc82f91bab 100644
--- a/spec/factories/clusters/agents.rb
+++ b/spec/factories/clusters/agents.rb
@@ -3,6 +3,7 @@
FactoryBot.define do
factory :cluster_agent, class: 'Clusters::Agent' do
project
+ association :created_by_user, factory: :user
sequence(:name) { |n| "agent-#{n}" }
end
diff --git a/spec/factories/clusters/agents/group_authorizations.rb b/spec/factories/clusters/agents/group_authorizations.rb
new file mode 100644
index 00000000000..6ea3668dc66
--- /dev/null
+++ b/spec/factories/clusters/agents/group_authorizations.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :agent_group_authorization, class: 'Clusters::Agents::GroupAuthorization' do
+ association :agent, factory: :cluster_agent
+ group
+
+ config { { default_namespace: 'production' } }
+ end
+end
diff --git a/spec/factories/clusters/agents/project_authorizations.rb b/spec/factories/clusters/agents/project_authorizations.rb
new file mode 100644
index 00000000000..176ecc3b517
--- /dev/null
+++ b/spec/factories/clusters/agents/project_authorizations.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :agent_project_authorization, class: 'Clusters::Agents::ProjectAuthorization' do
+ association :agent, factory: :cluster_agent
+ project
+
+ config { { default_namespace: 'production' } }
+ end
+end
diff --git a/spec/factories/compares.rb b/spec/factories/compares.rb
new file mode 100644
index 00000000000..4dd94b93049
--- /dev/null
+++ b/spec/factories/compares.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :compare do
+ skip_create # No persistence
+
+ start_project { association(:project, :repository) }
+ target_project { start_project }
+
+ start_ref { 'master' }
+ target_ref { 'feature' }
+
+ base_sha { nil }
+ straight { false }
+
+ initialize_with do
+ CompareService
+ .new(start_project, start_ref)
+ .execute(target_project, target_ref, base_sha: base_sha, straight: straight)
+ end
+ end
+end
diff --git a/spec/factories/customer_relations/contacts.rb b/spec/factories/customer_relations/contacts.rb
new file mode 100644
index 00000000000..437f8feea48
--- /dev/null
+++ b/spec/factories/customer_relations/contacts.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :contact, class: 'CustomerRelations::Contact' do
+ group
+
+ first_name { generate(:name) }
+ last_name { generate(:name) }
+
+ trait :with_organization do
+ organization
+ end
+ end
+end
diff --git a/spec/factories/dependency_proxy.rb b/spec/factories/dependency_proxy.rb
index 94a7986a8fa..c2873ce9b5e 100644
--- a/spec/factories/dependency_proxy.rb
+++ b/spec/factories/dependency_proxy.rb
@@ -3,12 +3,14 @@
FactoryBot.define do
factory :dependency_proxy_blob, class: 'DependencyProxy::Blob' do
group
+ size { 1234 }
file { fixture_file_upload('spec/fixtures/dependency_proxy/a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4.gz') }
file_name { 'a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4.gz' }
end
factory :dependency_proxy_manifest, class: 'DependencyProxy::Manifest' do
group
+ size { 1234 }
file { fixture_file_upload('spec/fixtures/dependency_proxy/manifest') }
digest { 'sha256:d0710affa17fad5f466a70159cc458227bd25d4afb39514ef662ead3e6c99515' }
file_name { 'alpine:latest.json' }
diff --git a/spec/factories/dependency_proxy/image_ttl_group_policies.rb b/spec/factories/dependency_proxy/image_ttl_group_policies.rb
new file mode 100644
index 00000000000..21e5dd44cf5
--- /dev/null
+++ b/spec/factories/dependency_proxy/image_ttl_group_policies.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :image_ttl_group_policy, class: 'DependencyProxy::ImageTtlGroupPolicy' do
+ group
+
+ enabled { true }
+ ttl { 90 }
+ end
+end
diff --git a/spec/factories/integration_data.rb b/spec/factories/integration_data.rb
index a7406794437..4d0892556f8 100644
--- a/spec/factories/integration_data.rb
+++ b/spec/factories/integration_data.rb
@@ -7,13 +7,21 @@ FactoryBot.define do
integration factory: :jira_integration
end
+ factory :zentao_tracker_data, class: 'Integrations::ZentaoTrackerData' do
+ integration factory: :zentao_integration
+ url { 'https://jihudemo.zentao.net' }
+ api_url { '' }
+ api_token { 'ZENTAO_TOKEN' }
+ zentao_product_xid { '3' }
+ end
+
factory :issue_tracker_data, class: 'Integrations::IssueTrackerData' do
integration
end
factory :open_project_tracker_data, class: 'Integrations::OpenProjectTrackerData' do
integration factory: :open_project_service
- url { 'http://openproject.example.com'}
+ url { 'http://openproject.example.com' }
token { 'supersecret' }
project_identifier_code { 'PRJ-1' }
closed_status_id { '15' }
diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb
index a5a17ca4058..cb1c94c25c1 100644
--- a/spec/factories/integrations.rb
+++ b/spec/factories/integrations.rb
@@ -12,6 +12,12 @@ FactoryBot.define do
issue_tracker
end
+ factory :datadog_integration, class: 'Integrations::Datadog' do
+ project
+ active { true }
+ api_key { 'secret' }
+ end
+
factory :emails_on_push_integration, class: 'Integrations::EmailsOnPush' do
project
type { 'EmailsOnPushService' }
@@ -79,6 +85,32 @@ FactoryBot.define do
end
end
+ factory :zentao_integration, class: 'Integrations::Zentao' do
+ project
+ active { true }
+ type { 'ZentaoService' }
+
+ transient do
+ create_data { true }
+ url { 'https://jihudemo.zentao.net' }
+ api_url { '' }
+ api_token { 'ZENTAO_TOKEN' }
+ zentao_product_xid { '3' }
+ end
+
+ after(:build) do |integration, evaluator|
+ if evaluator.create_data
+ integration.zentao_tracker_data = build(:zentao_tracker_data,
+ integration: integration,
+ url: evaluator.url,
+ api_url: evaluator.api_url,
+ api_token: evaluator.api_token,
+ zentao_product_xid: evaluator.zentao_product_xid
+ )
+ end
+ end
+ end
+
factory :confluence_integration, class: 'Integrations::Confluence' do
project
active { true }
diff --git a/spec/factories/issues.rb b/spec/factories/issues.rb
index 2d52747dece..8b53732a3c1 100644
--- a/spec/factories/issues.rb
+++ b/spec/factories/issues.rb
@@ -8,6 +8,7 @@ FactoryBot.define do
updated_by { author }
relative_position { RelativePositioning::START_POSITION }
issue_type { :issue }
+ association :work_item_type, :default
trait :confidential do
confidential { true }
@@ -59,6 +60,7 @@ FactoryBot.define do
factory :incident do
issue_type { :incident }
+ association :work_item_type, :default, :incident
end
end
end
diff --git a/spec/factories/namespaces/project_namespaces.rb b/spec/factories/namespaces/project_namespaces.rb
new file mode 100644
index 00000000000..10b86f48090
--- /dev/null
+++ b/spec/factories/namespaces/project_namespaces.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :project_namespace, class: 'Namespaces::ProjectNamespace' do
+ project
+ name { project.name }
+ path { project.path }
+ type { Namespaces::ProjectNamespace.sti_name }
+ owner { nil }
+ parent factory: :group
+ end
+end
diff --git a/spec/factories/operations/feature_flag_scopes.rb b/spec/factories/operations/feature_flag_scopes.rb
deleted file mode 100644
index 4ca9b53f320..00000000000
--- a/spec/factories/operations/feature_flag_scopes.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-# frozen_string_literal: true
-
-FactoryBot.define do
- factory :operations_feature_flag_scope, class: 'Operations::FeatureFlagScope' do
- association :feature_flag, factory: [:operations_feature_flag, :legacy_flag]
- active { true }
- strategies { [{ name: "default", parameters: {} }] }
- sequence(:environment_scope) { |n| "review/patch-#{n}" }
- end
-end
diff --git a/spec/factories/operations/feature_flags.rb b/spec/factories/operations/feature_flags.rb
index 32e5ec9fb26..33c13a62445 100644
--- a/spec/factories/operations/feature_flags.rb
+++ b/spec/factories/operations/feature_flags.rb
@@ -6,13 +6,5 @@ FactoryBot.define do
project
active { true }
version { :new_version_flag }
-
- trait :legacy_flag do
- version { Operations::FeatureFlag.versions['legacy_flag'] }
- end
-
- trait :new_version_flag do
- version { Operations::FeatureFlag.versions['new_version_flag'] }
- end
end
end
diff --git a/spec/factories/packages.rb b/spec/factories/packages.rb
index cd9c8a8bfbb..b04b7e691fe 100644
--- a/spec/factories/packages.rb
+++ b/spec/factories/packages.rb
@@ -112,7 +112,7 @@ FactoryBot.define do
factory :npm_package do
sequence(:name) { |n| "@#{project.root_namespace.path}/package-#{n}"}
- version { '1.0.0' }
+ sequence(:version) { |n| "1.0.#{n}" }
package_type { :npm }
after :create do |package|
@@ -354,4 +354,12 @@ FactoryBot.define do
package
sequence(:name) { |n| "tag-#{n}"}
end
+
+ factory :packages_build_info, class: 'Packages::BuildInfo' do
+ package
+
+ trait :with_pipeline do
+ association :pipeline, factory: [:ci_pipeline, :with_job]
+ end
+ end
end
diff --git a/spec/factories/packages/helm/file_metadatum.rb b/spec/factories/packages/helm/file_metadatum.rb
index cbc7e114ef6..3f599b5d5c0 100644
--- a/spec/factories/packages/helm/file_metadatum.rb
+++ b/spec/factories/packages/helm/file_metadatum.rb
@@ -2,8 +2,16 @@
FactoryBot.define do
factory :helm_file_metadatum, class: 'Packages::Helm::FileMetadatum' do
+ transient do
+ description { nil }
+ end
+
package_file { association(:helm_package_file, without_loaded_metadatum: true) }
sequence(:channel) { |n| "#{FFaker::Lorem.word}-#{n}" }
- metadata { { 'name': package_file.package.name, 'version': package_file.package.version, 'apiVersion': 'v2' } }
+ metadata do
+ { 'name': package_file.package.name, 'version': package_file.package.version, 'apiVersion': 'v2' }.tap do |defaults|
+ defaults['description'] = description if description
+ end
+ end
end
end
diff --git a/spec/factories/packages/package_file.rb b/spec/factories/packages/package_file.rb
index ac121da432c..d9afbac1048 100644
--- a/spec/factories/packages/package_file.rb
+++ b/spec/factories/packages/package_file.rb
@@ -212,11 +212,12 @@ FactoryBot.define do
package_name { package&.name || 'foo' }
sequence(:package_version) { |n| package&.version || "v#{n}" }
channel { 'stable' }
+ description { nil }
end
after :create do |package_file, evaluator|
unless evaluator.without_loaded_metadatum
- create :helm_file_metadatum, package_file: package_file, channel: evaluator.channel
+ create :helm_file_metadatum, package_file: package_file, channel: evaluator.channel, description: evaluator.description
end
end
end
diff --git a/spec/factories/plan_limits.rb b/spec/factories/plan_limits.rb
index ae892307193..b5921c1b311 100644
--- a/spec/factories/plan_limits.rb
+++ b/spec/factories/plan_limits.rb
@@ -4,6 +4,8 @@ FactoryBot.define do
factory :plan_limits do
plan
+ dast_profile_schedules { 50 }
+
trait :default_plan do
plan factory: :default_plan
end
diff --git a/spec/factories/project_topics.rb b/spec/factories/project_topics.rb
new file mode 100644
index 00000000000..60f5357d129
--- /dev/null
+++ b/spec/factories/project_topics.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :project_topic, class: 'Projects::ProjectTopic' do
+ association :project, factory: :project
+ association :topic, factory: :topic
+ end
+end
diff --git a/spec/factories/topics.rb b/spec/factories/topics.rb
new file mode 100644
index 00000000000..e77441d9eae
--- /dev/null
+++ b/spec/factories/topics.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :topic, class: 'Projects::Topic' do
+ name { generate(:name) }
+ end
+end
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index 476c57f2d80..04bacbe14e7 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -11,10 +11,6 @@ FactoryBot.define do
confirmation_token { nil }
can_create_group { true }
- after(:stub) do |user|
- user.notification_email = user.email
- end
-
trait :admin do
admin { true }
end
diff --git a/spec/factories/users/group_user_callouts.rb b/spec/factories/users/group_user_callouts.rb
new file mode 100644
index 00000000000..de8a6d3ee77
--- /dev/null
+++ b/spec/factories/users/group_user_callouts.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :group_callout, class: 'Users::GroupCallout' do
+ feature_name { :invite_members_banner }
+
+ user
+ group
+ end
+end
diff --git a/spec/factories/work_item/work_item_types.rb b/spec/factories/work_item/work_item_types.rb
index 07d6d685c57..1c586aab59b 100644
--- a/spec/factories/work_item/work_item_types.rb
+++ b/spec/factories/work_item/work_item_types.rb
@@ -8,6 +8,17 @@ FactoryBot.define do
base_type { WorkItem::Type.base_types[:issue] }
icon_name { 'issue-type-issue' }
+ initialize_with do
+ type_base_attributes = attributes.with_indifferent_access.slice(:base_type, :namespace, :namespace_id)
+
+ # Expect base_types to exist on the DB
+ if type_base_attributes.slice(:namespace, :namespace_id).compact.empty?
+ WorkItem::Type.find_or_initialize_by(type_base_attributes).tap { |type| type.assign_attributes(attributes) }
+ else
+ WorkItem::Type.new(attributes)
+ end
+ end
+
trait :default do
namespace { nil }
end
diff --git a/spec/factories_spec.rb b/spec/factories_spec.rb
index 2b308c9080e..6c7c3776c4a 100644
--- a/spec/factories_spec.rb
+++ b/spec/factories_spec.rb
@@ -74,6 +74,7 @@ RSpec.describe 'factories' do
milestone_release
namespace
project_broken_repo
+ project_namespace
project_repository
prometheus_alert
prometheus_alert_event
diff --git a/spec/fast_spec_helper.rb b/spec/fast_spec_helper.rb
index b06ebba3f6c..1485edcd97d 100644
--- a/spec/fast_spec_helper.rb
+++ b/spec/fast_spec_helper.rb
@@ -7,7 +7,7 @@ if $".include?(File.expand_path('spec_helper.rb', __dir__))
return
end
-require 'bundler/setup'
+require_relative '../config/bundler_setup'
ENV['GITLAB_ENV'] = 'test'
ENV['IN_MEMORY_APPLICATION_SETTINGS'] = 'true'
diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb
index 54c07985a21..8053be89ffc 100644
--- a/spec/features/admin/admin_runners_spec.rb
+++ b/spec/features/admin/admin_runners_spec.rb
@@ -356,6 +356,7 @@ RSpec.describe "Admin Runners" do
assigned_project = page.find('[data-testid="assigned-projects"]')
+ expect(page).to have_content('Runner assigned to project.')
expect(assigned_project).to have_content(@project2.path)
end
end
@@ -399,13 +400,14 @@ RSpec.describe "Admin Runners" do
visit admin_runner_path(runner)
end
- it 'enables specific runner for project' do
+ it 'removed specific runner from project' do
within '[data-testid="assigned-projects"]' do
click_on 'Disable'
end
new_runner_project = page.find('[data-testid="unassigned-projects"]')
+ expect(page).to have_content('Runner unassigned from project.')
expect(new_runner_project).to have_content(@project1.path)
end
end
diff --git a/spec/features/admin/admin_sees_background_migrations_spec.rb b/spec/features/admin/admin_sees_background_migrations_spec.rb
index 11823195310..94fb3a0314f 100644
--- a/spec/features/admin/admin_sees_background_migrations_spec.rb
+++ b/spec/features/admin/admin_sees_background_migrations_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe "Admin > Admin sees background migrations" do
let_it_be(:finished_migration) { create(:batched_background_migration, table_name: 'finished', status: :finished) }
before_all do
- create(:batched_background_migration_job, batched_migration: failed_migration, batch_size: 30, status: :succeeded)
+ create(:batched_background_migration_job, batched_migration: failed_migration, batch_size: 10, min_value: 6, max_value: 15, status: :failed, attempts: 3)
end
before do
@@ -53,22 +53,35 @@ RSpec.describe "Admin > Admin sees background migrations" do
end
end
- it 'can view failed migrations' do
- visit admin_background_migrations_path
+ context 'when there are failed migrations' do
+ before do
+ allow_next_instance_of(Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchingStrategy) do |batch_class|
+ allow(batch_class).to receive(:next_batch).with(anything, anything, batch_min_value: 6, batch_size: 5).and_return([6, 10])
+ end
+ end
- within '#content-body' do
- tab = find_link 'Failed'
- tab.click
+ it 'can view and retry them' do
+ visit admin_background_migrations_path
- expect(page).to have_current_path(admin_background_migrations_path(tab: 'failed'))
- expect(tab[:class]).to include('gl-tab-nav-item-active', 'gl-tab-nav-item-active-indigo')
+ within '#content-body' do
+ tab = find_link 'Failed'
+ tab.click
- expect(page).to have_selector('tbody tr', count: 1)
+ expect(page).to have_current_path(admin_background_migrations_path(tab: 'failed'))
+ expect(tab[:class]).to include('gl-tab-nav-item-active', 'gl-tab-nav-item-active-indigo')
+
+ expect(page).to have_selector('tbody tr', count: 1)
+
+ expect(page).to have_content(failed_migration.job_class_name)
+ expect(page).to have_content(failed_migration.table_name)
+ expect(page).to have_content('0.00%')
+ expect(page).to have_content(failed_migration.status.humanize)
- expect(page).to have_content(failed_migration.job_class_name)
- expect(page).to have_content(failed_migration.table_name)
- expect(page).to have_content('30.00%')
- expect(page).to have_content(failed_migration.status.humanize)
+ click_button('Retry')
+ expect(page).not_to have_content(failed_migration.job_class_name)
+ expect(page).not_to have_content(failed_migration.table_name)
+ expect(page).not_to have_content('0.00%')
+ end
end
end
diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb
index 4a0f7ccbb0a..b25fc9f257a 100644
--- a/spec/features/admin/admin_settings_spec.rb
+++ b/spec/features/admin/admin_settings_spec.rb
@@ -269,10 +269,7 @@ RSpec.describe 'Admin updates settings' do
end
context 'Integrations page' do
- let(:mailgun_events_receiver_enabled) { true }
-
before do
- stub_feature_flags(mailgun_events_receiver: mailgun_events_receiver_enabled)
visit general_admin_application_settings_path
end
@@ -286,26 +283,16 @@ RSpec.describe 'Admin updates settings' do
expect(current_settings.hide_third_party_offers).to be true
end
- context 'when mailgun_events_receiver feature flag is enabled' do
- it 'enabling Mailgun events', :aggregate_failures do
- page.within('.as-mailgun') do
- check 'Enable Mailgun event receiver'
- fill_in 'Mailgun HTTP webhook signing key', with: 'MAILGUN_SIGNING_KEY'
- click_button 'Save changes'
- end
-
- expect(page).to have_content 'Application settings saved successfully'
- expect(current_settings.mailgun_events_enabled).to be true
- expect(current_settings.mailgun_signing_key).to eq 'MAILGUN_SIGNING_KEY'
+ it 'enabling Mailgun events', :aggregate_failures do
+ page.within('.as-mailgun') do
+ check 'Enable Mailgun event receiver'
+ fill_in 'Mailgun HTTP webhook signing key', with: 'MAILGUN_SIGNING_KEY'
+ click_button 'Save changes'
end
- end
-
- context 'when mailgun_events_receiver feature flag is disabled' do
- let(:mailgun_events_receiver_enabled) { false }
- it 'does not have mailgun' do
- expect(page).not_to have_selector('.as-mailgun')
- end
+ expect(page).to have_content 'Application settings saved successfully'
+ expect(current_settings.mailgun_events_enabled).to be true
+ expect(current_settings.mailgun_signing_key).to eq 'MAILGUN_SIGNING_KEY'
end
end
@@ -559,6 +546,50 @@ RSpec.describe 'Admin updates settings' do
expect(current_settings.dns_rebinding_protection_enabled).to be false
end
+ it 'changes User and IP Rate Limits settings' do
+ visit network_admin_application_settings_path
+
+ page.within('.as-ip-limits') do
+ check 'Enable unauthenticated API request rate limit'
+ fill_in 'Maximum unauthenticated API requests per rate limit period per IP', with: 100
+ fill_in 'Unauthenticated API rate limit period in seconds', with: 200
+
+ check 'Enable unauthenticated web request rate limit'
+ fill_in 'Maximum unauthenticated web requests per rate limit period per IP', with: 300
+ fill_in 'Unauthenticated web rate limit period in seconds', with: 400
+
+ check 'Enable authenticated API request rate limit'
+ fill_in 'Maximum authenticated API requests per rate limit period per user', with: 500
+ fill_in 'Authenticated API rate limit period in seconds', with: 600
+
+ check 'Enable authenticated web request rate limit'
+ fill_in 'Maximum authenticated web requests per rate limit period per user', with: 700
+ fill_in 'Authenticated web rate limit period in seconds', with: 800
+
+ fill_in 'Plain-text response to send to clients that hit a rate limit', with: 'Custom message'
+
+ click_button 'Save changes'
+ end
+
+ expect(page).to have_content "Application settings saved successfully"
+
+ expect(current_settings).to have_attributes(
+ throttle_unauthenticated_api_enabled: true,
+ throttle_unauthenticated_api_requests_per_period: 100,
+ throttle_unauthenticated_api_period_in_seconds: 200,
+ throttle_unauthenticated_enabled: true,
+ throttle_unauthenticated_requests_per_period: 300,
+ throttle_unauthenticated_period_in_seconds: 400,
+ throttle_authenticated_api_enabled: true,
+ throttle_authenticated_api_requests_per_period: 500,
+ throttle_authenticated_api_period_in_seconds: 600,
+ throttle_authenticated_web_enabled: true,
+ throttle_authenticated_web_requests_per_period: 700,
+ throttle_authenticated_web_period_in_seconds: 800,
+ rate_limiting_response_text: 'Custom message'
+ )
+ end
+
it 'changes Issues rate limits settings' do
visit network_admin_application_settings_path
@@ -570,6 +601,20 @@ RSpec.describe 'Admin updates settings' do
expect(page).to have_content "Application settings saved successfully"
expect(current_settings.issues_create_limit).to eq(0)
end
+
+ it 'changes Files API rate limits settings' do
+ visit network_admin_application_settings_path
+
+ page.within('[data-testid="files-limits-settings"]') do
+ check 'Enable unauthenticated API request rate limit'
+ fill_in 'Max unauthenticated API requests per period per IP', with: 10
+ click_button 'Save changes'
+ end
+
+ expect(page).to have_content "Application settings saved successfully"
+ expect(current_settings.throttle_unauthenticated_files_api_enabled).to be true
+ expect(current_settings.throttle_unauthenticated_files_api_requests_per_period).to eq(10)
+ end
end
context 'Preferences page' do
diff --git a/spec/features/admin/admin_users_impersonation_tokens_spec.rb b/spec/features/admin/admin_users_impersonation_tokens_spec.rb
index 7466150addf..0966032ff37 100644
--- a/spec/features/admin/admin_users_impersonation_tokens_spec.rb
+++ b/spec/features/admin/admin_users_impersonation_tokens_spec.rb
@@ -42,7 +42,7 @@ RSpec.describe 'Admin > Users > Impersonation Tokens', :js do
click_on "Create impersonation token"
expect(active_impersonation_tokens).to have_text(name)
- expect(active_impersonation_tokens).to have_text('In')
+ expect(active_impersonation_tokens).to have_text('in')
expect(active_impersonation_tokens).to have_text('api')
expect(active_impersonation_tokens).to have_text('read_user')
expect(PersonalAccessTokensFinder.new(impersonation: true).execute.count).to equal(1)
@@ -59,6 +59,14 @@ RSpec.describe 'Admin > Users > Impersonation Tokens', :js do
expect(active_impersonation_tokens).to have_text(impersonation_token.name)
expect(active_impersonation_tokens).not_to have_text(personal_access_token.name)
+ expect(active_impersonation_tokens).to have_text('in')
+ end
+
+ it 'shows absolute times' do
+ admin.update!(time_display_relative: false)
+ visit admin_user_impersonation_tokens_path(user_id: user.username)
+
+ expect(active_impersonation_tokens).to have_text(personal_access_token.expires_at.strftime('%b %d'))
end
end
diff --git a/spec/features/atom/dashboard_issues_spec.rb b/spec/features/atom/dashboard_issues_spec.rb
index 511cdcc2940..855c91f70d7 100644
--- a/spec/features/atom/dashboard_issues_spec.rb
+++ b/spec/features/atom/dashboard_issues_spec.rb
@@ -4,8 +4,20 @@ require 'spec_helper'
RSpec.describe "Dashboard Issues Feed" do
describe "GET /issues" do
- let!(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') }
- let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') }
+ let!(:user) do
+ user = create(:user, email: 'private1@example.com')
+ public_email = create(:email, :confirmed, user: user, email: 'public1@example.com')
+ user.update!(public_email: public_email.email)
+ user
+ end
+
+ let!(:assignee) do
+ user = create(:user, email: 'private2@example.com')
+ public_email = create(:email, :confirmed, user: user, email: 'public2@example.com')
+ user.update!(public_email: public_email.email)
+ user
+ end
+
let!(:project1) { create(:project) }
let!(:project2) { create(:project) }
diff --git a/spec/features/atom/issues_spec.rb b/spec/features/atom/issues_spec.rb
index 13798a94fe9..913f5a7bcf3 100644
--- a/spec/features/atom/issues_spec.rb
+++ b/spec/features/atom/issues_spec.rb
@@ -4,61 +4,87 @@ require 'spec_helper'
RSpec.describe 'Issues Feed' do
describe 'GET /issues' do
- let!(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') }
- let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') }
- let!(:group) { create(:group) }
- let!(:project) { create(:project) }
- let!(:issue) { create(:issue, author: user, assignees: [assignee], project: project) }
+ let_it_be_with_reload(:user) do
+ user = create(:user, email: 'private1@example.com')
+ public_email = create(:email, :confirmed, user: user, email: 'public1@example.com')
+ user.update!(public_email: public_email.email)
+ user
+ end
+
+ let_it_be(:assignee) do
+ user = create(:user, email: 'private2@example.com')
+ public_email = create(:email, :confirmed, user: user, email: 'public2@example.com')
+ user.update!(public_email: public_email.email)
+ user
+ end
- before do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:issue) { create(:issue, author: user, assignees: [assignee], project: project, due_date: Date.today) }
+ let_it_be(:issuable) { issue } # "alias" for shared examples
+
+ before_all do
project.add_developer(user)
group.add_developer(user)
end
+ RSpec.shared_examples 'an authenticated issue atom feed' do
+ it 'renders atom feed with additional issue information' do
+ expect(body).to have_selector('title', text: "#{project.name} issues")
+ expect(body).to have_selector('due_date', text: issue.due_date)
+ end
+ end
+
context 'when authenticated' do
- it 'renders atom feed' do
+ before do
sign_in user
visit project_issues_path(project, :atom)
-
- expect(response_headers['Content-Type'])
- .to have_content('application/atom+xml')
- expect(body).to have_selector('title', text: "#{project.name} issues")
- expect(body).to have_selector('author email', text: issue.author_public_email)
- expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email)
- expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email)
- expect(body).to have_selector('entry summary', text: issue.title)
end
+
+ it_behaves_like 'an authenticated issuable atom feed'
+ it_behaves_like 'an authenticated issue atom feed'
end
context 'when authenticated via personal access token' do
- it 'renders atom feed' do
+ before do
personal_access_token = create(:personal_access_token, user: user)
visit project_issues_path(project, :atom,
- private_token: personal_access_token.token)
-
- expect(response_headers['Content-Type'])
- .to have_content('application/atom+xml')
- expect(body).to have_selector('title', text: "#{project.name} issues")
- expect(body).to have_selector('author email', text: issue.author_public_email)
- expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email)
- expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email)
- expect(body).to have_selector('entry summary', text: issue.title)
+ private_token: personal_access_token.token)
end
+
+ it_behaves_like 'an authenticated issuable atom feed'
+ it_behaves_like 'an authenticated issue atom feed'
end
context 'when authenticated via feed token' do
- it 'renders atom feed' do
+ before do
visit project_issues_path(project, :atom,
- feed_token: user.feed_token)
+ feed_token: user.feed_token)
+ end
- expect(response_headers['Content-Type'])
- .to have_content('application/atom+xml')
- expect(body).to have_selector('title', text: "#{project.name} issues")
- expect(body).to have_selector('author email', text: issue.author_public_email)
- expect(body).to have_selector('assignees assignee email', text: issue.assignees.first.public_email)
- expect(body).to have_selector('assignee email', text: issue.assignees.first.public_email)
- expect(body).to have_selector('entry summary', text: issue.title)
+ it_behaves_like 'an authenticated issuable atom feed'
+ it_behaves_like 'an authenticated issue atom feed'
+ end
+
+ context 'when not authenticated' do
+ before do
+ visit project_issues_path(project, :atom)
+ end
+
+ context 'and the project is private' do
+ it 'redirects to login page' do
+ expect(page).to have_current_path(new_user_session_path)
+ end
+ end
+
+ context 'and the project is public' do
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:issue) { create(:issue, author: user, assignees: [assignee], project: project, due_date: Date.today) }
+ let_it_be(:issuable) { issue } # "alias" for shared examples
+
+ it_behaves_like 'an authenticated issuable atom feed'
+ it_behaves_like 'an authenticated issue atom feed'
end
end
diff --git a/spec/features/atom/merge_requests_spec.rb b/spec/features/atom/merge_requests_spec.rb
new file mode 100644
index 00000000000..48db8fcbf1e
--- /dev/null
+++ b/spec/features/atom/merge_requests_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Merge Requests Feed' do
+ describe 'GET /merge_requests' do
+ let_it_be_with_reload(:user) { create(:user, email: 'private1@example.com') }
+ let_it_be(:assignee) { create(:user, email: 'private2@example.com') }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:merge_request) { create(:merge_request, source_project: project, assignees: [assignee]) }
+ let_it_be(:issuable) { merge_request } # "alias" for shared examples
+
+ before_all do
+ project.add_developer(user)
+ group.add_developer(user)
+ end
+
+ RSpec.shared_examples 'an authenticated merge request atom feed' do
+ it 'renders atom feed with additional merge request information' do
+ expect(body).to have_selector('title', text: "#{project.name} merge requests")
+ end
+ end
+
+ context 'when authenticated' do
+ before do
+ sign_in user
+ visit project_merge_requests_path(project, :atom)
+ end
+
+ it_behaves_like 'an authenticated issuable atom feed'
+ it_behaves_like 'an authenticated merge request atom feed'
+
+ context 'but the use can not see the project' do
+ let_it_be(:other_project) { create(:project) }
+
+ it 'renders 404 page' do
+ visit project_issues_path(other_project, :atom)
+
+ expect(page).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'when authenticated via personal access token' do
+ before do
+ personal_access_token = create(:personal_access_token, user: user)
+
+ visit project_merge_requests_path(project, :atom,
+ private_token: personal_access_token.token)
+ end
+
+ it_behaves_like 'an authenticated issuable atom feed'
+ it_behaves_like 'an authenticated merge request atom feed'
+ end
+
+ context 'when authenticated via feed token' do
+ before do
+ visit project_merge_requests_path(project, :atom,
+ feed_token: user.feed_token)
+ end
+
+ it_behaves_like 'an authenticated issuable atom feed'
+ it_behaves_like 'an authenticated merge request atom feed'
+ end
+
+ context 'when not authenticated' do
+ before do
+ visit project_merge_requests_path(project, :atom)
+ end
+
+ context 'and the project is private' do
+ it 'redirects to login page' do
+ expect(page).to have_current_path(new_user_session_path)
+ end
+ end
+
+ context 'and the project is public' do
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:merge_request) { create(:merge_request, source_project: project, assignees: [assignee]) }
+ let_it_be(:issuable) { merge_request } # "alias" for shared examples
+
+ it_behaves_like 'an authenticated issuable atom feed'
+ it_behaves_like 'an authenticated merge request atom feed'
+ end
+ end
+ end
+end
diff --git a/spec/features/boards/multi_select_spec.rb b/spec/features/boards/multi_select_spec.rb
index 057464326fa..9148fb23214 100644
--- a/spec/features/boards/multi_select_spec.rb
+++ b/spec/features/boards/multi_select_spec.rb
@@ -43,12 +43,12 @@ RSpec.describe 'Multi Select Issue', :js do
# Multi select drag&drop support is temporarily disabled
# https://gitlab.com/gitlab-org/gitlab/-/issues/289797
- stub_feature_flags(graphql_board_lists: false, board_multi_select: project)
+ stub_feature_flags(board_multi_select: project)
sign_in(user)
end
- context 'with lists' do
+ xcontext 'with lists' do
let(:label1) { create(:label, project: project, name: 'Label 1', description: 'Test') }
let(:label2) { create(:label, project: project, name: 'Label 2', description: 'Test') }
let!(:list1) { create(:list, board: board, label: label1, position: 0) }
diff --git a/spec/features/boards/sidebar_labels_spec.rb b/spec/features/boards/sidebar_labels_spec.rb
index 2f0230c61d8..fa16f47f69a 100644
--- a/spec/features/boards/sidebar_labels_spec.rb
+++ b/spec/features/boards/sidebar_labels_spec.rb
@@ -5,8 +5,9 @@ require 'spec_helper'
RSpec.describe 'Project issue boards sidebar labels', :js do
include BoardHelpers
+ let_it_be(:group) { create(:group, :public) }
let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project, :public) }
+ let_it_be(:project) { create(:project, :public, namespace: group) }
let_it_be(:development) { create(:label, project: project, name: 'Development') }
let_it_be(:bug) { create(:label, project: project, name: 'Bug') }
let_it_be(:regression) { create(:label, project: project, name: 'Regression') }
diff --git a/spec/features/boards/user_adds_lists_to_board_spec.rb b/spec/features/boards/user_adds_lists_to_board_spec.rb
index 5128fc4004e..26c310a6f56 100644
--- a/spec/features/boards/user_adds_lists_to_board_spec.rb
+++ b/spec/features/boards/user_adds_lists_to_board_spec.rb
@@ -3,8 +3,6 @@
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) }
@@ -17,6 +15,8 @@ RSpec.describe 'User adds lists', :js do
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(:backlog) { create(:group_label, group: group, name: 'Backlog') }
+ let_it_be(:closed) { create(:group_label, group: group, name: 'Closed') }
let_it_be(:issue) { create(:labeled_issue, project: project, labels: [group_label, project_label]) }
@@ -25,15 +25,8 @@ RSpec.describe 'User adds lists', :js do
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
+ where(:board_type) do
+ [[:project], [:group]]
end
with_them do
@@ -42,11 +35,6 @@ RSpec.describe 'User adds lists', :js do
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
@@ -56,40 +44,43 @@ RSpec.describe 'User adds lists', :js do
wait_for_all_requests
end
- it 'creates new column for label containing labeled issue' do
- click_button button_text(board_new_list_enabled)
+ it 'creates new column for label containing labeled issue', :aggregate_failures do
+ click_button 'Create list'
wait_for_all_requests
- select_label(board_new_list_enabled, group_label)
-
- wait_for_all_requests
+ select_label(group_label)
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
- click_button 'Select a label'
+ it 'creates new list for Backlog and closed labels' do
+ click_button 'Create list'
+ wait_for_requests
- find('label', text: label.title).click
+ select_label(backlog)
- click_button 'Add to board'
+ click_button 'Create list'
+ wait_for_requests
- wait_for_all_requests
- else
- page.within('.dropdown-menu-issues-board-new') do
- click_link label.title
- end
+ select_label(closed)
+
+ wait_for_requests
+
+ expect(page).to have_selector('.board', text: closed.title)
+ expect(find('.board:nth-child(2) .board-header')).to have_content(backlog.title)
+ expect(find('.board:nth-child(3) .board-header')).to have_content(closed.title)
+ expect(find('.board:nth-child(4) .board-header')).to have_content('Closed')
end
end
- def button_text(board_new_list_enabled)
- if board_new_list_enabled
- 'Create list'
- else
- 'Add list'
- end
+ def select_label(label)
+ click_button 'Select a label'
+
+ find('label', text: label.title).click
+
+ click_button 'Add to board'
+
+ wait_for_all_requests
end
end
diff --git a/spec/features/clusters/cluster_health_dashboard_spec.rb b/spec/features/clusters/cluster_health_dashboard_spec.rb
index e4a36f654e5..88d6976c2be 100644
--- a/spec/features/clusters/cluster_health_dashboard_spec.rb
+++ b/spec/features/clusters/cluster_health_dashboard_spec.rb
@@ -80,8 +80,8 @@ RSpec.describe 'Cluster Health board', :js, :kubeclient, :use_clean_rails_memory
expect(page).to have_content('Avg')
end
- it 'focuses the single panel on toggle', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338341' do
- click_button('More actions')
+ it 'focuses the single panel on toggle' do
+ click_button('More actions', match: :first)
click_button('Expand panel')
expect(page).to have_css('.prometheus-graph', count: 1)
diff --git a/spec/features/commit_spec.rb b/spec/features/commit_spec.rb
index 80a30ab01b2..3fd613ce393 100644
--- a/spec/features/commit_spec.rb
+++ b/spec/features/commit_spec.rb
@@ -42,7 +42,7 @@ RSpec.describe 'Commit' do
visit project_commit_path(project, commit)
end
- it "shows an adjusted count for changed files on this page" do
+ it "shows an adjusted count for changed files on this page", :js do
expect(page).to have_content("Showing 1 changed file")
end
diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb
index de6cb53fdfa..bec474f6cfe 100644
--- a/spec/features/cycle_analytics_spec.rb
+++ b/spec/features/cycle_analytics_spec.rb
@@ -5,35 +5,40 @@ require 'spec_helper'
RSpec.describe 'Value Stream Analytics', :js do
let_it_be(:user) { create(:user) }
let_it_be(:guest) { create(:user) }
- let_it_be(:project) { create(:project, :repository) }
let_it_be(:stage_table_selector) { '[data-testid="vsa-stage-table"]' }
+ let_it_be(:stage_table_event_selector) { '[data-testid="vsa-stage-event"]' }
let_it_be(:metrics_selector) { "[data-testid='vsa-time-metrics']" }
+ let_it_be(:metric_value_selector) { "[data-testid='displayValue']" }
+ let(:stage_table) { page.find(stage_table_selector) }
+ let(:project) { create(:project, :repository) }
let(:issue) { create(:issue, project: project, created_at: 2.days.ago) }
let(:milestone) { create(:milestone, project: project) }
let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") }
let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) }
+ def metrics_values
+ page.find(metrics_selector).all(metric_value_selector).collect(&:text)
+ end
+
+ def set_daterange(from_date, to_date)
+ page.find(".js-daterange-picker-from input").set(from_date)
+ page.find(".js-daterange-picker-to input").set(to_date)
+ wait_for_all_requests
+ end
+
context 'as an allowed user' do
context 'when project is new' do
- before(:all) do
- project.add_maintainer(user)
- end
-
before do
+ project.add_maintainer(user)
sign_in(user)
visit project_cycle_analytics_path(project)
wait_for_requests
end
- it 'displays metrics' do
- aggregate_failures 'with relevant values' do
- expect(new_issues_counter).to have_content('-')
- expect(commits_counter).to have_content('-')
- expect(deploys_counter).to have_content('-')
- expect(deployment_frequency_counter).to have_content('-')
- end
+ it 'displays metrics with relevant values' do
+ expect(metrics_values).to eq(['-'] * 4)
end
it 'shows active stage with empty message' do
@@ -43,24 +48,37 @@ RSpec.describe 'Value Stream Analytics', :js do
end
context "when there's value stream analytics data" do
+ # NOTE: in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68595 travel back
+ # 5 days in time before we create data for these specs, to mitigate some flakiness
+ # So setting the date range to be the last 2 days should skip past the existing data
+ from = 2.days.ago.strftime("%Y-%m-%d")
+ to = 1.day.ago.strftime("%Y-%m-%d")
+
+ around do |example|
+ travel_to(5.days.ago) { example.run }
+ end
+
before do
project.add_maintainer(user)
+ create_list(:issue, 2, project: project, created_at: 2.weeks.ago, milestone: milestone)
- @build = create_cycle(user, project, issue, mr, milestone, pipeline)
+ create_cycle(user, project, issue, mr, milestone, pipeline)
deploy_master(user, project)
issue.metrics.update!(first_mentioned_in_commit_at: issue.metrics.first_associated_with_milestone_at + 1.hour)
merge_request = issue.merge_requests_closing_issues.first.merge_request
merge_request.update!(created_at: issue.metrics.first_associated_with_milestone_at + 1.hour)
merge_request.metrics.update!(
- latest_build_started_at: 4.hours.ago,
- latest_build_finished_at: 3.hours.ago,
- merged_at: merge_request.created_at + 1.hour,
- first_deployed_to_production_at: merge_request.created_at + 2.hours
+ latest_build_started_at: merge_request.created_at + 3.hours,
+ latest_build_finished_at: merge_request.created_at + 4.hours,
+ merged_at: merge_request.created_at + 4.hours,
+ first_deployed_to_production_at: merge_request.created_at + 5.hours
)
sign_in(user)
visit project_cycle_analytics_path(project)
+
+ wait_for_requests
end
it 'displays metrics' do
@@ -93,18 +111,20 @@ RSpec.describe 'Value Stream Analytics', :js do
expect_merge_request_to_be_present
end
- context "when I change the time period observed" do
- before do
- _two_weeks_old_issue = create(:issue, project: project, created_at: 2.weeks.ago)
+ it 'can filter the issues by date' do
+ expect(stage_table.all(stage_table_event_selector).length).to eq(3)
- click_button('Last 30 days')
- click_link('Last 7 days')
- wait_for_requests
- end
+ set_daterange(from, to)
- it 'shows only relevant data' do
- expect(new_issue_counter).to have_content('1')
- end
+ expect(stage_table.all(stage_table_event_selector).length).to eq(0)
+ end
+
+ it 'can filter the metrics by date' do
+ expect(metrics_values).to eq(["3.0", "2.0", "1.0", "0.0"])
+
+ set_daterange(from, to)
+
+ expect(metrics_values).to eq(['-'] * 4)
end
end
end
@@ -137,31 +157,6 @@ RSpec.describe 'Value Stream Analytics', :js do
end
end
- def find_metric_tile(sel)
- page.find("#{metrics_selector} #{sel}")
- end
-
- # When now use proper pluralization for the metric names, which affects the id
- def new_issue_counter
- find_metric_tile("#new-issue")
- end
-
- def new_issues_counter
- find_metric_tile("#new-issues")
- end
-
- def commits_counter
- find_metric_tile("#commits")
- end
-
- def deploys_counter
- find_metric_tile("#deploys")
- end
-
- def deployment_frequency_counter
- find_metric_tile("#deployment-frequency")
- end
-
def expect_issue_to_be_present
expect(find(stage_table_selector)).to have_content(issue.title)
expect(find(stage_table_selector)).to have_content(issue.author.name)
diff --git a/spec/features/global_search_spec.rb b/spec/features/global_search_spec.rb
index 19fb8e5f52c..a380edff3a4 100644
--- a/spec/features/global_search_spec.rb
+++ b/spec/features/global_search_spec.rb
@@ -11,40 +11,64 @@ RSpec.describe 'Global search' do
before do
project.add_maintainer(user)
sign_in(user)
-
- visit dashboard_projects_path
end
- it 'increases usage ping searches counter' do
- expect(Gitlab::UsageDataCounters::SearchCounter).to receive(:count).with(:navbar_searches)
- expect(Gitlab::UsageDataCounters::SearchCounter).to receive(:count).with(:all_searches)
+ describe 'when new_header_search feature is disabled' do
+ before do
+ # TODO: Remove this along with feature flag #339348
+ stub_feature_flags(new_header_search: false)
+ visit dashboard_projects_path
+ end
- submit_search('foobar')
- end
+ it 'increases usage ping searches counter' do
+ expect(Gitlab::UsageDataCounters::SearchCounter).to receive(:count).with(:navbar_searches)
+ expect(Gitlab::UsageDataCounters::SearchCounter).to receive(:count).with(:all_searches)
- describe 'I search through the issues and I see pagination' do
- before do
- allow_next(SearchService).to receive(:per_page).and_return(1)
- create_list(:issue, 2, project: project, title: 'initial')
+ submit_search('foobar')
end
- it "has a pagination" do
- submit_search('initial')
- select_search_scope('Issues')
+ describe 'I search through the issues and I see pagination' do
+ before do
+ allow_next(SearchService).to receive(:per_page).and_return(1)
+ create_list(:issue, 2, project: project, title: 'initial')
+ end
+
+ it "has a pagination" do
+ submit_search('initial')
+ select_search_scope('Issues')
- expect(page).to have_selector('.gl-pagination .next')
+ expect(page).to have_selector('.gl-pagination .next')
+ end
end
- end
- it 'closes the dropdown on blur', :js do
- find('#search').click
- fill_in 'search', with: "a"
+ it 'closes the dropdown on blur', :js do
+ find('#search').click
+ fill_in 'search', with: "a"
+
+ expect(page).to have_selector("div[data-testid='dashboard-search-options'].show")
- expect(page).to have_selector("div[data-testid='dashboard-search-options'].show")
+ find('#search').send_keys(:backspace)
+ find('body').click
- find('#search').send_keys(:backspace)
- find('body').click
+ expect(page).to have_no_selector("div[data-testid='dashboard-search-options'].show")
+ end
+
+ it 'renders legacy search bar' do
+ expect(page).to have_selector('.search-form')
+ expect(page).to have_no_selector('#js-header-search')
+ end
+ end
- expect(page).to have_no_selector("div[data-testid='dashboard-search-options'].show")
+ describe 'when new_header_search feature is enabled' do
+ before do
+ # TODO: Remove this along with feature flag #339348
+ stub_feature_flags(new_header_search: true)
+ visit dashboard_projects_path
+ end
+
+ it 'renders updated search bar' do
+ expect(page).to have_no_selector('.search-form')
+ expect(page).to have_selector('#js-header-search')
+ end
end
end
diff --git a/spec/features/groups/board_sidebar_spec.rb b/spec/features/groups/board_sidebar_spec.rb
index e2dd2fecab7..69a6788e438 100644
--- a/spec/features/groups/board_sidebar_spec.rb
+++ b/spec/features/groups/board_sidebar_spec.rb
@@ -42,30 +42,4 @@ RSpec.describe 'Group Issue Boards', :js do
end
end
end
-
- context 'when graphql_board_lists FF disabled' do
- before do
- stub_feature_flags(graphql_board_lists: false)
- sign_in(user)
-
- visit group_board_path(group, board)
- wait_for_requests
- end
-
- it 'only shows valid labels for the issue project and group' do
- click_card(card)
-
- page.within('.labels') do
- click_link 'Edit'
-
- wait_for_requests
-
- page.within('.selectbox') do
- expect(page).to have_content(project_1_label.title)
- expect(page).to have_content(group_label.title)
- expect(page).not_to have_content(project_2_label.title)
- end
- end
- end
- end
end
diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb
index 21b39d2da46..489beb70ab3 100644
--- a/spec/features/groups/issues_spec.rb
+++ b/spec/features/groups/issues_spec.rb
@@ -33,7 +33,7 @@ RSpec.describe 'Group issues page' do
# However,`:js` option forces Capybara to use Selenium that doesn't support`:has`
context "it has an RSS button with current_user's feed token" do
it "shows the RSS button with current_user's feed token" do
- expect(find('[data-testid="rss-feed-link"]')['href']).to have_content(user.feed_token)
+ expect(page).to have_link 'Subscribe to RSS feed', href: /feed_token=#{user.feed_token}/
end
end
end
@@ -46,7 +46,7 @@ RSpec.describe 'Group issues page' do
# Note: please see the above
context "it has an RSS button without a feed token" do
it "shows the RSS button without a feed token" do
- expect(find('[data-testid="rss-feed-link"]')['href']).not_to have_content('feed_token')
+ expect(page).not_to have_link 'Subscribe to RSS feed', href: /feed_token/
end
end
end
@@ -94,6 +94,41 @@ RSpec.describe 'Group issues page' do
expect(page).not_to have_content issue.title[0..80]
end
end
+
+ context 'when cached issues state count is enabled', :clean_gitlab_redis_cache do
+ before do
+ stub_feature_flags(cached_issues_state_count: true)
+ end
+
+ it 'truncates issue counts if over the threshold' do
+ allow(Rails.cache).to receive(:read).and_call_original
+ allow(Rails.cache).to receive(:read).with(
+ ['group', group.id, 'issues'],
+ { expires_in: Gitlab::IssuablesCountForState::CACHE_EXPIRES_IN }
+ ).and_return({ opened: 1050, closed: 500, all: 1550 })
+
+ visit issues_group_path(group)
+
+ expect(page).to have_text('Open 1.1k Closed 500 All 1.6k')
+ end
+ end
+
+ context 'when cached issues state count is disabled', :clean_gitlab_redis_cache do
+ before do
+ stub_feature_flags(cached_issues_state_count: false)
+ end
+
+ it 'does not truncate counts if they are over the threshold' do
+ allow_next_instance_of(IssuesFinder) do |finder|
+ allow(finder).to receive(:count_by_state).and_return(true)
+ .and_return({ opened: 1050, closed: 500, all: 1550 })
+ end
+
+ visit issues_group_path(group)
+
+ expect(page).to have_text('Open 1,050 Closed 500 All 1,550')
+ end
+ end
end
context 'projects with issues disabled' do
diff --git a/spec/features/groups/members/request_access_spec.rb b/spec/features/groups/members/request_access_spec.rb
index 827962fee61..f806c7d3704 100644
--- a/spec/features/groups/members/request_access_spec.rb
+++ b/spec/features/groups/members/request_access_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe 'Groups > Members > Request access' do
it 'user can request access to a group' do
perform_enqueued_jobs { click_link 'Request Access' }
- expect(ActionMailer::Base.deliveries.last.to).to eq [owner.notification_email]
+ expect(ActionMailer::Base.deliveries.last.to).to eq [owner.notification_email_or_default]
expect(ActionMailer::Base.deliveries.last.subject).to match "Request to join the #{group.name} group"
expect(group.requesters.exists?(user_id: user)).to be_truthy
diff --git a/spec/features/groups/packages_spec.rb b/spec/features/groups/packages_spec.rb
index 9a7950266a5..3c2ade6b274 100644
--- a/spec/features/groups/packages_spec.rb
+++ b/spec/features/groups/packages_spec.rb
@@ -44,14 +44,6 @@ RSpec.describe 'Group Packages' do
it_behaves_like 'packages list', check_project_name: true
- context 'when package_details_apollo feature flag is off' do
- before do
- stub_feature_flags(package_details_apollo: false)
- end
-
- it_behaves_like 'package details link'
- end
-
it_behaves_like 'package details link'
it 'allows you to navigate to the project page' do
diff --git a/spec/features/groups/settings/packages_and_registries_spec.rb b/spec/features/groups/settings/packages_and_registries_spec.rb
index 835555480dd..d3141da9160 100644
--- a/spec/features/groups/settings/packages_and_registries_spec.rb
+++ b/spec/features/groups/settings/packages_and_registries_spec.rb
@@ -90,9 +90,10 @@ RSpec.describe 'Group Packages & Registries settings' do
expect(page).to have_content('Do not allow duplicates')
fill_in 'Exceptions', with: ')'
+
+ # simulate blur event
+ find('#maven-duplicated-settings-regex-input').native.send_keys(:tab)
end
- # simulate blur event
- find('body').click
expect(page).to have_content('is an invalid regexp')
end
diff --git a/spec/features/groups/settings/repository_spec.rb b/spec/features/groups/settings/repository_spec.rb
index 7082b2b20bd..d95eaf3c92c 100644
--- a/spec/features/groups/settings/repository_spec.rb
+++ b/spec/features/groups/settings/repository_spec.rb
@@ -18,11 +18,11 @@ RSpec.describe 'Group Repository settings' do
before do
stub_container_registry_config(enabled: true)
- visit group_settings_repository_path(group)
end
it_behaves_like 'a deploy token in settings' do
let(:entity_type) { 'group' }
+ let(:page_path) { group_settings_repository_path(group) }
end
end
diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb
index 79226facad4..eb62b6fa8ee 100644
--- a/spec/features/groups/show_spec.rb
+++ b/spec/features/groups/show_spec.rb
@@ -3,25 +3,74 @@
require 'spec_helper'
RSpec.describe 'Group show page' do
- let(:group) { create(:group) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+
let(:path) { group_path(group) }
context 'when signed in' do
- let(:user) do
- create(:group_member, :developer, user: create(:user), group: group ).user
- end
+ context 'with non-admin group concerns' do
+ before do
+ group.add_developer(user)
+ sign_in(user)
+ visit path
+ end
- before do
- sign_in(user)
- visit path
+ it_behaves_like "an autodiscoverable RSS feed with current_user's feed token"
+
+ context 'when group does not exist' do
+ let(:path) { group_path('not-exist') }
+
+ it { expect(status_code).to eq(404) }
+ end
end
- it_behaves_like "an autodiscoverable RSS feed with current_user's feed token"
+ context 'when user is an owner' do
+ before do
+ group.add_owner(user)
+ sign_in(user)
+ end
+
+ it 'shows the invite banner and persists dismissal', :js do
+ visit path
+
+ expect(page).to have_content('Collaborate with your team')
- context 'when group does not exist' do
- let(:path) { group_path('not-exist') }
+ page.within(find('[data-testid="invite-members-banner"]')) do
+ find('[data-testid="close-icon"]').click
+ end
+
+ expect(page).not_to have_content('Collaborate with your team')
+
+ visit path
+
+ expect(page).not_to have_content('Collaborate with your team')
+ end
+
+ context 'when group has a project with emoji in description', :js do
+ let!(:project) { create(:project, description: ':smile:', namespace: group) }
+
+ it 'shows the project info', :aggregate_failures do
+ visit path
+
+ expect(page).to have_content(project.title)
+ expect(page).to have_emoji('smile')
+ end
+ end
- it { expect(status_code).to eq(404) }
+ context 'when group has projects' do
+ it 'allows users to sorts projects by most stars', :js do
+ project1 = create(:project, namespace: group, star_count: 2)
+ project2 = create(:project, namespace: group, star_count: 3)
+ project3 = create(:project, namespace: group, star_count: 0)
+
+ visit group_path(group, sort: :stars_desc)
+
+ expect(find('.group-row:nth-child(1) .namespace-title > a')).to have_content(project2.title)
+ expect(find('.group-row:nth-child(2) .namespace-title > a')).to have_content(project1.title)
+ expect(find('.group-row:nth-child(3) .namespace-title > a')).to have_content(project3.title)
+ end
+ end
end
end
@@ -37,7 +86,7 @@ RSpec.describe 'Group show page' do
context 'when group has a public project', :js do
let!(:project) { create(:project, :public, namespace: group) }
- it 'renders public project' do
+ it 'renders public project', :aggregate_failures do
visit path
expect(page).to have_link group.name
@@ -48,7 +97,7 @@ RSpec.describe 'Group show page' do
context 'when group has a private project', :js do
let!(:project) { create(:project, :private, namespace: group) }
- it 'does not render private project' do
+ it 'does not render private project', :aggregate_failures do
visit path
expect(page).to have_link group.name
@@ -58,28 +107,19 @@ RSpec.describe 'Group show page' do
end
context 'subgroup support' do
- let(:restricted_group) do
+ let_it_be(:restricted_group) do
create(:group, subgroup_creation_level: ::Gitlab::Access::OWNER_SUBGROUP_ACCESS)
end
- let(:relaxed_group) do
- create(:group, subgroup_creation_level: ::Gitlab::Access::MAINTAINER_SUBGROUP_ACCESS)
- end
-
- let(:owner) { create(:user) }
- let(:maintainer) { create(:user) }
-
context 'for owners' do
- let(:path) { group_path(restricted_group) }
-
before do
- restricted_group.add_owner(owner)
- sign_in(owner)
+ restricted_group.add_owner(user)
+ sign_in(user)
end
context 'when subgroups are supported' do
it 'allows creating subgroups' do
- visit path
+ visit group_path(restricted_group)
expect(page).to have_link('New subgroup')
end
@@ -88,18 +128,21 @@ RSpec.describe 'Group show page' do
context 'for maintainers' do
before do
- sign_in(maintainer)
+ sign_in(user)
end
context 'when subgroups are supported' do
context 'when subgroup_creation_level is set to maintainers' do
+ let(:relaxed_group) do
+ create(:group, subgroup_creation_level: ::Gitlab::Access::MAINTAINER_SUBGROUP_ACCESS)
+ end
+
before do
- relaxed_group.add_maintainer(maintainer)
+ relaxed_group.add_maintainer(user)
end
it 'allows creating subgroups' do
- path = group_path(relaxed_group)
- visit path
+ visit group_path(relaxed_group)
expect(page).to have_link('New subgroup')
end
@@ -107,12 +150,11 @@ RSpec.describe 'Group show page' do
context 'when subgroup_creation_level is set to owners' do
before do
- restricted_group.add_maintainer(maintainer)
+ restricted_group.add_maintainer(user)
end
it 'does not allow creating subgroups' do
- path = group_path(restricted_group)
- visit path
+ visit group_path(restricted_group)
expect(page).not_to have_link('New subgroup')
end
@@ -121,50 +163,10 @@ RSpec.describe 'Group show page' do
end
end
- context 'group has a project with emoji in description', :js do
- let(:user) { create(:user) }
- let!(:project) { create(:project, description: ':smile:', namespace: group) }
-
- before do
- group.add_owner(user)
- sign_in(user)
- visit path
- end
-
- it 'shows the project info' do
- expect(page).to have_content(project.title)
- expect(page).to have_emoji('smile')
- end
- end
-
- context 'where group has projects' do
- let(:user) { create(:user) }
-
- before do
- group.add_owner(user)
- sign_in(user)
- end
-
- it 'allows users to sorts projects by most stars', :js do
- project1 = create(:project, namespace: group, star_count: 2)
- project2 = create(:project, namespace: group, star_count: 3)
- project3 = create(:project, namespace: group, star_count: 0)
-
- visit group_path(group, sort: :stars_desc)
-
- expect(find('.group-row:nth-child(1) .namespace-title > a')).to have_content(project2.title)
- expect(find('.group-row:nth-child(2) .namespace-title > a')).to have_content(project1.title)
- expect(find('.group-row:nth-child(3) .namespace-title > a')).to have_content(project3.title)
- end
- end
-
context 'notification button', :js do
- let(:maintainer) { create(:user) }
- let!(:project) { create(:project, namespace: group) }
-
before do
- group.add_maintainer(maintainer)
- sign_in(maintainer)
+ group.add_maintainer(user)
+ sign_in(user)
end
it 'is enabled by default' do
@@ -174,7 +176,8 @@ RSpec.describe 'Group show page' do
end
it 'is disabled if emails are disabled' do
- group.update_attribute(:emails_disabled, true)
+ group.update!(emails_disabled: true)
+
visit path
expect(page).to have_selector('[data-testid="notification-dropdown"] .disabled')
@@ -182,12 +185,10 @@ RSpec.describe 'Group show page' do
end
context 'page og:description' do
- let(:group) { create(:group, description: '**Lorem** _ipsum_ dolor sit [amet](https://example.com)') }
- let(:maintainer) { create(:user) }
-
before do
- group.add_maintainer(maintainer)
- sign_in(maintainer)
+ group.update!(description: '**Lorem** _ipsum_ dolor sit [amet](https://example.com)')
+ group.add_maintainer(user)
+ sign_in(user)
visit path
end
@@ -237,7 +238,7 @@ RSpec.describe 'Group show page' do
end
end
- it 'does not include structured markup in shared projects tab', :js do
+ it 'does not include structured markup in shared projects tab', :aggregate_failures, :js do
other_project = create(:project, :public)
other_project.project_group_links.create!(group: group)
@@ -248,7 +249,7 @@ RSpec.describe 'Group show page' do
expect(page).not_to have_selector('[itemprop="owns"][itemtype="https://schema.org/SoftwareSourceCode"]')
end
- it 'does not include structured markup in archived projects tab', :js do
+ it 'does not include structured markup in archived projects tab', :aggregate_failures, :js do
project.update!(archived: true)
visit group_archived_path(group)
diff --git a/spec/features/ics/dashboard_issues_spec.rb b/spec/features/ics/dashboard_issues_spec.rb
index 4a93a4b490a..1d0ea495757 100644
--- a/spec/features/ics/dashboard_issues_spec.rb
+++ b/spec/features/ics/dashboard_issues_spec.rb
@@ -4,8 +4,20 @@ require 'spec_helper'
RSpec.describe 'Dashboard Issues Calendar Feed' do
describe 'GET /issues' do
- let!(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') }
- let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') }
+ let!(:user) do
+ user = create(:user, email: 'private1@example.com')
+ public_email = create(:email, :confirmed, user: user, email: 'public1@example.com')
+ user.update!(public_email: public_email.email)
+ user
+ end
+
+ let!(:assignee) do
+ user = create(:user, email: 'private2@example.com')
+ public_email = create(:email, :confirmed, user: user, email: 'public2@example.com')
+ user.update!(public_email: public_email.email)
+ user
+ end
+
let!(:project) { create(:project) }
let(:milestone) { create(:milestone, project_id: project.id, title: 'v1.0') }
diff --git a/spec/features/ics/group_issues_spec.rb b/spec/features/ics/group_issues_spec.rb
index 05caca4b5a8..f29c39ad4ef 100644
--- a/spec/features/ics/group_issues_spec.rb
+++ b/spec/features/ics/group_issues_spec.rb
@@ -4,8 +4,20 @@ require 'spec_helper'
RSpec.describe 'Group Issues Calendar Feed' do
describe 'GET /issues' do
- let!(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') }
- let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') }
+ let!(:user) do
+ user = create(:user, email: 'private1@example.com')
+ public_email = create(:email, :confirmed, user: user, email: 'public1@example.com')
+ user.update!(public_email: public_email.email)
+ user
+ end
+
+ let!(:assignee) do
+ user = create(:user, email: 'private2@example.com')
+ public_email = create(:email, :confirmed, user: user, email: 'public2@example.com')
+ user.update!(public_email: public_email.email)
+ user
+ end
+
let!(:group) { create(:group) }
let!(:project) { create(:project, group: group) }
diff --git a/spec/features/ics/project_issues_spec.rb b/spec/features/ics/project_issues_spec.rb
index 58a1a32eac2..771748060bb 100644
--- a/spec/features/ics/project_issues_spec.rb
+++ b/spec/features/ics/project_issues_spec.rb
@@ -4,8 +4,20 @@ require 'spec_helper'
RSpec.describe 'Project Issues Calendar Feed' do
describe 'GET /issues' do
- let!(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') }
- let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') }
+ let!(:user) do
+ user = create(:user, email: 'private1@example.com')
+ public_email = create(:email, :confirmed, user: user, email: 'public1@example.com')
+ user.update!(public_email: public_email.email)
+ user
+ end
+
+ let!(:assignee) do
+ user = create(:user, email: 'private2@example.com')
+ public_email = create(:email, :confirmed, user: user, email: 'public2@example.com')
+ user.update!(public_email: public_email.email)
+ user
+ end
+
let!(:project) { create(:project) }
let!(:issue) { create(:issue, author: user, assignees: [assignee], project: project) }
diff --git a/spec/features/incidents/user_views_incident_spec.rb b/spec/features/incidents/user_views_incident_spec.rb
index b94ce3cd06f..244b66f7a9a 100644
--- a/spec/features/incidents/user_views_incident_spec.rb
+++ b/spec/features/incidents/user_views_incident_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe "User views incident" do
it 'shows the merge request and incident actions', :js, :aggregate_failures do
click_button 'Incident actions'
- expect(page).to have_link('New incident', href: new_project_issue_path(project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }))
+ expect(page).to have_link('New incident', href: new_project_issue_path(project, { issuable_template: 'incident', issue: { issue_type: 'incident', description: "Related to \##{incident.iid}.\n\n" } }))
expect(page).to have_button('Create merge request')
expect(page).to have_button('Close incident')
end
diff --git a/spec/features/invites_spec.rb b/spec/features/invites_spec.rb
index d56bedd4852..87fb8955dcc 100644
--- a/spec/features/invites_spec.rb
+++ b/spec/features/invites_spec.rb
@@ -189,6 +189,16 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
end
context 'email confirmation enabled' do
+ context 'when user is not valid in sign up form' do
+ let(:new_user) { build_stubbed(:user, first_name: '', last_name: '') }
+
+ it 'fails sign up and redirects back to sign up', :aggregate_failures do
+ expect { fill_in_sign_up_form(new_user) }.not_to change { User.count }
+ expect(page).to have_content('prohibited this user from being saved')
+ expect(current_path).to eq(user_registration_path)
+ end
+ end
+
context 'with invite email acceptance', :snowplow do
it 'tracks the accepted invite' do
fill_in_sign_up_form(new_user)
@@ -216,6 +226,20 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
end
end
+ context 'with invite email acceptance for the invite_email_from experiment', :experiment do
+ let(:extra_params) do
+ { invite_type: Emails::Members::INITIAL_INVITE, experiment_name: 'invite_email_from' }
+ end
+
+ it 'tracks the accepted invite' do
+ expect(experiment(:invite_email_from)).to track(:accepted)
+ .with_context(actor: group_invite)
+ .on_next_instance
+
+ fill_in_sign_up_form(new_user)
+ end
+ end
+
it 'signs up and redirects to the group activity page with all the project/groups invitation automatically accepted' do
fill_in_sign_up_form(new_user)
fill_in_welcome_form
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 077c363f78b..507d427bf0b 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
@@ -27,7 +27,7 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j
it 'shows a button to resolve all threads by creating a new issue' do
within('.line-resolve-all-container') do
- expect(page).to have_selector resolve_all_discussions_link_selector( title: "Resolve all threads in new issue" )
+ expect(page).to have_selector resolve_all_discussions_link_selector( title: "Create issue to resolve all threads" )
end
end
@@ -38,7 +38,7 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j
it 'hides the link for creating a new issue' do
expect(page).not_to have_selector resolve_all_discussions_link_selector
- expect(page).not_to have_content "Resolve all threads in new issue"
+ expect(page).not_to have_content "Create issue to resolve all threads"
end
end
@@ -62,7 +62,7 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j
end
it 'does not show a link to create a new issue' do
- expect(page).not_to have_link 'Resolve all threads in new issue'
+ expect(page).not_to have_link 'Create issue to resolve all threads'
end
end
@@ -77,14 +77,14 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j
it 'has a link to resolve all threads by creating an issue' do
page.within '.mr-widget-body' do
- expect(page).to have_link 'Resolve all threads in new issue', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
+ expect(page).to have_link 'Create issue to resolve all threads', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
end
end
context 'creating an issue for threads' do
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)
+ page.click_link 'Create issue to resolve all threads', href: new_project_issue_path(project, merge_request_to_resolve_discussions_of: merge_request.iid)
wait_for_all_requests
end
diff --git a/spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb
index 3ff8fc5ecca..0de15d3d304 100644
--- a/spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb
+++ b/spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe 'Resolve an open thread in a merge request by creating an issue',
let!(:discussion) { create(:diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion }
def resolve_discussion_selector
- title = 'Resolve this thread in a new issue'
+ title = 'Create issue to resolve thread'
url = new_project_issue_path(project, discussion_to_resolve: discussion.id, merge_request_to_resolve_discussions_of: merge_request.iid)
"a[title=\"#{title}\"][href=\"#{url}\"]"
end
diff --git a/spec/features/issues/csv_spec.rb b/spec/features/issues/csv_spec.rb
index 51e0d54ca5e..b4c737495b4 100644
--- a/spec/features/issues/csv_spec.rb
+++ b/spec/features/issues/csv_spec.rb
@@ -44,7 +44,7 @@ RSpec.describe 'Issues csv', :js do
request_csv
expect(page).to have_content 'CSV export has started'
- expect(page).to have_content "emailed to #{user.notification_email}"
+ expect(page).to have_content "emailed to #{user.notification_email_or_default}"
end
it 'includes a csv attachment', :sidekiq_might_not_need_inline do
diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb
index 88a7b890daa..edf3df7c16e 100644
--- a/spec/features/issues/filtered_search/filter_issues_spec.rb
+++ b/spec/features/issues/filtered_search/filter_issues_spec.rb
@@ -565,21 +565,18 @@ RSpec.describe 'Filter issues', :js do
end
it 'maintains filter' do
- # Closed
- find('.issues-state-filters [data-state="closed"]').click
+ click_link 'Closed'
wait_for_requests
expect(page).to have_selector('.issues-list .issue', count: 1)
expect(page).to have_link(closed_issue.title)
- # Opened
- find('.issues-state-filters [data-state="opened"]').click
+ click_link 'Open'
wait_for_requests
expect(page).to have_selector('.issues-list .issue', count: 4)
- # All
- find('.issues-state-filters [data-state="all"]').click
+ click_link 'All'
wait_for_requests
expect(page).to have_selector('.issues-list .issue', count: 5)
diff --git a/spec/features/issues/filtered_search/search_bar_spec.rb b/spec/features/issues/filtered_search/search_bar_spec.rb
index 1efcc329e32..60963d95ae5 100644
--- a/spec/features/issues/filtered_search/search_bar_spec.rb
+++ b/spec/features/issues/filtered_search/search_bar_spec.rb
@@ -89,7 +89,7 @@ RSpec.describe 'Search bar', :js do
expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: original_size)
end
- it 'resets the dropdown filters', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/9985' do
+ it 'resets the dropdown filters' do
filtered_search.click
hint_offset = get_left_style(find('#js-dropdown-hint')['style'])
@@ -103,7 +103,7 @@ RSpec.describe 'Search bar', :js do
find('.filtered-search-box .clear-search').click
filtered_search.click
- expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 6)
+ expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', minimum: 6)
expect(get_left_style(find('#js-dropdown-hint')['style'])).to eq(hint_offset)
end
end
diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb
index 644d7cc4611..2d8587d886f 100644
--- a/spec/features/issues/filtered_search/visual_tokens_spec.rb
+++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb
@@ -16,9 +16,6 @@ RSpec.describe 'Visual tokens', :js do
let(:filtered_search) { find('.filtered-search') }
let(:filter_author_dropdown) { find("#js-dropdown-author .filter-dropdown") }
- let(:filter_assignee_dropdown) { find("#js-dropdown-assignee .filter-dropdown") }
- let(:filter_milestone_dropdown) { find("#js-dropdown-milestone .filter-dropdown") }
- let(:filter_label_dropdown) { find("#js-dropdown-label .filter-dropdown") }
def is_input_focused
page.evaluate_script("document.activeElement.classList.contains('filtered-search')")
diff --git a/spec/features/issues/issue_detail_spec.rb b/spec/features/issues/issue_detail_spec.rb
index a942a1a44f6..531c3634b5e 100644
--- a/spec/features/issues/issue_detail_spec.rb
+++ b/spec/features/issues/issue_detail_spec.rb
@@ -32,6 +32,21 @@ RSpec.describe 'Issue Detail', :js do
end
end
+ context 'when issue description has emojis' do
+ let(:issue) { create(:issue, project: project, author: user, description: 'hello world :100:') }
+
+ before do
+ sign_in(user)
+ visit project_issue_path(project, issue)
+ end
+
+ it 'renders gl-emoji tag' do
+ page.within('.description') do
+ expect(page).to have_selector('gl-emoji', count: 1)
+ end
+ end
+ end
+
context 'when issue description has xss snippet' do
before do
issue.update!(description: '![xss" onload=alert(1);//](a)')
diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb
index e198d9d4ebb..bd4be755a92 100644
--- a/spec/features/issues/issue_sidebar_spec.rb
+++ b/spec/features/issues/issue_sidebar_spec.rb
@@ -117,7 +117,7 @@ RSpec.describe 'Issue Sidebar' do
page.within '.dropdown-menu-user' do
expect(page).to have_link('Invite members')
- expect(page).to have_selector('[data-track-event="click_invite_members"]')
+ expect(page).to have_selector('[data-track-action="click_invite_members"]')
expect(page).to have_selector('[data-track-label="edit_assignee"]')
end
diff --git a/spec/features/issues/resource_label_events_spec.rb b/spec/features/issues/resource_label_events_spec.rb
index 33edf2f0b63..e08410efc0b 100644
--- a/spec/features/issues/resource_label_events_spec.rb
+++ b/spec/features/issues/resource_label_events_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe 'List issue resource label events', :js do
click_on 'Edit'
wait_for_requests
- labels.each { |label| click_link label }
+ labels.each { |label| click_on label }
send_keys(:escape)
wait_for_requests
diff --git a/spec/features/issues/rss_spec.rb b/spec/features/issues/rss_spec.rb
index 6c4498ea711..b20502ecc25 100644
--- a/spec/features/issues/rss_spec.rb
+++ b/spec/features/issues/rss_spec.rb
@@ -3,21 +3,24 @@
require 'spec_helper'
RSpec.describe 'Project Issues RSS' do
- let!(:user) { create(:user) }
- let(:group) { create(:group) }
- let(:project) { create(:project, group: group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
- let(:path) { project_issues_path(project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
+ let_it_be(:path) { project_issues_path(project) }
+ let_it_be(:issue) { create(:issue, project: project, assignees: [user]) }
- before do
- create(:issue, project: project, assignees: [user])
+ before_all do
group.add_developer(user)
end
context 'when signed in' do
- let(:user) { create(:user) }
+ let_it_be(:user) { create(:user) }
- before do
+ before_all do
project.add_developer(user)
+ end
+
+ before do
sign_in(user)
visit path
end
@@ -36,26 +39,6 @@ RSpec.describe 'Project Issues RSS' do
end
describe 'feeds' do
- shared_examples 'updates atom feed link' do |type|
- it "for #{type}" do
- sign_in(user)
- visit path
-
- link = find_link('Subscribe to RSS feed')
- params = CGI.parse(URI.parse(link[:href]).query)
- auto_discovery_link = find('link[type="application/atom+xml"]', visible: false)
- auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
-
- expected = {
- 'feed_token' => [user.feed_token],
- 'assignee_id' => [user.id.to_s]
- }
-
- expect(params).to include(expected)
- expect(auto_discovery_params).to include(expected)
- end
- end
-
it_behaves_like 'updates atom feed link', :project do
let(:path) { project_issues_path(project, assignee_id: user.id) }
end
diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb
index e4bba706453..63c36a20adc 100644
--- a/spec/features/issues/user_edits_issue_spec.rb
+++ b/spec/features/issues/user_edits_issue_spec.rb
@@ -15,6 +15,7 @@ RSpec.describe "Issues > User edits issue", :js do
context 'with authorized user' do
before do
+ stub_feature_flags(labels_widget: false)
project.add_developer(user)
project_with_milestones.add_developer(user)
sign_in(user)
diff --git a/spec/features/issues/user_views_issue_spec.rb b/spec/features/issues/user_views_issue_spec.rb
index 8792d76981f..31bf7649470 100644
--- a/spec/features/issues/user_views_issue_spec.rb
+++ b/spec/features/issues/user_views_issue_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe "User views issue" do
it 'shows the merge request and issue actions', :js, :aggregate_failures do
click_button 'Issue actions'
- expect(page).to have_link('New issue', href: new_project_issue_path(project))
+ expect(page).to have_link('New issue', href: new_project_issue_path(project, { issue: { description: "Related to \##{issue.iid}.\n\n" } }))
expect(page).to have_button('Create merge request')
expect(page).to have_button('Close issue')
end
diff --git a/spec/features/issues/user_views_issues_spec.rb b/spec/features/issues/user_views_issues_spec.rb
index 165f4b10cff..56afa7eb6ba 100644
--- a/spec/features/issues/user_views_issues_spec.rb
+++ b/spec/features/issues/user_views_issues_spec.rb
@@ -34,7 +34,7 @@ RSpec.describe "User views issues" do
.and have_content(open_issue2.title)
.and have_no_content(closed_issue.title)
.and have_content(moved_open_issue.title)
- .and have_no_selector(".js-new-board-list")
+ .and have_no_content('Create list')
end
it "opens issues by label" do
@@ -65,7 +65,7 @@ RSpec.describe "User views issues" do
.and have_no_content(open_issue1.title)
.and have_no_content(open_issue2.title)
.and have_no_content(moved_open_issue.title)
- .and have_no_selector(".js-new-board-list")
+ .and have_no_content('Create list')
end
include_examples "opens issue from list" do
@@ -87,7 +87,7 @@ RSpec.describe "User views issues" do
.and have_content(open_issue2.title)
.and have_content(moved_open_issue.title)
.and have_no_content('CLOSED (MOVED)')
- .and have_no_selector(".js-new-board-list")
+ .and have_no_content('Create list')
end
include_examples "opens issue from list" do
diff --git a/spec/features/labels_hierarchy_spec.rb b/spec/features/labels_hierarchy_spec.rb
index fca5e946d0c..25c315f2d16 100644
--- a/spec/features/labels_hierarchy_spec.rb
+++ b/spec/features/labels_hierarchy_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe 'Labels Hierarchy', :js do
let!(:project_label_1) { create(:label, project: project_1, title: 'Label_4') }
before do
- stub_feature_flags(board_new_list: false)
+ stub_feature_flags(labels_widget: false)
grandparent.add_owner(user)
sign_in(user)
@@ -215,44 +215,6 @@ RSpec.describe 'Labels Hierarchy', :js do
end
end
- context 'issuable sidebar when graphql_board_lists FF disabled' do
- let!(:issue) { create(:issue, project: project_1) }
-
- before do
- stub_feature_flags(graphql_board_lists: false)
- end
-
- context 'on project board issue sidebar' do
- before do
- project_1.add_developer(user)
- board = create(:board, project: project_1)
-
- visit project_board_path(project_1, board)
-
- wait_for_requests
-
- find('.board-card').click
- end
-
- it_behaves_like 'assigning labels from sidebar'
- end
-
- context 'on group board issue sidebar' do
- before do
- parent.add_developer(user)
- board = create(:board, group: parent)
-
- visit group_board_path(parent, board)
-
- wait_for_requests
-
- find('.board-card').click
- end
-
- it_behaves_like 'assigning labels from sidebar'
- end
- end
-
context 'issuable filtering' do
let!(:labeled_issue) { create(:labeled_issue, project: project_1, labels: [grandparent_group_label, parent_group_label, project_label_1]) }
let!(:issue) { create(:issue, project: project_1) }
@@ -307,88 +269,4 @@ RSpec.describe 'Labels Hierarchy', :js do
it_behaves_like 'filtering by ancestor labels for groups', true
end
end
-
- context 'creating boards lists' do
- before do
- stub_feature_flags(board_new_list: false)
- end
-
- context 'on project boards' do
- let(:board) { create(:board, project: project_1) }
-
- before do
- project_1.add_developer(user)
- visit project_board_path(project_1, board)
- find('.js-new-board-list').click
- wait_for_requests
- end
-
- it 'creates lists from all ancestor labels' do
- [grandparent_group_label, parent_group_label, project_label_1].each do |label|
- find('a', text: label.title).click
- end
-
- wait_for_requests
-
- expect(page).to have_selector('.board-title-text', text: grandparent_group_label.title)
- expect(page).to have_selector('.board-title-text', text: parent_group_label.title)
- expect(page).to have_selector('.board-title-text', text: project_label_1.title)
- end
- end
-
- context 'on group boards' do
- let(:board) { create(:board, group: parent) }
-
- before do
- parent.add_developer(user)
- visit group_board_path(parent, board)
- find('.js-new-board-list').click
- wait_for_requests
- end
-
- context 'when graphql_board_lists FF enabled' do
- it 'creates lists from all ancestor group labels' do
- [grandparent_group_label, parent_group_label].each do |label|
- find('a', text: label.title).click
- end
-
- wait_for_requests
-
- expect(page).to have_selector('.board-title-text', text: grandparent_group_label.title)
- expect(page).to have_selector('.board-title-text', text: parent_group_label.title)
- end
-
- it 'does not create lists from descendant groups' do
- expect(page).not_to have_selector('a', text: child_group_label.title)
- end
- end
- end
-
- context 'when graphql_board_lists FF disabled' do
- let(:board) { create(:board, group: parent) }
-
- before do
- stub_feature_flags(graphql_board_lists: false)
- parent.add_developer(user)
- visit group_board_path(parent, board)
- find('.js-new-board-list').click
- wait_for_requests
- end
-
- it 'creates lists from all ancestor group labels' do
- [grandparent_group_label, parent_group_label].each do |label|
- find('a', text: label.title).click
- end
-
- wait_for_requests
-
- expect(page).to have_selector('.board-title-text', text: grandparent_group_label.title)
- expect(page).to have_selector('.board-title-text', text: parent_group_label.title)
- end
-
- it 'does not create lists from descendant groups' do
- expect(page).not_to have_selector('a', text: child_group_label.title)
- end
- end
- end
end
diff --git a/spec/features/merge_request/batch_comments_spec.rb b/spec/features/merge_request/batch_comments_spec.rb
index c646698219b..f695b225915 100644
--- a/spec/features/merge_request/batch_comments_spec.rb
+++ b/spec/features/merge_request/batch_comments_spec.rb
@@ -25,10 +25,6 @@ RSpec.describe 'Merge request > Batch comments', :js do
visit_diffs
end
- it 'has review bar' do
- expect(page).to have_selector('[data-testid="review_bar_component"]', visible: false)
- end
-
it 'adds draft note' do
write_diff_comment
diff --git a/spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb b/spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb
index 45ee914de9d..caf0c609f64 100644
--- a/spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb
+++ b/spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe 'Merge request > User edits reviewers sidebar', :js do
page.within '.dropdown-menu-user' do
expect(page).to have_link('Invite Members')
- expect(page).to have_selector('[data-track-event="click_invite_members"]')
+ expect(page).to have_selector('[data-track-action="click_invite_members"]')
expect(page).to have_selector('[data-track-label="edit_reviewer"]')
end
diff --git a/spec/features/merge_requests/rss_spec.rb b/spec/features/merge_requests/rss_spec.rb
new file mode 100644
index 00000000000..9fc3d3d6ae1
--- /dev/null
+++ b/spec/features/merge_requests/rss_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Project Merge Requests RSS' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, :repository, group: group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
+ let_it_be(:merge_request) { create(:merge_request, source_project: project, assignees: [user]) }
+ let_it_be(:path) { project_merge_requests_path(project) }
+
+ before_all do
+ group.add_developer(user)
+ end
+
+ context 'when signed in' do
+ let_it_be(:user) { create(:user) }
+
+ before_all do
+ project.add_developer(user)
+ end
+
+ before do
+ sign_in(user)
+ visit path
+ end
+
+ it_behaves_like "it has an RSS button with current_user's feed token"
+ it_behaves_like "an autodiscoverable RSS feed with current_user's feed token"
+ end
+
+ context 'when signed out' do
+ before do
+ visit path
+ end
+
+ it_behaves_like "it has an RSS button without a feed token"
+ it_behaves_like "an autodiscoverable RSS feed without a feed token"
+ end
+
+ describe 'feeds' do
+ it_behaves_like 'updates atom feed link', :project do
+ let(:path) { project_merge_requests_path(project, assignee_id: user.id) }
+ end
+ end
+end
diff --git a/spec/features/merge_requests/user_sees_empty_state_spec.rb b/spec/features/merge_requests/user_sees_empty_state_spec.rb
index ac07b31731d..056da53c47b 100644
--- a/spec/features/merge_requests/user_sees_empty_state_spec.rb
+++ b/spec/features/merge_requests/user_sees_empty_state_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'Merge request > User sees empty state' do
+ include ProjectForksHelper
+
let(:project) { create(:project, :public, :repository) }
let(:user) { project.creator }
@@ -37,4 +39,23 @@ RSpec.describe 'Merge request > User sees empty state' do
expect(page).to have_content('To widen your search, change or remove filters above')
end
end
+
+ context 'as member of a fork' do
+ let(:fork_user) { create(:user) }
+ let(:forked_project) { fork_project(project, fork_user, namespace: fork_user.namespace, repository: true) }
+
+ before do
+ forked_project.add_maintainer(fork_user)
+ sign_in(fork_user)
+ end
+
+ it 'shows an empty state and a "New merge request" button' do
+ visit project_merge_requests_path(project, search: 'foo')
+
+ expect(page).to have_selector('.empty-state')
+ within('.empty-state') do
+ expect(page).to have_link 'New merge request', href: project_new_merge_request_path(forked_project)
+ end
+ end
+ end
end
diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb
index de511e99182..8025db9f86d 100644
--- a/spec/features/profiles/personal_access_tokens_spec.rb
+++ b/spec/features/profiles/personal_access_tokens_spec.rb
@@ -56,7 +56,7 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
click_on "Create personal access token"
expect(active_personal_access_tokens).to have_text(name)
- expect(active_personal_access_tokens).to have_text('In')
+ expect(active_personal_access_tokens).to have_text('in')
expect(active_personal_access_tokens).to have_text('api')
expect(active_personal_access_tokens).to have_text('read_user')
expect(created_personal_access_token).not_to be_empty
@@ -85,6 +85,18 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
expect(active_personal_access_tokens).to have_text(personal_access_token.name)
expect(active_personal_access_tokens).not_to have_text(impersonation_token.name)
end
+
+ context 'when User#time_display_relative is false' do
+ before do
+ user.update!(time_display_relative: false)
+ end
+
+ it 'shows absolute times for expires_at' do
+ visit profile_personal_access_tokens_path
+
+ expect(active_personal_access_tokens).to have_text(PersonalAccessToken.last.expires_at.strftime('%b %d'))
+ end
+ end
end
describe "inactive tokens" do
diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb
index d941988d12f..af085b63155 100644
--- a/spec/features/profiles/user_edit_profile_spec.rb
+++ b/spec/features/profiles/user_edit_profile_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe 'User edit profile' do
fill_in 'user_skype', with: 'testskype'
fill_in 'user_linkedin', with: 'testlinkedin'
fill_in 'user_twitter', with: 'testtwitter'
- fill_in 'user_website_url', with: 'testurl'
+ fill_in 'user_website_url', with: 'http://testurl.com'
fill_in 'user_location', with: 'Ukraine'
fill_in 'user_bio', with: 'I <3 GitLab :tada:'
fill_in 'user_job_title', with: 'Frontend Engineer'
@@ -43,9 +43,8 @@ RSpec.describe 'User edit profile' do
skype: 'testskype',
linkedin: 'testlinkedin',
twitter: 'testtwitter',
- website_url: 'testurl',
+ website_url: 'http://testurl.com',
bio: 'I <3 GitLab :tada:',
- bio_html: '<p data-sourcepos="1:1-1:18" dir="auto">I &lt;3 GitLab <gl-emoji title="party popper" data-name="tada" data-unicode-version="6.0">🎉</gl-emoji></p>',
job_title: 'Frontend Engineer',
organization: 'GitLab'
)
@@ -54,6 +53,19 @@ RSpec.describe 'User edit profile' do
expect(page).to have_content('Profile was successfully updated')
end
+ it 'does not set secondary emails without user input' do
+ fill_in 'user_organization', with: 'GitLab'
+ submit_settings
+
+ user.reload
+ expect(page).to have_field('user_commit_email', with: '')
+ expect(page).to have_field('user_public_email', with: '')
+
+ User::SECONDARY_EMAIL_ATTRIBUTES.each do |attribute|
+ expect(user.read_attribute(attribute)).to be_blank
+ end
+ end
+
it 'shows an error if the full name contains an emoji', :js do
simulate_input('#user_name', 'Martin 😀')
submit_settings
@@ -65,6 +77,17 @@ RSpec.describe 'User edit profile' do
end
end
+ it 'shows an error if the website url is not valid' do
+ fill_in 'user_website_url', with: 'admin@gitlab.com'
+ submit_settings
+
+ expect(user.reload).to have_attributes(
+ website_url: ''
+ )
+
+ expect(page).to have_content('Website url is not a valid URL')
+ end
+
describe 'when I change my email' do
before do
user.send_reset_password_instructions
diff --git a/spec/features/projects/ci/editor_spec.rb b/spec/features/projects/ci/editor_spec.rb
index 192bccd6f6e..7fe1c63f490 100644
--- a/spec/features/projects/ci/editor_spec.rb
+++ b/spec/features/projects/ci/editor_spec.rb
@@ -27,10 +27,6 @@ RSpec.describe 'Pipeline Editor', :js do
end
context 'branch switcher' do
- before do
- stub_feature_flags(pipeline_editor_branch_switcher: true)
- end
-
def switch_to_branch(branch)
find('[data-testid="branch-selector"]').click
diff --git a/spec/features/projects/commits/user_browses_commits_spec.rb b/spec/features/projects/commits/user_browses_commits_spec.rb
index 76162fb800a..863fdbdadaa 100644
--- a/spec/features/projects/commits/user_browses_commits_spec.rb
+++ b/spec/features/projects/commits/user_browses_commits_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe 'User browses commits' do
sign_in(user)
end
- it 'renders commit' do
+ it 'renders commit', :js do
visit project_commit_path(project, sample_commit.id)
expect(page).to have_content(sample_commit.message.gsub(/\s+/, ' '))
@@ -103,7 +103,7 @@ RSpec.describe 'User browses commits' do
context 'when the blob does not exist' do
let(:commit) { create(:commit, project: project) }
- it 'renders successfully' do
+ it 'renders successfully', :js do
allow_next_instance_of(Gitlab::Diff::File) do |instance|
allow(instance).to receive(:blob).and_return(nil)
end
@@ -113,7 +113,9 @@ RSpec.describe 'User browses commits' do
visit(project_commit_path(project, commit))
- expect(find('.diff-file-changes', visible: false)).to have_content('files/ruby/popen.rb')
+ click_button '2 changed files'
+
+ expect(find('[data-testid="diff-stats-dropdown"]')).to have_content('files/ruby/popen.rb')
end
end
diff --git a/spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb b/spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb
index f6330491886..71c9d89fbde 100644
--- a/spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb
+++ b/spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb
@@ -66,20 +66,4 @@ RSpec.describe 'User updates feature flag', :js do
end
end
end
-
- context 'with a legacy feature flag' do
- let!(:feature_flag) do
- create_flag(project, 'ci_live_trace', true,
- description: 'For live trace feature',
- version: :legacy_flag)
- end
-
- let!(:scope) { create_scope(feature_flag, 'review/*', true) }
-
- it 'shows not found error' do
- visit(edit_project_feature_flag_path(project, feature_flag))
-
- expect(page).to have_text 'Page Not Found'
- end
- end
end
diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb
index 302187917b7..00e85a215b8 100644
--- a/spec/features/projects/import_export/import_file_spec.rb
+++ b/spec/features/projects/import_export/import_file_spec.rb
@@ -10,6 +10,7 @@ RSpec.describe 'Import/Export - project import integration test', :js do
let(:export_path) { "#{Dir.tmpdir}/import_file_spec" }
before do
+ stub_feature_flags(paginatable_namespace_drop_down_for_project_creation: false)
stub_uploads_object_storage(FileUploader)
allow_next_instance_of(Gitlab::ImportExport) do |instance|
allow(instance).to receive(:storage_path).and_return(export_path)
diff --git a/spec/features/projects/jobs/permissions_spec.rb b/spec/features/projects/jobs/permissions_spec.rb
index 140d5dee270..a904ba770dd 100644
--- a/spec/features/projects/jobs/permissions_spec.rb
+++ b/spec/features/projects/jobs/permissions_spec.rb
@@ -90,7 +90,7 @@ RSpec.describe 'Project Jobs Permissions' do
it_behaves_like 'recent job page details responds with status', 200 do
it 'renders job details', :js do
- expect(page).to have_content "Job ##{job.id}"
+ expect(page).to have_content "Job #{job.name}"
expect(page).to have_css '.log-line'
end
end
diff --git a/spec/features/projects/jobs/user_browses_job_spec.rb b/spec/features/projects/jobs/user_browses_job_spec.rb
index 9b199157d79..060b7ffbfc9 100644
--- a/spec/features/projects/jobs/user_browses_job_spec.rb
+++ b/spec/features/projects/jobs/user_browses_job_spec.rb
@@ -21,7 +21,7 @@ RSpec.describe 'User browses a job', :js do
it 'erases the job log', :js do
wait_for_requests
- expect(page).to have_content("Job ##{build.id}")
+ expect(page).to have_content("Job #{build.name}")
expect(page).to have_css('.job-log')
# scroll to the top of the page first
diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb
index 94543290050..113ba692497 100644
--- a/spec/features/projects/members/user_requests_access_spec.rb
+++ b/spec/features/projects/members/user_requests_access_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe 'Projects > Members > User requests access', :js do
it 'user can request access to a project' do
perform_enqueued_jobs { click_link 'Request Access' }
- expect(ActionMailer::Base.deliveries.last.to).to eq [maintainer.notification_email]
+ expect(ActionMailer::Base.deliveries.last.to).to eq [maintainer.notification_email_or_default]
expect(ActionMailer::Base.deliveries.last.subject).to eq "Request to join the #{project.full_name} project"
expect(project.requesters.exists?(user_id: user)).to be_truthy
diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb
index 0b293970703..39f9d3b331b 100644
--- a/spec/features/projects/new_project_spec.rb
+++ b/spec/features/projects/new_project_spec.rb
@@ -6,6 +6,10 @@ RSpec.describe 'New project', :js do
include Select2Helper
include Spec::Support::Helpers::Features::TopNavSpecHelpers
+ before do
+ stub_feature_flags(paginatable_namespace_drop_down_for_project_creation: false)
+ end
+
context 'as a user' do
let(:user) { create(:user) }
diff --git a/spec/features/projects/package_files_spec.rb b/spec/features/projects/package_files_spec.rb
index c5c03396d71..6dc0294bb9e 100644
--- a/spec/features/projects/package_files_spec.rb
+++ b/spec/features/projects/package_files_spec.rb
@@ -23,20 +23,6 @@ RSpec.describe 'PackageFiles' do
expect(status_code).to eq(200)
end
- context 'when package_details_apollo feature flag is off' do
- before do
- stub_feature_flags(package_details_apollo: false)
- end
-
- it 'renders the download link with the correct url', :js do
- visit project_package_path(project, package)
-
- download_url = download_project_package_file_path(project, package_file)
-
- expect(page).to have_link(package_file.file_name, href: download_url)
- end
- end
-
it 'does not allow download of package belonging to different project' do
another_package = create(:maven_package)
another_file = another_package.package_files.first
diff --git a/spec/features/projects/packages_spec.rb b/spec/features/projects/packages_spec.rb
index 30298f79312..7fcc8200b1c 100644
--- a/spec/features/projects/packages_spec.rb
+++ b/spec/features/projects/packages_spec.rb
@@ -37,14 +37,6 @@ RSpec.describe 'Packages' do
it_behaves_like 'packages list'
- context 'when package_details_apollo feature flag is off' do
- before do
- stub_feature_flags(package_details_apollo: false)
- end
-
- it_behaves_like 'package details link'
- end
-
it_behaves_like 'package details link'
context 'deleting a package' do
diff --git a/spec/features/projects/services/user_activates_slack_slash_command_spec.rb b/spec/features/projects/services/user_activates_slack_slash_command_spec.rb
index 4dfd4416eeb..bc84ccaa432 100644
--- a/spec/features/projects/services/user_activates_slack_slash_command_spec.rb
+++ b/spec/features/projects/services/user_activates_slack_slash_command_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe 'Slack slash commands', :js do
end
it 'shows a help message' do
- expect(page).to have_content('This service allows users to perform common')
+ expect(page).to have_content('Perform common operations in this project')
end
it 'redirects to the integrations page after saving but not activating' do
@@ -42,6 +42,6 @@ RSpec.describe 'Slack slash commands', :js do
end
it 'shows help content' do
- expect(page).to have_content('This service allows users to perform common operations on this project by entering slash commands in Slack.')
+ expect(page).to have_content('Perform common operations in this project by entering slash commands in Slack.')
end
end
diff --git a/spec/features/projects/settings/access_tokens_spec.rb b/spec/features/projects/settings/access_tokens_spec.rb
index 33e2623522e..deeab084c5f 100644
--- a/spec/features/projects/settings/access_tokens_spec.rb
+++ b/spec/features/projects/settings/access_tokens_spec.rb
@@ -65,7 +65,7 @@ RSpec.describe 'Project > Settings > Access Tokens', :js do
click_on 'Create project access token'
expect(active_project_access_tokens).to have_text(name)
- expect(active_project_access_tokens).to have_text('In')
+ expect(active_project_access_tokens).to have_text('in')
expect(active_project_access_tokens).to have_text('api')
expect(active_project_access_tokens).to have_text('read_api')
expect(active_project_access_tokens).to have_text('Maintainer')
@@ -156,6 +156,18 @@ RSpec.describe 'Project > Settings > Access Tokens', :js do
expect(active_project_access_tokens).to have_text(project_access_token.name)
end
+
+ context 'when User#time_display_relative is false' do
+ before do
+ user.update!(time_display_relative: false)
+ end
+
+ it 'shows absolute times for expires_at' do
+ visit project_settings_access_tokens_path(project)
+
+ expect(active_project_access_tokens).to have_text(PersonalAccessToken.last.expires_at.strftime('%b %d'))
+ end
+ end
end
describe 'inactive tokens' do
diff --git a/spec/features/projects/settings/monitor_settings_spec.rb b/spec/features/projects/settings/monitor_settings_spec.rb
index 2d8c418b7d0..e3d75c30e5e 100644
--- a/spec/features/projects/settings/monitor_settings_spec.rb
+++ b/spec/features/projects/settings/monitor_settings_spec.rb
@@ -150,6 +150,33 @@ RSpec.describe 'Projects > Settings > For a forked project', :js do
assert_text('Connection failed. Check Auth Token and try again.')
end
end
+
+ context 'integrated error tracking backend' do
+ it 'successfully fills and submits the form' do
+ visit project_settings_operations_path(project)
+
+ wait_for_requests
+
+ within '.js-error-tracking-settings' do
+ click_button('Expand')
+ end
+
+ expect(page).to have_content('Error tracking backend')
+
+ within '.js-error-tracking-settings' do
+ check('Active')
+ choose('GitLab')
+ end
+
+ expect(page).not_to have_content('Sentry API URL')
+
+ click_button('Save changes')
+
+ wait_for_requests
+
+ assert_text('Your changes have been saved')
+ end
+ end
end
context 'grafana integration settings form' do
diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb
index f420a8a76b9..4e1b55d3d70 100644
--- a/spec/features/projects/settings/repository_settings_spec.rb
+++ b/spec/features/projects/settings/repository_settings_spec.rb
@@ -31,11 +31,11 @@ RSpec.describe 'Projects > Settings > Repository settings' do
before do
stub_container_registry_config(enabled: true)
stub_feature_flags(ajax_new_deploy_token: project)
- visit project_settings_repository_path(project)
end
it_behaves_like 'a deploy token in settings' do
let(:entity_type) { 'project' }
+ let(:page_path) { project_settings_repository_path(project) }
end
end
diff --git a/spec/features/projects/settings/service_desk_setting_spec.rb b/spec/features/projects/settings/service_desk_setting_spec.rb
index 91355d8f625..0924f8320e1 100644
--- a/spec/features/projects/settings/service_desk_setting_spec.rb
+++ b/spec/features/projects/settings/service_desk_setting_spec.rb
@@ -38,7 +38,6 @@ RSpec.describe 'Service Desk Setting', :js, :clean_gitlab_redis_cache do
expect(project.service_desk_enabled).to be_truthy
expect(project.service_desk_address).to be_present
expect(find('[data-testid="incoming-email"]').value).to eq(project.service_desk_incoming_address)
- expect(page).not_to have_selector('#service-desk-project-suffix')
end
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 be4b6d6b82d..02a634a0fcc 100644
--- a/spec/features/projects/settings/user_manages_project_members_spec.rb
+++ b/spec/features/projects/settings/user_manages_project_members_spec.rb
@@ -43,10 +43,15 @@ RSpec.describe 'Projects > Settings > User manages project members' do
visit(project_project_members_path(project))
- click_link('Import a project')
+ click_on 'Import from a project'
+ click_on 'Select a project'
+ wait_for_requests
- select2(project2.id, from: '#source_project_id')
- click_button('Import project members')
+ click_button project2.name
+ click_button 'Import project members'
+ wait_for_requests
+
+ page.refresh
expect(find_member_row(user_mike)).to have_content('Reporter')
end
diff --git a/spec/features/projects/user_creates_project_spec.rb b/spec/features/projects/user_creates_project_spec.rb
index 2dc2f168896..9f08759603e 100644
--- a/spec/features/projects/user_creates_project_spec.rb
+++ b/spec/features/projects/user_creates_project_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe 'User creates a project', :js do
let(:user) { create(:user) }
before do
+ stub_feature_flags(paginatable_namespace_drop_down_for_project_creation: false)
sign_in(user)
create(:personal_key, user: user)
end
diff --git a/spec/features/registrations/experience_level_spec.rb b/spec/features/registrations/experience_level_spec.rb
deleted file mode 100644
index f432215d4a8..00000000000
--- a/spec/features/registrations/experience_level_spec.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Experience level screen' do
- let_it_be(:user) { create(:user, :unconfirmed) }
- let_it_be(:group) { create(:group) }
-
- before do
- group.add_owner(user)
- gitlab_sign_in(user)
- visit users_sign_up_experience_level_path(namespace_path: group.to_param)
- end
-
- subject { page }
-
- it 'shows the intro content' do
- is_expected.to have_content('Hello there')
- is_expected.to have_content('Welcome to the guided GitLab tour')
- is_expected.to have_content('What describes you best?')
- end
-
- it 'shows the option for novice' do
- is_expected.to have_content('Novice')
- is_expected.to have_content('I’m not familiar with the basics of DevOps')
- is_expected.to have_content('Show me the basics')
- end
-
- it 'shows the option for experienced' do
- is_expected.to have_content('Experienced')
- is_expected.to have_content('I’m familiar with the basics of DevOps')
- is_expected.to have_content('Show me advanced features')
- end
-
- it 'does not display any flash messages' do
- is_expected.not_to have_selector('.flash-container')
- is_expected.not_to have_content("Please check your email (#{user.email}) to verify that you own this address and unlock the power of CI/CD")
- end
-
- it 'does not include the footer links' do
- is_expected.not_to have_link('Help')
- is_expected.not_to have_link('About GitLab')
- end
-end
diff --git a/spec/features/users/anonymous_sessions_spec.rb b/spec/features/users/anonymous_sessions_spec.rb
index 273d3aa346f..6b21412ae3d 100644
--- a/spec/features/users/anonymous_sessions_spec.rb
+++ b/spec/features/users/anonymous_sessions_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'Session TTLs', :clean_gitlab_redis_shared_state do
+ include SessionHelpers
+
it 'creates a session with a short TTL when login fails' do
visit new_user_session_path
# The session key only gets created after a post
@@ -12,7 +14,7 @@ RSpec.describe 'Session TTLs', :clean_gitlab_redis_shared_state do
expect(page).to have_content('Invalid login or password')
- expect_single_session_with_expiration(Settings.gitlab['unauthenticated_session_expire_delay'])
+ expect_single_session_with_short_ttl
end
it 'increases the TTL when the login succeeds' do
@@ -21,21 +23,17 @@ RSpec.describe 'Session TTLs', :clean_gitlab_redis_shared_state do
expect(page).to have_content(user.name)
- expect_single_session_with_expiration(Settings.gitlab['session_expire_delay'] * 60)
+ expect_single_session_with_authenticated_ttl
end
- def expect_single_session_with_expiration(expiration)
- session_keys = get_session_keys
-
- expect(session_keys.size).to eq(1)
- expect(get_ttl(session_keys.first)).to eq expiration
- end
+ context 'with an unauthorized project' do
+ let_it_be(:project) { create(:project, :repository) }
- def get_session_keys
- Gitlab::Redis::SharedState.with { |redis| redis.scan_each(match: 'session:gitlab:*').to_a }
- end
+ it 'creates a session with a short TTL' do
+ visit project_raw_path(project, 'master/README.md')
- def get_ttl(key)
- Gitlab::Redis::SharedState.with { |redis| redis.ttl(key) }
+ expect_single_session_with_short_ttl
+ expect(page).to have_current_path(new_user_session_path)
+ end
end
end
diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb
index 6c38d5d8b24..afd750d02eb 100644
--- a/spec/features/users/login_spec.rb
+++ b/spec/features/users/login_spec.rb
@@ -2,9 +2,10 @@
require 'spec_helper'
-RSpec.describe 'Login' do
+RSpec.describe 'Login', :clean_gitlab_redis_shared_state do
include TermsHelper
include UserLoginHelper
+ include SessionHelpers
before do
stub_authentication_activity_metrics(debug: true)
@@ -59,6 +60,7 @@ RSpec.describe 'Login' do
fill_in 'user_password', with: 'password'
click_button 'Sign in'
+ expect_single_session_with_authenticated_ttl
expect(current_path).to eq root_path
end
@@ -192,6 +194,7 @@ RSpec.describe 'Login' do
enter_code(user.current_otp)
expect(page).not_to have_content(I18n.t('devise.failure.already_authenticated'))
+ expect_single_session_with_authenticated_ttl
end
it 'does not allow sign-in if the user password is updated before entering a one-time code' do
@@ -210,6 +213,7 @@ RSpec.describe 'Login' do
enter_code(user.current_otp)
+ expect_single_session_with_authenticated_ttl
expect(current_path).to eq root_path
end
@@ -237,6 +241,8 @@ RSpec.describe 'Login' do
expect(page).to have_content('Invalid two-factor code')
enter_code(user.current_otp)
+
+ expect_single_session_with_authenticated_ttl
expect(current_path).to eq root_path
end
@@ -353,6 +359,7 @@ RSpec.describe 'Login' do
sign_in_using_saml!
+ expect_single_session_with_authenticated_ttl
expect(page).not_to have_content('Two-Factor Authentication')
expect(current_path).to eq root_path
end
@@ -371,6 +378,7 @@ RSpec.describe 'Login' do
enter_code(user.current_otp)
+ expect_single_session_with_authenticated_ttl
expect(current_path).to eq root_path
end
end
@@ -391,6 +399,7 @@ RSpec.describe 'Login' do
gitlab_sign_in(user)
+ expect_single_session_with_authenticated_ttl
expect(current_path).to eq root_path
expect(page).not_to have_content(I18n.t('devise.failure.already_authenticated'))
end
@@ -402,6 +411,7 @@ RSpec.describe 'Login' do
gitlab_sign_in(user)
visit new_user_session_path
+ expect_single_session_with_authenticated_ttl
expect(page).not_to have_content(I18n.t('devise.failure.already_authenticated'))
end
@@ -443,6 +453,7 @@ RSpec.describe 'Login' do
gitlab_sign_in(user)
+ expect_single_session_with_short_ttl
expect(page).to have_content('Invalid login or password.')
end
end
diff --git a/spec/features/users/show_spec.rb b/spec/features/users/show_spec.rb
index fb2873f1c96..e629d329033 100644
--- a/spec/features/users/show_spec.rb
+++ b/spec/features/users/show_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe 'User page' do
include ExternalAuthorizationServiceHelpers
- let_it_be(:user) { create(:user, bio: '**Lorem** _ipsum_ dolor sit [amet](https://example.com)') }
+ let_it_be(:user) { create(:user, bio: '<b>Lorem</b> <i>ipsum</i> dolor sit <a href="https://example.com">amet</a>') }
subject(:visit_profile) { visit(user_path(user)) }
@@ -186,7 +186,17 @@ RSpec.describe 'User page' do
end
context 'with blocked profile' do
- let_it_be(:user) { create(:user, state: :blocked) }
+ let_it_be(:user) do
+ create(
+ :user,
+ state: :blocked,
+ organization: 'GitLab - work info test',
+ job_title: 'Frontend Engineer',
+ pronunciation: 'pruh-nuhn-see-ay-shn'
+ )
+ end
+
+ let_it_be(:status) { create(:user_status, user: user, message: "Working hard!") }
it 'shows no tab' do
subject
@@ -211,7 +221,10 @@ RSpec.describe 'User page' do
subject
expect(page).not_to have_css(".profile-user-bio")
- expect(page).not_to have_css(".profile-link-holder")
+ expect(page).not_to have_content('GitLab - work info test')
+ expect(page).not_to have_content('Frontend Engineer')
+ expect(page).not_to have_content('Working hard!')
+ expect(page).not_to have_content("Pronounced as: pruh-nuhn-see-ay-shn")
end
it 'shows username' do
@@ -222,7 +235,17 @@ RSpec.describe 'User page' do
end
context 'with unconfirmed user' do
- let_it_be(:user) { create(:user, :unconfirmed) }
+ let_it_be(:user) do
+ create(
+ :user,
+ :unconfirmed,
+ organization: 'GitLab - work info test',
+ job_title: 'Frontend Engineer',
+ pronunciation: 'pruh-nuhn-see-ay-shn'
+ )
+ end
+
+ let_it_be(:status) { create(:user_status, user: user, message: "Working hard!") }
shared_examples 'unconfirmed user profile' do
before do
@@ -240,7 +263,10 @@ RSpec.describe 'User page' do
it 'shows no additional fields' do
expect(page).not_to have_css(".profile-user-bio")
- expect(page).not_to have_css(".profile-link-holder")
+ expect(page).not_to have_content('GitLab - work info test')
+ expect(page).not_to have_content('Frontend Engineer')
+ expect(page).not_to have_content('Working hard!')
+ expect(page).not_to have_content("Pronounced as: pruh-nuhn-see-ay-shn")
end
it 'shows private profile message' do
@@ -403,4 +429,27 @@ RSpec.describe 'User page' do
end
end
end
+
+ context 'GPG keys' do
+ context 'when user has verified GPG keys' do
+ let_it_be(:user) { create(:user, email: GpgHelpers::User1.emails.first) }
+ let_it_be(:gpg_key) { create(:gpg_key, user: user, key: GpgHelpers::User1.public_key) }
+ let_it_be(:gpg_key2) { create(:gpg_key, user: user, key: GpgHelpers::User1.public_key2) }
+
+ it 'shows link to public GPG keys' do
+ subject
+
+ expect(page).to have_link('View public GPG keys', href: user_gpg_keys_path(user))
+ end
+ end
+
+ context 'when user does not have verified GPG keys' do
+ it 'does not show link to public GPG keys' do
+ subject
+
+ expect(page).not_to have_link('View public GPG key', href: user_gpg_keys_path(user))
+ expect(page).not_to have_link('View public GPG keys', href: user_gpg_keys_path(user))
+ end
+ end
+ end
end
diff --git a/spec/finders/branches_finder_spec.rb b/spec/finders/branches_finder_spec.rb
index a62dd3842db..f9d525c33a4 100644
--- a/spec/finders/branches_finder_spec.rb
+++ b/spec/finders/branches_finder_spec.rb
@@ -259,4 +259,11 @@ RSpec.describe BranchesFinder do
end
end
end
+
+ describe '#total' do
+ subject { branch_finder.total }
+
+ it { is_expected.to be_an(Integer) }
+ it { is_expected.to eq(repository.branch_count) }
+ end
end
diff --git a/spec/finders/ci/pipelines_finder_spec.rb b/spec/finders/ci/pipelines_finder_spec.rb
index c7bd52576e8..908210e0296 100644
--- a/spec/finders/ci/pipelines_finder_spec.rb
+++ b/spec/finders/ci/pipelines_finder_spec.rb
@@ -113,27 +113,6 @@ RSpec.describe Ci::PipelinesFinder do
end
end
- context 'when name is specified' do
- let(:user) { create(:user) }
- let!(:pipeline) { create(:ci_pipeline, project: project, user: user) }
-
- context 'when name exists' do
- let(:params) { { name: user.name } }
-
- it 'returns matched pipelines' do
- is_expected.to eq([pipeline])
- end
- end
-
- context 'when name does not exist' do
- let(:params) { { name: 'invalid-name' } }
-
- it 'returns empty' do
- is_expected.to be_empty
- end
- end
- end
-
context 'when username is specified' do
let(:user) { create(:user) }
let!(:pipeline) { create(:ci_pipeline, project: project, user: user) }
@@ -258,20 +237,8 @@ RSpec.describe Ci::PipelinesFinder do
let!(:push_pipeline) { create(:ci_pipeline, project: project, source: 'push') }
let!(:api_pipeline) { create(:ci_pipeline, project: project, source: 'api') }
- context 'when `pipeline_source_filter` feature flag is disabled' do
- before do
- stub_feature_flags(pipeline_source_filter: false)
- end
-
- it 'returns all the pipelines' do
- is_expected.to contain_exactly(web_pipeline, push_pipeline, api_pipeline)
- end
- end
-
- context 'when `pipeline_source_filter` feature flag is enabled' do
- it 'returns only the matched pipeline' do
- is_expected.to eq([web_pipeline])
- end
+ it 'returns only the matched pipeline' do
+ is_expected.to eq([web_pipeline])
end
end
diff --git a/spec/finders/ci/runners_finder_spec.rb b/spec/finders/ci/runners_finder_spec.rb
index 599b4ffb804..10d3f641e02 100644
--- a/spec/finders/ci/runners_finder_spec.rb
+++ b/spec/finders/ci/runners_finder_spec.rb
@@ -18,6 +18,13 @@ RSpec.describe Ci::RunnersFinder do
end
end
+ context 'with nil group' do
+ it 'returns all runners' do
+ expect(Ci::Runner).to receive(:with_tags).and_call_original
+ expect(described_class.new(current_user: admin, params: { group: nil }).execute).to match_array [runner1, runner2]
+ end
+ end
+
context 'with preload param set to :tag_name true' do
it 'requests tags' do
expect(Ci::Runner).to receive(:with_tags).and_call_original
@@ -158,6 +165,7 @@ RSpec.describe Ci::RunnersFinder do
let_it_be(:project_4) { create(:project, group: sub_group_2) }
let_it_be(:project_5) { create(:project, group: sub_group_3) }
let_it_be(:project_6) { create(:project, group: sub_group_4) }
+ let_it_be(:runner_instance) { create(:ci_runner, :instance, contacted_at: 13.minutes.ago) }
let_it_be(:runner_group) { create(:ci_runner, :group, contacted_at: 12.minutes.ago) }
let_it_be(:runner_sub_group_1) { create(:ci_runner, :group, active: false, contacted_at: 11.minutes.ago) }
let_it_be(:runner_sub_group_2) { create(:ci_runner, :group, contacted_at: 10.minutes.ago) }
@@ -171,7 +179,10 @@ RSpec.describe Ci::RunnersFinder do
let_it_be(:runner_project_6) { create(:ci_runner, :project, contacted_at: 2.minutes.ago, projects: [project_5])}
let_it_be(:runner_project_7) { create(:ci_runner, :project, contacted_at: 1.minute.ago, projects: [project_6])}
- let(:params) { {} }
+ let(:target_group) { nil }
+ let(:membership) { nil }
+ let(:extra_params) { {} }
+ let(:params) { { group: target_group, membership: membership }.merge(extra_params).reject { |_, v| v.nil? } }
before do
group.runners << runner_group
@@ -182,65 +193,104 @@ RSpec.describe Ci::RunnersFinder do
end
describe '#execute' do
- subject { described_class.new(current_user: user, group: group, params: params).execute }
+ subject { described_class.new(current_user: user, params: params).execute }
+
+ shared_examples 'membership equal to :descendants' do
+ it 'returns all descendant runners' do
+ expect(subject).to eq([runner_project_7, runner_project_6, runner_project_5,
+ runner_project_4, runner_project_3, runner_project_2,
+ runner_project_1, runner_sub_group_4, runner_sub_group_3,
+ runner_sub_group_2, runner_sub_group_1, runner_group])
+ end
+ end
context 'with user as group owner' do
before do
group.add_owner(user)
end
- context 'passing no params' do
- it 'returns all descendant runners' do
- expect(subject).to eq([runner_project_7, runner_project_6, runner_project_5,
- runner_project_4, runner_project_3, runner_project_2,
- runner_project_1, runner_sub_group_4, runner_sub_group_3,
- runner_sub_group_2, runner_sub_group_1, runner_group])
+ context 'with :group as target group' do
+ let(:target_group) { group }
+
+ context 'passing no params' do
+ it_behaves_like 'membership equal to :descendants'
end
- end
- context 'with sort param' do
- let(:params) { { sort: 'contacted_asc' } }
+ context 'with :descendants membership' do
+ let(:membership) { :descendants }
- it 'sorts by specified attribute' do
- expect(subject).to eq([runner_group, runner_sub_group_1, runner_sub_group_2,
- runner_sub_group_3, runner_sub_group_4, runner_project_1,
- runner_project_2, runner_project_3, runner_project_4,
- runner_project_5, runner_project_6, runner_project_7])
+ it_behaves_like 'membership equal to :descendants'
end
- end
- context 'filtering' do
- context 'by search term' do
- let(:params) { { search: 'runner_project_search' } }
+ context 'with :direct membership' do
+ let(:membership) { :direct }
+
+ it 'returns runners belonging to group' do
+ expect(subject).to eq([runner_group])
+ end
+ end
+
+ context 'with unknown membership' do
+ let(:membership) { :unsupported }
- it 'returns correct runner' do
- expect(subject).to eq([runner_project_3])
+ it 'raises an error' do
+ expect { subject }.to raise_error(ArgumentError, 'Invalid membership filter')
end
end
- context 'by status' do
- let(:params) { { status_status: 'paused' } }
+ context 'with nil group' do
+ let(:target_group) { nil }
- it 'returns correct runner' do
- expect(subject).to eq([runner_sub_group_1])
+ it 'returns no runners' do
+ # Query should run against all runners, however since user is not admin, query returns no results
+ expect(subject).to eq([])
end
end
- context 'by tag_name' do
- let(:params) { { tag_name: %w[runner_tag] } }
+ context 'with sort param' do
+ let(:extra_params) { { sort: 'contacted_asc' } }
- it 'returns correct runner' do
- expect(subject).to eq([runner_project_5])
+ it 'sorts by specified attribute' do
+ expect(subject).to eq([runner_group, runner_sub_group_1, runner_sub_group_2,
+ runner_sub_group_3, runner_sub_group_4, runner_project_1,
+ runner_project_2, runner_project_3, runner_project_4,
+ runner_project_5, runner_project_6, runner_project_7])
end
end
- context 'by runner type' do
- let(:params) { { type_type: 'project_type' } }
+ context 'filtering' do
+ context 'by search term' do
+ let(:extra_params) { { search: 'runner_project_search' } }
+
+ it 'returns correct runner' do
+ expect(subject).to eq([runner_project_3])
+ end
+ end
+
+ context 'by status' do
+ let(:extra_params) { { status_status: 'paused' } }
+
+ it 'returns correct runner' do
+ expect(subject).to eq([runner_sub_group_1])
+ end
+ end
+
+ context 'by tag_name' do
+ let(:extra_params) { { tag_name: %w[runner_tag] } }
+
+ it 'returns correct runner' do
+ expect(subject).to eq([runner_project_5])
+ end
+ end
+
+ context 'by runner type' do
+ let(:extra_params) { { type_type: 'project_type' } }
- it 'returns correct runners' do
- expect(subject).to eq([runner_project_7, runner_project_6,
- runner_project_5, runner_project_4,
- runner_project_3, runner_project_2, runner_project_1])
+ it 'returns correct runners' do
+ expect(subject).to eq([runner_project_7, runner_project_6,
+ runner_project_5, runner_project_4,
+ runner_project_3, runner_project_2, runner_project_1])
+ end
end
end
end
@@ -278,7 +328,7 @@ RSpec.describe Ci::RunnersFinder do
end
describe '#sort_key' do
- subject { described_class.new(current_user: user, group: group, params: params).sort_key }
+ subject { described_class.new(current_user: user, params: params.merge(group: group)).sort_key }
context 'without params' do
it 'returns created_at_desc' do
@@ -287,7 +337,7 @@ RSpec.describe Ci::RunnersFinder do
end
context 'with params' do
- let(:params) { { sort: 'contacted_asc' } }
+ let(:extra_params) { { sort: 'contacted_asc' } }
it 'returns contacted_asc' do
expect(subject).to eq('contacted_asc')
diff --git a/spec/finders/error_tracking/errors_finder_spec.rb b/spec/finders/error_tracking/errors_finder_spec.rb
index 2df5f1653e0..29053054f9d 100644
--- a/spec/finders/error_tracking/errors_finder_spec.rb
+++ b/spec/finders/error_tracking/errors_finder_spec.rb
@@ -6,7 +6,8 @@ RSpec.describe ErrorTracking::ErrorsFinder do
let_it_be(:project) { create(:project) }
let_it_be(:user) { project.creator }
let_it_be(:error) { create(:error_tracking_error, project: project) }
- let_it_be(:error_resolved) { create(:error_tracking_error, :resolved, project: project) }
+ let_it_be(:error_resolved) { create(:error_tracking_error, :resolved, project: project, first_seen_at: 2.hours.ago) }
+ let_it_be(:error_yesterday) { create(:error_tracking_error, project: project, first_seen_at: Time.zone.now.yesterday) }
before do
project.add_maintainer(user)
@@ -17,12 +18,25 @@ RSpec.describe ErrorTracking::ErrorsFinder do
subject { described_class.new(user, project, params).execute }
- it { is_expected.to contain_exactly(error, error_resolved) }
+ it { is_expected.to contain_exactly(error, error_resolved, error_yesterday) }
context 'with status parameter' do
let(:params) { { status: 'resolved' } }
it { is_expected.to contain_exactly(error_resolved) }
end
+
+ context 'with sort parameter' do
+ let(:params) { { status: 'unresolved', sort: 'first_seen' } }
+
+ it { is_expected.to eq([error, error_yesterday]) }
+ end
+
+ context 'with limit parameter' do
+ let(:params) { { limit: '1', sort: 'first_seen' } }
+
+ # Sort by first_seen is DESC by default, so the most recent error is `error`
+ it { is_expected.to contain_exactly(error) }
+ end
end
end
diff --git a/spec/finders/feature_flags_finder_spec.rb b/spec/finders/feature_flags_finder_spec.rb
index 4faa6a62a1f..1b3c71b143f 100644
--- a/spec/finders/feature_flags_finder_spec.rb
+++ b/spec/finders/feature_flags_finder_spec.rb
@@ -72,13 +72,5 @@ RSpec.describe FeatureFlagsFinder do
subject
end
end
-
- context 'with a legacy flag' do
- let!(:feature_flag_3) { create(:operations_feature_flag, :legacy_flag, name: 'flag-c', project: project) }
-
- it 'returns new flags' do
- is_expected.to eq([feature_flag_1, feature_flag_2])
- end
- end
end
end
diff --git a/spec/finders/groups/user_groups_finder_spec.rb b/spec/finders/groups/user_groups_finder_spec.rb
new file mode 100644
index 00000000000..4cce3ab72eb
--- /dev/null
+++ b/spec/finders/groups/user_groups_finder_spec.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Groups::UserGroupsFinder do
+ describe '#execute' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:guest_group) { create(:group, name: 'public guest', path: 'public-guest') }
+ let_it_be(:private_maintainer_group) { create(:group, :private, name: 'b private maintainer', path: 'b-private-maintainer') }
+ let_it_be(:public_developer_group) { create(:group, project_creation_level: nil, name: 'c public developer', path: 'c-public-developer') }
+ let_it_be(:public_maintainer_group) { create(:group, name: 'a public maintainer', path: 'a-public-maintainer') }
+
+ subject { described_class.new(current_user, target_user, arguments).execute }
+
+ let(:arguments) { {} }
+ let(:current_user) { user }
+ let(:target_user) { user }
+
+ before_all do
+ guest_group.add_guest(user)
+ private_maintainer_group.add_maintainer(user)
+ public_developer_group.add_developer(user)
+ public_maintainer_group.add_maintainer(user)
+ end
+
+ it 'returns all groups where the user is a direct member' do
+ is_expected.to match(
+ [
+ public_maintainer_group,
+ private_maintainer_group,
+ public_developer_group,
+ guest_group
+ ]
+ )
+ end
+
+ context 'when target_user is nil' do
+ let(:target_user) { nil }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'when current_user is nil' do
+ let(:current_user) { nil }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'when permission is :create_projects' do
+ let(:arguments) { { permission_scope: :create_projects } }
+
+ specify do
+ is_expected.to match(
+ [
+ public_maintainer_group,
+ private_maintainer_group,
+ public_developer_group
+ ]
+ )
+ end
+
+ context 'when paginatable_namespace_drop_down_for_project_creation feature flag is disabled' do
+ before do
+ stub_feature_flags(paginatable_namespace_drop_down_for_project_creation: false)
+ end
+
+ it 'ignores project creation scope and returns all groups where the user is a direct member' do
+ is_expected.to match(
+ [
+ public_maintainer_group,
+ private_maintainer_group,
+ public_developer_group,
+ guest_group
+ ]
+ )
+ end
+ end
+
+ context 'when search is provided' do
+ let(:arguments) { { permission_scope: :create_projects, search: 'maintainer' } }
+
+ specify do
+ is_expected.to match(
+ [
+ public_maintainer_group,
+ private_maintainer_group
+ ]
+ )
+ end
+ end
+ end
+
+ context 'when search is provided' do
+ let(:arguments) { { search: 'maintainer' } }
+
+ specify do
+ is_expected.to match(
+ [
+ public_maintainer_group,
+ private_maintainer_group
+ ]
+ )
+ end
+ end
+ end
+end
diff --git a/spec/finders/issues_finder/params_spec.rb b/spec/finders/issues_finder/params_spec.rb
new file mode 100644
index 00000000000..879ecc364a2
--- /dev/null
+++ b/spec/finders/issues_finder/params_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IssuesFinder::Params do
+ describe '#include_hidden' do
+ subject { described_class.new(params, user, IssuesFinder) }
+
+ context 'when param is not set' do
+ let(:params) { {} }
+
+ context 'with an admin', :enable_admin_mode do
+ let(:user) { create(:user, :admin) }
+
+ it 'returns true' do
+ expect(subject.include_hidden?).to be_truthy
+ end
+ end
+
+ context 'with a regular user' do
+ let(:user) { create(:user) }
+
+ it 'returns false' do
+ expect(subject.include_hidden?).to be_falsey
+ end
+ end
+ end
+
+ context 'when param is set' do
+ let(:params) { { include_hidden: true } }
+
+ context 'with an admin', :enable_admin_mode do
+ let(:user) { create(:user, :admin) }
+
+ it 'returns true' do
+ expect(subject.include_hidden?).to be_truthy
+ end
+ end
+
+ context 'with a regular user' do
+ let(:user) { create(:user) }
+
+ it 'returns false' do
+ expect(subject.include_hidden?).to be_falsey
+ end
+ end
+ end
+ end
+end
diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb
index 0cb73f3da6d..ed35d75720c 100644
--- a/spec/finders/issues_finder_spec.rb
+++ b/spec/finders/issues_finder_spec.rb
@@ -12,8 +12,52 @@ RSpec.describe IssuesFinder do
context 'scope: all' do
let(:scope) { 'all' }
- it 'returns all issues' do
- expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5)
+ context 'include_hidden and public_only params' do
+ let_it_be(:banned_user) { create(:user, :banned) }
+ let_it_be(:hidden_issue) { create(:issue, project: project1, author: banned_user) }
+ let_it_be(:confidential_issue) { create(:issue, project: project1, confidential: true) }
+
+ context 'when user is an admin', :enable_admin_mode do
+ let(:user) { create(:user, :admin) }
+
+ it 'returns all issues' do
+ expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5, hidden_issue, confidential_issue)
+ end
+ end
+
+ context 'when user is not an admin' do
+ context 'when public_only is true' do
+ let(:params) { { public_only: true } }
+
+ it 'returns public issues' do
+ expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5)
+ end
+ end
+
+ context 'when public_only is false' do
+ let(:params) { { public_only: false } }
+
+ it 'returns public and confidential issues' do
+ expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5, confidential_issue)
+ end
+ end
+
+ context 'when public_only is not set' do
+ it 'returns public and confidential issue' do
+ expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5, confidential_issue)
+ end
+ end
+
+ context 'when ban_user_feature_flag is false' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it 'returns all issues' do
+ expect(issues).to contain_exactly(issue1, issue2, issue3, issue4, issue5, hidden_issue, confidential_issue)
+ end
+ end
+ end
end
context 'user does not have read permissions' do
@@ -426,139 +470,121 @@ RSpec.describe IssuesFinder do
end
end
- shared_examples ':label_name parameter' do
- context 'filtering by label' do
- let(:params) { { label_name: label.title } }
+ context 'filtering by label' do
+ let(:params) { { label_name: label.title } }
- it 'returns issues with that label' do
- expect(issues).to contain_exactly(issue2)
- end
+ it 'returns issues with that label' do
+ expect(issues).to contain_exactly(issue2)
+ end
- context 'using NOT' do
- let(:params) { { not: { label_name: label.title } } }
+ context 'using NOT' do
+ let(:params) { { not: { label_name: label.title } } }
- it 'returns issues that do not have that label' do
- expect(issues).to contain_exactly(issue1, issue3, issue4, issue5)
- end
+ it 'returns issues that do not have that label' do
+ expect(issues).to contain_exactly(issue1, issue3, issue4, issue5)
+ end
- # IssuableFinder first filters using the outer params (the ones not inside the `not` key.)
- # Afterwards, it applies the `not` params to that resultset. This means that things inside the `not` param
- # do not take precedence over the outer params with the same name.
- context 'shadowing the same outside param' do
- let(:params) { { label_name: label2.title, not: { label_name: label.title } } }
+ # IssuableFinder first filters using the outer params (the ones not inside the `not` key.)
+ # Afterwards, it applies the `not` params to that resultset. This means that things inside the `not` param
+ # do not take precedence over the outer params with the same name.
+ context 'shadowing the same outside param' do
+ let(:params) { { label_name: label2.title, not: { label_name: label.title } } }
- it 'does not take precedence over labels outside NOT' do
- expect(issues).to contain_exactly(issue3)
- end
+ it 'does not take precedence over labels outside NOT' do
+ expect(issues).to contain_exactly(issue3)
end
+ end
- context 'further filtering outside params' do
- let(:params) { { label_name: label2.title, not: { assignee_username: user2.username } } }
+ context 'further filtering outside params' do
+ let(:params) { { label_name: label2.title, not: { assignee_username: user2.username } } }
- it 'further filters on the returned resultset' do
- expect(issues).to be_empty
- end
+ it 'further filters on the returned resultset' do
+ expect(issues).to be_empty
end
end
end
+ end
- context 'filtering by multiple labels' do
- let(:params) { { label_name: [label.title, label2.title].join(',') } }
- let(:label2) { create(:label, project: project2) }
-
- before do
- create(:label_link, label: label2, target: issue2)
- end
+ context 'filtering by multiple labels' do
+ let(:params) { { label_name: [label.title, label2.title].join(',') } }
+ let(:label2) { create(:label, project: project2) }
- it 'returns the unique issues with all those labels' do
- expect(issues).to contain_exactly(issue2)
- end
-
- context 'using NOT' do
- let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } }
+ before do
+ create(:label_link, label: label2, target: issue2)
+ end
- it 'returns issues that do not have any of the labels provided' do
- expect(issues).to contain_exactly(issue1, issue4, issue5)
- end
- end
+ it 'returns the unique issues with all those labels' do
+ expect(issues).to contain_exactly(issue2)
end
- context 'filtering by a label that includes any or none in the title' do
- let(:params) { { label_name: [label.title, label2.title].join(',') } }
- let(:label) { create(:label, title: 'any foo', project: project2) }
- let(:label2) { create(:label, title: 'bar none', project: project2) }
+ context 'using NOT' do
+ let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } }
- before do
- create(:label_link, label: label2, target: issue2)
+ it 'returns issues that do not have any of the labels provided' do
+ expect(issues).to contain_exactly(issue1, issue4, issue5)
end
+ end
+ end
- it 'returns the unique issues with all those labels' do
- expect(issues).to contain_exactly(issue2)
- end
+ context 'filtering by a label that includes any or none in the title' do
+ let(:params) { { label_name: [label.title, label2.title].join(',') } }
+ let(:label) { create(:label, title: 'any foo', project: project2) }
+ let(:label2) { create(:label, title: 'bar none', project: project2) }
- context 'using NOT' do
- let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } }
+ before do
+ create(:label_link, label: label2, target: issue2)
+ end
- it 'returns issues that do not have ANY ONE of the labels provided' do
- expect(issues).to contain_exactly(issue1, issue4, issue5)
- end
- end
+ it 'returns the unique issues with all those labels' do
+ expect(issues).to contain_exactly(issue2)
end
- context 'filtering by no label' do
- let(:params) { { label_name: described_class::Params::FILTER_NONE } }
+ context 'using NOT' do
+ let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } }
- it 'returns issues with no labels' do
+ it 'returns issues that do not have ANY ONE of the labels provided' do
expect(issues).to contain_exactly(issue1, issue4, issue5)
end
end
+ end
- context 'filtering by any label' do
- let(:params) { { label_name: described_class::Params::FILTER_ANY } }
-
- it 'returns issues that have one or more label' do
- create_list(:label_link, 2, label: create(:label, project: project2), target: issue3)
+ context 'filtering by no label' do
+ let(:params) { { label_name: described_class::Params::FILTER_NONE } }
- expect(issues).to contain_exactly(issue2, issue3)
- end
+ it 'returns issues with no labels' do
+ expect(issues).to contain_exactly(issue1, issue4, issue5)
end
+ end
- context 'when the same label exists on project and group levels' do
- let(:issue1) { create(:issue, project: project1) }
- let(:issue2) { create(:issue, project: project1) }
-
- # Skipping validation to reproduce a "real-word" scenario.
- # We still have legacy labels on PRD that have the same title on the group and project levels, example: `bug`
- let(:project_label) { build(:label, title: 'somelabel', project: project1).tap { |r| r.save!(validate: false) } }
- let(:group_label) { create(:group_label, title: 'somelabel', group: project1.group) }
-
- let(:params) { { label_name: 'somelabel' } }
+ context 'filtering by any label' do
+ let(:params) { { label_name: described_class::Params::FILTER_ANY } }
- before do
- create(:label_link, label: group_label, target: issue1)
- create(:label_link, label: project_label, target: issue2)
- end
+ it 'returns issues that have one or more label' do
+ create_list(:label_link, 2, label: create(:label, project: project2), target: issue3)
- it 'finds both issue records' do
- expect(issues).to contain_exactly(issue1, issue2)
- end
+ expect(issues).to contain_exactly(issue2, issue3)
end
end
- context 'when `optimized_issuable_label_filter` feature flag is off' do
- before do
- stub_feature_flags(optimized_issuable_label_filter: false)
- end
+ context 'when the same label exists on project and group levels' do
+ let(:issue1) { create(:issue, project: project1) }
+ let(:issue2) { create(:issue, project: project1) }
- it_behaves_like ':label_name parameter'
- end
+ # Skipping validation to reproduce a "real-word" scenario.
+ # We still have legacy labels on PRD that have the same title on the group and project levels, example: `bug`
+ let(:project_label) { build(:label, title: 'somelabel', project: project1).tap { |r| r.save!(validate: false) } }
+ let(:group_label) { create(:group_label, title: 'somelabel', group: project1.group) }
+
+ let(:params) { { label_name: 'somelabel' } }
- context 'when `optimized_issuable_label_filter` feature flag is on' do
before do
- stub_feature_flags(optimized_issuable_label_filter: true)
+ create(:label_link, label: group_label, target: issue1)
+ create(:label_link, label: project_label, target: issue2)
end
- it_behaves_like ':label_name parameter'
+ it 'finds both issue records' do
+ expect(issues).to contain_exactly(issue1, issue2)
+ end
end
context 'filtering by issue term' do
@@ -567,6 +593,35 @@ RSpec.describe IssuesFinder do
it 'returns issues with title and description match for search term' do
expect(issues).to contain_exactly(issue1, issue2)
end
+
+ context 'with anonymous user' do
+ let_it_be(:public_project) { create(:project, :public, group: subgroup) }
+ let_it_be(:issue6) { create(:issue, project: public_project, title: 'tanuki') }
+ let_it_be(:issue7) { create(:issue, project: public_project, title: 'ikunat') }
+
+ let(:search_user) { nil }
+ let(:params) { { search: 'tanuki' } }
+
+ context 'with disable_anonymous_search feature flag enabled' do
+ before do
+ stub_feature_flags(disable_anonymous_search: true)
+ end
+
+ it 'does not perform search' do
+ expect(issues).to contain_exactly(issue6, issue7)
+ end
+ end
+
+ context 'with disable_anonymous_search feature flag disabled' do
+ before do
+ stub_feature_flags(disable_anonymous_search: false)
+ end
+
+ it 'finds one public issue' do
+ expect(issues).to contain_exactly(issue6)
+ end
+ end
+ end
end
context 'filtering by issue term in title' do
@@ -1001,132 +1056,64 @@ RSpec.describe IssuesFinder do
end
describe '#with_confidentiality_access_check' do
- let(:guest) { create(:user) }
+ let(:user) { create(:user) }
let_it_be(:authorized_user) { create(:user) }
- let_it_be(:banned_user) { create(:user, :banned) }
let_it_be(:project) { create(:project, namespace: authorized_user.namespace) }
let_it_be(:public_issue) { create(:issue, project: project) }
let_it_be(:confidential_issue) { create(:issue, project: project, confidential: true) }
- let_it_be(:hidden_issue) { create(:issue, project: project, author: banned_user) }
- shared_examples 'returns public, does not return hidden or confidential' do
+ shared_examples 'returns public, does not return confidential' do
it 'returns only public issues' do
expect(subject).to include(public_issue)
- expect(subject).not_to include(confidential_issue, hidden_issue)
+ expect(subject).not_to include(confidential_issue)
end
end
- shared_examples 'returns public and confidential, does not return hidden' do
- it 'returns only public and confidential issues' do
+ shared_examples 'returns public and confidential' do
+ it 'returns public and confidential issues' do
expect(subject).to include(public_issue, confidential_issue)
- expect(subject).not_to include(hidden_issue)
- end
- end
-
- shared_examples 'returns public and hidden, does not return confidential' do
- it 'returns only public and hidden issues' do
- expect(subject).to include(public_issue, hidden_issue)
- expect(subject).not_to include(confidential_issue)
end
end
- shared_examples 'returns public, confidential, and hidden' do
- it 'returns all issues' do
- expect(subject).to include(public_issue, confidential_issue, hidden_issue)
- end
- end
+ subject { described_class.new(user, params).with_confidentiality_access_check }
context 'when no project filter is given' do
let(:params) { {} }
context 'for an anonymous user' do
- subject { described_class.new(nil, params).with_confidentiality_access_check }
-
- it_behaves_like 'returns public, does not return hidden or confidential'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public and hidden, does not return confidential'
- end
+ it_behaves_like 'returns public, does not return confidential'
end
context 'for a user without project membership' do
- subject { described_class.new(user, params).with_confidentiality_access_check }
-
- it_behaves_like 'returns public, does not return hidden or confidential'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public and hidden, does not return confidential'
- end
+ it_behaves_like 'returns public, does not return confidential'
end
context 'for a guest user' do
- subject { described_class.new(guest, params).with_confidentiality_access_check }
-
before do
- project.add_guest(guest)
+ project.add_guest(user)
end
- it_behaves_like 'returns public, does not return hidden or confidential'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public and hidden, does not return confidential'
- end
+ it_behaves_like 'returns public, does not return confidential'
end
context 'for a project member with access to view confidential issues' do
- subject { described_class.new(authorized_user, params).with_confidentiality_access_check }
-
- it_behaves_like 'returns public and confidential, does not return hidden'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public, confidential, and hidden'
+ before do
+ project.add_reporter(user)
end
+
+ it_behaves_like 'returns public and confidential'
end
context 'for an admin' do
- let(:admin_user) { create(:user, :admin) }
-
- subject { described_class.new(admin_user, params).with_confidentiality_access_check }
+ let(:user) { create(:user, :admin) }
context 'when admin mode is enabled', :enable_admin_mode do
- it_behaves_like 'returns public, confidential, and hidden'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public, confidential, and hidden'
- end
+ it_behaves_like 'returns public and confidential'
end
context 'when admin mode is disabled' do
- it_behaves_like 'returns public, does not return hidden or confidential'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public and hidden, does not return confidential'
- end
+ it_behaves_like 'returns public, does not return confidential'
end
end
end
@@ -1135,17 +1122,9 @@ RSpec.describe IssuesFinder do
let(:params) { { project_id: project.id } }
context 'for an anonymous user' do
- subject { described_class.new(nil, params).with_confidentiality_access_check }
-
- it_behaves_like 'returns public, does not return hidden or confidential'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
+ let(:user) { nil }
- it_behaves_like 'returns public and hidden, does not return confidential'
- end
+ it_behaves_like 'returns public, does not return confidential'
it 'does not filter by confidentiality' do
expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything)
@@ -1154,17 +1133,7 @@ RSpec.describe IssuesFinder do
end
context 'for a user without project membership' do
- subject { described_class.new(user, params).with_confidentiality_access_check }
-
- it_behaves_like 'returns public, does not return hidden or confidential'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public and hidden, does not return confidential'
- end
+ it_behaves_like 'returns public, does not return confidential'
it 'filters by confidentiality' do
expect(subject.to_sql).to match("issues.confidential")
@@ -1172,21 +1141,11 @@ RSpec.describe IssuesFinder do
end
context 'for a guest user' do
- subject { described_class.new(guest, params).with_confidentiality_access_check }
-
before do
- project.add_guest(guest)
+ project.add_guest(user)
end
- it_behaves_like 'returns public, does not return hidden or confidential'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public and hidden, does not return confidential'
- end
+ it_behaves_like 'returns public, does not return confidential'
it 'filters by confidentiality' do
expect(subject.to_sql).to match("issues.confidential")
@@ -1194,40 +1153,18 @@ RSpec.describe IssuesFinder do
end
context 'for a project member with access to view confidential issues' do
- subject { described_class.new(authorized_user, params).with_confidentiality_access_check }
-
- it_behaves_like 'returns public and confidential, does not return hidden'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public, confidential, and hidden'
+ before do
+ project.add_reporter(user)
end
- it 'does not filter by confidentiality' do
- expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything)
-
- subject
- end
+ it_behaves_like 'returns public and confidential'
end
context 'for an admin' do
- let(:admin_user) { create(:user, :admin) }
-
- subject { described_class.new(admin_user, params).with_confidentiality_access_check }
+ let(:user) { create(:user, :admin) }
context 'when admin mode is enabled', :enable_admin_mode do
- it_behaves_like 'returns public, confidential, and hidden'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public, confidential, and hidden'
- end
+ it_behaves_like 'returns public and confidential'
it 'does not filter by confidentiality' do
expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything)
@@ -1237,19 +1174,7 @@ RSpec.describe IssuesFinder do
end
context 'when admin mode is disabled' do
- it_behaves_like 'returns public, does not return hidden or confidential'
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ban_user_feature_flag: false)
- end
-
- it_behaves_like 'returns public and hidden, does not return confidential'
- end
-
- it 'filters by confidentiality' do
- expect(subject.to_sql).to match("issues.confidential")
- end
+ it_behaves_like 'returns public, does not return confidential'
end
end
end
diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb
index 49b29cefb9b..42197a6b103 100644
--- a/spec/finders/merge_requests_finder_spec.rb
+++ b/spec/finders/merge_requests_finder_spec.rb
@@ -227,56 +227,38 @@ RSpec.describe MergeRequestsFinder do
end
end
- shared_examples ':label_name parameter' do
- describe ':label_name parameter' do
- let(:common_labels) { create_list(:label, 3) }
- let(:distinct_labels) { create_list(:label, 3) }
- let(:merge_requests) do
- common_attrs = {
- source_project: project1, target_project: project1, author: user
- }
- distinct_labels.map do |label|
- labels = [label, *common_labels]
- create(:labeled_merge_request, :closed, labels: labels, **common_attrs)
- end
- end
-
- def find(label_name)
- described_class.new(user, label_name: label_name).execute
- end
-
- it 'accepts a single label' do
- found = find(distinct_labels.first.title)
- common = find(common_labels.first.title)
-
- expect(found).to contain_exactly(merge_requests.first)
- expect(common).to match_array(merge_requests)
- end
-
- it 'accepts an array of labels, all of which must match' do
- all_distinct = find(distinct_labels.pluck(:title))
- all_common = find(common_labels.pluck(:title))
-
- expect(all_distinct).to be_empty
- expect(all_common).to match_array(merge_requests)
+ describe ':label_name parameter' do
+ let(:common_labels) { create_list(:label, 3) }
+ let(:distinct_labels) { create_list(:label, 3) }
+ let(:merge_requests) do
+ common_attrs = {
+ source_project: project1, target_project: project1, author: user
+ }
+ distinct_labels.map do |label|
+ labels = [label, *common_labels]
+ create(:labeled_merge_request, :closed, labels: labels, **common_attrs)
end
end
- end
- context 'when `optimized_issuable_label_filter` feature flag is off' do
- before do
- stub_feature_flags(optimized_issuable_label_filter: false)
+ def find(label_name)
+ described_class.new(user, label_name: label_name).execute
end
- it_behaves_like ':label_name parameter'
- end
+ it 'accepts a single label' do
+ found = find(distinct_labels.first.title)
+ common = find(common_labels.first.title)
- context 'when `optimized_issuable_label_filter` feature flag is on' do
- before do
- stub_feature_flags(optimized_issuable_label_filter: true)
+ expect(found).to contain_exactly(merge_requests.first)
+ expect(common).to match_array(merge_requests)
end
- it_behaves_like ':label_name parameter'
+ it 'accepts an array of labels, all of which must match' do
+ all_distinct = find(distinct_labels.pluck(:title))
+ all_common = find(common_labels.pluck(:title))
+
+ expect(all_distinct).to be_empty
+ expect(all_common).to match_array(merge_requests)
+ end
end
it 'filters by source project id' do
@@ -729,6 +711,36 @@ RSpec.describe MergeRequestsFinder do
merge_requests = described_class.new(user, params).execute
expect { merge_requests.load }.not_to raise_error
end
+
+ context 'filtering by search text' do
+ let!(:merge_request6) { create(:merge_request, source_project: project1, target_project: project1, source_branch: 'tanuki-branch', title: 'tanuki') }
+
+ let(:params) { { project_id: project1.id, search: 'tanuki' } }
+
+ context 'with anonymous user' do
+ let(:merge_requests) { described_class.new(nil, params).execute }
+
+ context 'with disable_anonymous_search feature flag enabled' do
+ before do
+ stub_feature_flags(disable_anonymous_search: true)
+ end
+
+ it 'does not perform search' do
+ expect(merge_requests).to contain_exactly(merge_request1, merge_request2, merge_request6)
+ end
+ end
+
+ context 'with disable_anonymous_search feature flag disabled' do
+ before do
+ stub_feature_flags(disable_anonymous_search: false)
+ end
+
+ it 'returns matching merge requests' do
+ expect(merge_requests).to contain_exactly(merge_request6)
+ end
+ end
+ end
+ end
end
describe '#row_count', :request_store do
diff --git a/spec/finders/packages/helm/package_files_finder_spec.rb b/spec/finders/packages/helm/package_files_finder_spec.rb
index 2b84fd2b2d2..5f1378f837d 100644
--- a/spec/finders/packages/helm/package_files_finder_spec.rb
+++ b/spec/finders/packages/helm/package_files_finder_spec.rb
@@ -6,42 +6,51 @@ RSpec.describe ::Packages::Helm::PackageFilesFinder do
let_it_be(:project1) { create(:project) }
let_it_be(:project2) { create(:project) }
let_it_be(:helm_package) { create(:helm_package, project: project1) }
- let_it_be(:helm_package_file) { helm_package.package_files.first }
+ let_it_be(:helm_package_file1) { helm_package.package_files.first }
+ let_it_be(:helm_package_file2) { create(:helm_package_file, package: helm_package) }
let_it_be(:debian_package) { create(:debian_package, project: project1) }
- describe '#execute' do
- let(:project) { project1 }
- let(:channel) { 'stable' }
- let(:params) { {} }
+ let(:project) { project1 }
+ let(:channel) { 'stable' }
+ let(:params) { {} }
+
+ let(:service) { described_class.new(project, channel, params) }
- subject { described_class.new(project, channel, params).execute }
+ describe '#execute' do
+ subject { service.execute }
context 'with empty params' do
- it { is_expected.to match_array([helm_package_file]) }
+ it { is_expected.to eq([helm_package_file2, helm_package_file1]) }
end
context 'with another project' do
let(:project) { project2 }
- it { is_expected.to match_array([]) }
+ it { is_expected.to eq([]) }
end
context 'with another channel' do
let(:channel) { 'staging' }
- it { is_expected.to match_array([]) }
+ it { is_expected.to eq([]) }
end
- context 'with file_name' do
- let(:params) { { file_name: helm_package_file.file_name } }
+ context 'with matching file_name' do
+ let(:params) { { file_name: helm_package_file1.file_name } }
- it { is_expected.to match_array([helm_package_file]) }
+ it { is_expected.to eq([helm_package_file2, helm_package_file1]) }
end
context 'with another file_name' do
let(:params) { { file_name: 'foobar.tgz' } }
- it { is_expected.to match_array([]) }
+ it { is_expected.to eq([]) }
end
end
+
+ describe '#most_recent!' do
+ subject { service.most_recent! }
+
+ it { is_expected.to eq(helm_package_file2) }
+ end
end
diff --git a/spec/finders/packages/helm/packages_finder_spec.rb b/spec/finders/packages/helm/packages_finder_spec.rb
new file mode 100644
index 00000000000..5037a9e6205
--- /dev/null
+++ b/spec/finders/packages/helm/packages_finder_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Packages::Helm::PackagesFinder do
+ let_it_be(:project1) { create(:project) }
+ let_it_be(:project2) { create(:project) }
+ let_it_be(:helm_package) { create(:helm_package, project: project1) }
+ let_it_be(:npm_package) { create(:npm_package, project: project1) }
+ let_it_be(:npm_package) { create(:npm_package, project: project2) }
+
+ let(:project) { project1 }
+ let(:channel) { 'stable' }
+ let(:finder) { described_class.new(project, channel) }
+
+ describe '#execute' do
+ subject { finder.execute }
+
+ context 'with project' do
+ context 'with channel' do
+ it { is_expected.to eq([helm_package]) }
+
+ context 'ignores duplicate package files' do
+ let_it_be(:package_file1) { create(:helm_package_file, package: helm_package) }
+ let_it_be(:package_file2) { create(:helm_package_file, package: helm_package) }
+
+ it { is_expected.to eq([helm_package]) }
+
+ context 'let clients use select id' do
+ subject { finder.execute.pluck_primary_key }
+
+ it { is_expected.to eq([helm_package.id]) }
+ end
+ end
+ end
+
+ context 'with not existing channel' do
+ let(:channel) { 'alpha' }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'with no channel' do
+ let(:channel) { nil }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'with no helm packages' do
+ let(:project) { project2 }
+
+ it { is_expected.to be_empty }
+ end
+ end
+
+ context 'with no project' do
+ let(:project) { nil }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'when the limit is hit' do
+ let_it_be(:helm_package2) { create(:helm_package, project: project1) }
+ let_it_be(:helm_package3) { create(:helm_package, project: project1) }
+ let_it_be(:helm_package4) { create(:helm_package, project: project1) }
+
+ before do
+ stub_const("#{described_class}::MAX_PACKAGES_COUNT", 2)
+ end
+
+ it { is_expected.to eq([helm_package4, helm_package3]) }
+ end
+ end
+end
diff --git a/spec/finders/packages/npm/package_finder_spec.rb b/spec/finders/packages/npm/package_finder_spec.rb
index a995f3b96c4..230d267e508 100644
--- a/spec/finders/packages/npm/package_finder_spec.rb
+++ b/spec/finders/packages/npm/package_finder_spec.rb
@@ -7,6 +7,7 @@ RSpec.describe ::Packages::Npm::PackageFinder do
let(:project) { package.project }
let(:package_name) { package.name }
+ let(:last_of_each_version) { true }
shared_examples 'accepting a namespace for' do |example_name|
before do
@@ -38,6 +39,8 @@ RSpec.describe ::Packages::Npm::PackageFinder do
end
describe '#execute' do
+ subject { finder.execute }
+
shared_examples 'finding packages by name' do
it { is_expected.to eq([package]) }
@@ -56,13 +59,27 @@ RSpec.describe ::Packages::Npm::PackageFinder do
end
end
- subject { finder.execute }
+ shared_examples 'handling last_of_each_version' do
+ include_context 'last_of_each_version setup context'
+
+ context 'disabled' do
+ let(:last_of_each_version) { false }
+
+ it { is_expected.to contain_exactly(package1, package2) }
+ end
+
+ context 'enabled' do
+ it { is_expected.to contain_exactly(package2) }
+ end
+ end
context 'with a project' do
- let(:finder) { described_class.new(package_name, project: project) }
+ let(:finder) { described_class.new(package_name, project: project, last_of_each_version: last_of_each_version) }
it_behaves_like 'finding packages by name'
+ it_behaves_like 'handling last_of_each_version'
+
context 'set to nil' do
let(:project) { nil }
@@ -71,10 +88,12 @@ RSpec.describe ::Packages::Npm::PackageFinder do
end
context 'with a namespace' do
- let(:finder) { described_class.new(package_name, namespace: namespace) }
+ let(:finder) { described_class.new(package_name, namespace: namespace, last_of_each_version: last_of_each_version) }
it_behaves_like 'accepting a namespace for', 'finding packages by name'
+ it_behaves_like 'accepting a namespace for', 'handling last_of_each_version'
+
context 'set to nil' do
let_it_be(:namespace) { nil }
@@ -98,16 +117,28 @@ RSpec.describe ::Packages::Npm::PackageFinder do
end
end
+ shared_examples 'handling last_of_each_version' do
+ include_context 'last_of_each_version setup context'
+
+ context 'enabled' do
+ it { is_expected.to eq(package2) }
+ end
+ end
+
context 'with a project' do
- let(:finder) { described_class.new(package_name, project: project) }
+ let(:finder) { described_class.new(package_name, project: project, last_of_each_version: last_of_each_version) }
it_behaves_like 'finding packages by version'
+
+ it_behaves_like 'handling last_of_each_version'
end
context 'with a namespace' do
- let(:finder) { described_class.new(package_name, namespace: namespace) }
+ let(:finder) { described_class.new(package_name, namespace: namespace, last_of_each_version: last_of_each_version) }
it_behaves_like 'accepting a namespace for', 'finding packages by version'
+
+ it_behaves_like 'accepting a namespace for', 'handling last_of_each_version'
end
end
@@ -118,10 +149,26 @@ RSpec.describe ::Packages::Npm::PackageFinder do
it { is_expected.to eq(package) }
end
+ shared_examples 'handling last_of_each_version' do
+ include_context 'last_of_each_version setup context'
+
+ context 'disabled' do
+ let(:last_of_each_version) { false }
+
+ it { is_expected.to eq(package2) }
+ end
+
+ context 'enabled' do
+ it { is_expected.to eq(package2) }
+ end
+ end
+
context 'with a project' do
- let(:finder) { described_class.new(package_name, project: project) }
+ let(:finder) { described_class.new(package_name, project: project, last_of_each_version: last_of_each_version) }
it_behaves_like 'finding package by last'
+
+ it_behaves_like 'handling last_of_each_version'
end
context 'with a namespace' do
@@ -129,6 +176,8 @@ RSpec.describe ::Packages::Npm::PackageFinder do
it_behaves_like 'accepting a namespace for', 'finding package by last'
+ it_behaves_like 'accepting a namespace for', 'handling last_of_each_version'
+
context 'with duplicate packages' do
let_it_be(:namespace) { create(:group) }
let_it_be(:subgroup1) { create(:group, parent: namespace) }
diff --git a/spec/finders/projects_finder_spec.rb b/spec/finders/projects_finder_spec.rb
index 21b5b2f6130..d26180bbf94 100644
--- a/spec/finders/projects_finder_spec.rb
+++ b/spec/finders/projects_finder_spec.rb
@@ -135,6 +135,7 @@ RSpec.describe ProjectsFinder do
describe 'filter by tags (deprecated)' do
before do
+ public_project.reload
public_project.topic_list = 'foo'
public_project.save!
end
@@ -146,6 +147,7 @@ RSpec.describe ProjectsFinder do
describe 'filter by topics' do
before do
+ public_project.reload
public_project.topic_list = 'foo, bar'
public_project.save!
end
@@ -188,6 +190,32 @@ RSpec.describe ProjectsFinder do
it { is_expected.to eq([public_project]) }
end
+ context 'with anonymous user' do
+ let(:public_project_2) { create(:project, :public, group: group, name: 'E', path: 'E') }
+ let(:current_user) { nil }
+ let(:params) { { search: 'C' } }
+
+ context 'with disable_anonymous_project_search feature flag enabled' do
+ before do
+ stub_feature_flags(disable_anonymous_project_search: true)
+ end
+
+ it 'does not perform search' do
+ is_expected.to eq([public_project_2, public_project])
+ end
+ end
+
+ context 'with disable_anonymous_project_search feature flag disabled' do
+ before do
+ stub_feature_flags(disable_anonymous_project_search: false)
+ end
+
+ it 'finds one public project' do
+ is_expected.to eq([public_project])
+ end
+ end
+ end
+
describe 'filter by name for backward compatibility' do
let(:params) { { name: 'C' } }
diff --git a/spec/finders/repositories/tree_finder_spec.rb b/spec/finders/repositories/tree_finder_spec.rb
new file mode 100644
index 00000000000..0d70d5f92d3
--- /dev/null
+++ b/spec/finders/repositories/tree_finder_spec.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe Repositories::TreeFinder do
+ include RepoHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :repository, creator: user) }
+
+ let(:repository) { project.repository }
+ let(:tree_finder) { described_class.new(project, params) }
+ let(:params) { {} }
+ let(:first_page_ids) { tree_finder.execute.map(&:id) }
+ let(:second_page_token) { first_page_ids.last }
+
+ describe "#execute" do
+ subject { tree_finder.execute(gitaly_pagination: true) }
+
+ it "returns an array" do
+ is_expected.to be_an(Array)
+ end
+
+ it "includes 20 items by default" do
+ expect(subject.size).to eq(20)
+ end
+
+ it "accepts a gitaly_pagination argument" do
+ expect(repository).to receive(:tree).with(anything, anything, recursive: nil, pagination_params: { limit: 20, page_token: nil }).and_call_original
+ expect(tree_finder.execute(gitaly_pagination: true)).to be_an(Array)
+
+ expect(repository).to receive(:tree).with(anything, anything, recursive: nil).and_call_original
+ expect(tree_finder.execute(gitaly_pagination: false)).to be_an(Array)
+ end
+
+ context "commit doesn't exist" do
+ let(:params) do
+ { ref: "nonesuchref" }
+ end
+
+ it "raises an error" do
+ expect { subject }.to raise_error(described_class::CommitMissingError)
+ end
+ end
+
+ describe "pagination_params" do
+ let(:params) do
+ { per_page: 5, page_token: nil }
+ end
+
+ it "has the per_page number of items" do
+ expect(subject.size).to eq(5)
+ end
+
+ it "doesn't include any of the first page records" do
+ first_page_ids = subject.map(&:id)
+ second_page = described_class.new(project, { per_page: 5, page_token: first_page_ids.last }).execute(gitaly_pagination: true)
+
+ expect(second_page.map(&:id)).not_to include(*first_page_ids)
+ end
+ end
+ end
+
+ describe "#total", :use_clean_rails_memory_store_caching do
+ subject { tree_finder.total }
+
+ it { is_expected.to be_an(Integer) }
+
+ it "only calculates the total once" do
+ expect(repository).to receive(:tree).once.and_call_original
+
+ 2.times { tree_finder.total }
+ end
+ end
+
+ describe "#commit_exists?" do
+ subject { tree_finder.commit_exists? }
+
+ context "ref exists" do
+ let(:params) do
+ { ref: project.default_branch }
+ end
+
+ it { is_expected.to be(true) }
+ end
+
+ context "ref is missing" do
+ let(:params) do
+ { ref: "nonesuchref" }
+ end
+
+ it { is_expected.to be(false) }
+ end
+ end
+end
diff --git a/spec/fixtures/api/schemas/feature_flag.json b/spec/fixtures/api/schemas/feature_flag.json
index 45b704e4b84..47f86a9f92b 100644
--- a/spec/fixtures/api/schemas/feature_flag.json
+++ b/spec/fixtures/api/schemas/feature_flag.json
@@ -1,10 +1,7 @@
{
"type": "object",
- "required" : [
- "id",
- "name"
- ],
- "properties" : {
+ "required": ["id", "name"],
+ "properties": {
"id": { "type": "integer" },
"iid": { "type": ["integer", "null"] },
"version": { "type": "string" },
diff --git a/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric.yml b/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric.yml
index 8495d983d10..16ca71f24ae 100644
--- a/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric.yml
+++ b/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric.yml
@@ -7,7 +7,7 @@ product_stage:
product_group:
product_category:
value_type: number
-status: implemented
+status: active
milestone: "13.9"
introduced_by_url:
time_frame: 7d
diff --git a/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_ee.yml b/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_ee.yml
index 82e9af5b04f..060ab7baccf 100644
--- a/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_ee.yml
+++ b/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_ee.yml
@@ -7,7 +7,7 @@ product_stage:
product_group:
product_category:
value_type: number
-status: implemented
+status: active
milestone: "13.9"
introduced_by_url:
time_frame: 7d
diff --git a/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_name_suggestions.yml b/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_name_suggestions.yml
index aad7dc76290..e373d6a9e45 100644
--- a/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_name_suggestions.yml
+++ b/spec/fixtures/lib/generators/gitlab/usage_metric_definition_generator/sample_metric_with_name_suggestions.yml
@@ -8,7 +8,7 @@ product_stage:
product_group:
product_category:
value_type: number
-status: implemented
+status: active
milestone: "13.9"
introduced_by_url:
time_frame: 7d
diff --git a/spec/frontend/__helpers__/emoji.js b/spec/frontend/__helpers__/emoji.js
index 9f9134f6f63..a64135601ae 100644
--- a/spec/frontend/__helpers__/emoji.js
+++ b/spec/frontend/__helpers__/emoji.js
@@ -49,6 +49,11 @@ export const emojiFixtureMap = {
unicodeVersion: '5.1',
description: 'white medium star',
},
+ xss: {
+ moji: '<img src=x onerror=prompt(1)>',
+ unicodeVersion: '5.1',
+ description: 'xss',
+ },
};
export const mockEmojiData = Object.keys(emojiFixtureMap).reduce((acc, k) => {
diff --git a/spec/frontend/__helpers__/local_storage_helper.js b/spec/frontend/__helpers__/local_storage_helper.js
index 21749fd8070..cf75b0b53fe 100644
--- a/spec/frontend/__helpers__/local_storage_helper.js
+++ b/spec/frontend/__helpers__/local_storage_helper.js
@@ -2,9 +2,7 @@
* Manage the instance of a custom `window.localStorage`
*
* This only encapsulates the setup / teardown logic so that it can easily be
- * reused with different implementations (i.e. a spy or a [fake][1])
- *
- * [1]: https://stackoverflow.com/a/41434763/1708147
+ * reused with different implementations (i.e. a spy or a fake)
*
* @param {() => any} fn Function that returns the object to use for localStorage
*/
diff --git a/spec/frontend/__helpers__/mock_window_location_helper.js b/spec/frontend/__helpers__/mock_window_location_helper.js
index 3755778e5c1..14082857053 100644
--- a/spec/frontend/__helpers__/mock_window_location_helper.js
+++ b/spec/frontend/__helpers__/mock_window_location_helper.js
@@ -2,9 +2,7 @@
* Manage the instance of a custom `window.location`
*
* This only encapsulates the setup / teardown logic so that it can easily be
- * reused with different implementations (i.e. a spy or a [fake][1])
- *
- * [1]: https://stackoverflow.com/a/41434763/1708147
+ * reused with different implementations (i.e. a spy or a fake)
*
* @param {() => any} fn Function that returns the object to use for window.location
*/
diff --git a/spec/frontend/__helpers__/test_apollo_link.js b/spec/frontend/__helpers__/test_apollo_link.js
new file mode 100644
index 00000000000..dde3a4e99bb
--- /dev/null
+++ b/spec/frontend/__helpers__/test_apollo_link.js
@@ -0,0 +1,46 @@
+import { InMemoryCache } from 'apollo-cache-inmemory';
+import { ApolloClient } from 'apollo-client';
+import { ApolloLink } from 'apollo-link';
+import gql from 'graphql-tag';
+
+const FOO_QUERY = gql`
+ query {
+ foo
+ }
+`;
+
+/**
+ * This function returns a promise that resolves to the final operation after
+ * running an ApolloClient query with the given ApolloLink
+ *
+ * @typedef {Object} TestApolloLinkOptions
+ * @property {Object} context the default context object sent along the ApolloLink chain
+ *
+ * @param {ApolloLink} subjectLink the ApolloLink which is under test
+ * @param {TestApolloLinkOptions} options contains options to send a long with the query
+ *
+ * @returns Promise resolving to the resulting operation after running the subjectLink
+ */
+export const testApolloLink = (subjectLink, options = {}) =>
+ new Promise((resolve) => {
+ const { context = {} } = options;
+
+ // Use the terminating link to capture the final operation and resolve with this.
+ const terminatingLink = new ApolloLink((operation) => {
+ resolve(operation);
+
+ return null;
+ });
+
+ const client = new ApolloClient({
+ link: ApolloLink.from([subjectLink, terminatingLink]),
+ // cache is a required option
+ cache: new InMemoryCache(),
+ });
+
+ // Trigger a query so the ApolloLink chain will be executed.
+ client.query({
+ context,
+ query: FOO_QUERY,
+ });
+ });
diff --git a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap
index 3a374084dbc..ddb188edb10 100644
--- a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap
+++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap
@@ -51,10 +51,14 @@ exports[`Alert integration settings form default state should match the default
<gl-dropdown-stub
block="true"
category="primary"
+ clearalltext="Clear all"
data-qa-selector="incident_templates_dropdown"
headertext=""
hideheaderborder="true"
+ highlighteditemstitle="Selected"
+ highlighteditemstitleclass="gl-px-5"
id="alert-integration-settings-issue-template"
+ showhighlighteditemstitle="true"
size="medium"
text="selecte_tmpl"
variant="default"
diff --git a/spec/frontend/api/projects_api_spec.js b/spec/frontend/api/projects_api_spec.js
new file mode 100644
index 00000000000..8f40b557e1f
--- /dev/null
+++ b/spec/frontend/api/projects_api_spec.js
@@ -0,0 +1,62 @@
+import MockAdapter from 'axios-mock-adapter';
+import * as projectsApi from '~/api/projects_api';
+import axios from '~/lib/utils/axios_utils';
+
+describe('~/api/projects_api.js', () => {
+ let mock;
+ let originalGon;
+
+ const projectId = 1;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+
+ originalGon = window.gon;
+ window.gon = { api_version: 'v7' };
+ });
+
+ afterEach(() => {
+ mock.restore();
+ window.gon = originalGon;
+ });
+
+ describe('getProjects', () => {
+ beforeEach(() => {
+ jest.spyOn(axios, 'get');
+ });
+
+ it('retrieves projects from the correct URL and returns them in the response data', () => {
+ const expectedUrl = '/api/v7/projects.json';
+ const expectedParams = { params: { per_page: 20, search: '', simple: true } };
+ const expectedProjects = [{ name: 'project 1' }];
+ const query = '';
+ const options = {};
+
+ mock.onGet(expectedUrl).reply(200, { data: expectedProjects });
+
+ return projectsApi.getProjects(query, options).then(({ data }) => {
+ expect(axios.get).toHaveBeenCalledWith(expectedUrl, expectedParams);
+ expect(data.data).toEqual(expectedProjects);
+ });
+ });
+ });
+
+ describe('importProjectMembers', () => {
+ beforeEach(() => {
+ jest.spyOn(axios, 'post');
+ });
+
+ it('posts to the correct URL and returns the response message', () => {
+ const targetId = 2;
+ const expectedUrl = '/api/v7/projects/1/import_project_members/2';
+ const expectedMessage = 'Successfully imported';
+
+ mock.onPost(expectedUrl).replyOnce(200, expectedMessage);
+
+ return projectsApi.importProjectMembers(projectId, targetId).then(({ data }) => {
+ expect(axios.post).toHaveBeenCalledWith(expectedUrl);
+ expect(data).toEqual(expectedMessage);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js b/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js
index b77def195b6..2dcc537809f 100644
--- a/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js
+++ b/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js
@@ -78,7 +78,7 @@ describe('RecoveryCodes', () => {
it('fires Snowplow event', () => {
expect(findProceedButton().attributes()).toMatchObject({
- 'data-track-event': 'click_button',
+ 'data-track-action': 'click_button',
'data-track-label': '2fa_recovery_codes_proceed_button',
});
});
diff --git a/spec/frontend/autosave_spec.js b/spec/frontend/autosave_spec.js
index bbdf3c6f91d..c881e0f9794 100644
--- a/spec/frontend/autosave_spec.js
+++ b/spec/frontend/autosave_spec.js
@@ -15,28 +15,28 @@ describe('Autosave', () => {
describe('class constructor', () => {
beforeEach(() => {
- jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(true);
+ jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true);
jest.spyOn(Autosave.prototype, 'restore').mockImplementation(() => {});
});
it('should set .isLocalStorageAvailable', () => {
autosave = new Autosave(field, key);
- expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
+ expect(AccessorUtilities.canUseLocalStorage).toHaveBeenCalled();
expect(autosave.isLocalStorageAvailable).toBe(true);
});
it('should set .isLocalStorageAvailable if fallbackKey is passed', () => {
autosave = new Autosave(field, key, fallbackKey);
- expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
+ expect(AccessorUtilities.canUseLocalStorage).toHaveBeenCalled();
expect(autosave.isLocalStorageAvailable).toBe(true);
});
it('should set .isLocalStorageAvailable if lockVersion is passed', () => {
autosave = new Autosave(field, key, null, lockVersion);
- expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
+ expect(AccessorUtilities.canUseLocalStorage).toHaveBeenCalled();
expect(autosave.isLocalStorageAvailable).toBe(true);
});
});
diff --git a/spec/frontend/batch_comments/components/review_bar_spec.js b/spec/frontend/batch_comments/components/review_bar_spec.js
new file mode 100644
index 00000000000..f50db6ab210
--- /dev/null
+++ b/spec/frontend/batch_comments/components/review_bar_spec.js
@@ -0,0 +1,42 @@
+import { shallowMount } from '@vue/test-utils';
+import ReviewBar from '~/batch_comments/components/review_bar.vue';
+import { REVIEW_BAR_VISIBLE_CLASS_NAME } from '~/batch_comments/constants';
+import createStore from '../create_batch_comments_store';
+
+describe('Batch comments review bar component', () => {
+ let store;
+ let wrapper;
+
+ const createComponent = (propsData = {}) => {
+ store = createStore();
+
+ wrapper = shallowMount(ReviewBar, {
+ store,
+ propsData,
+ });
+ };
+
+ beforeEach(() => {
+ document.body.className = '';
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('it adds review-bar-visible class to body when review bar is mounted', async () => {
+ expect(document.body.classList.contains(REVIEW_BAR_VISIBLE_CLASS_NAME)).toBe(false);
+
+ createComponent();
+
+ expect(document.body.classList.contains(REVIEW_BAR_VISIBLE_CLASS_NAME)).toBe(true);
+ });
+
+ it('it removes review-bar-visible class to body when review bar is destroyed', async () => {
+ createComponent();
+
+ wrapper.destroy();
+
+ expect(document.body.classList.contains(REVIEW_BAR_VISIBLE_CLASS_NAME)).toBe(false);
+ });
+});
diff --git a/spec/frontend/batch_comments/create_batch_comments_store.js b/spec/frontend/batch_comments/create_batch_comments_store.js
new file mode 100644
index 00000000000..10dc6fe196e
--- /dev/null
+++ b/spec/frontend/batch_comments/create_batch_comments_store.js
@@ -0,0 +1,15 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import batchCommentsModule from '~/batch_comments/stores/modules/batch_comments';
+import notesModule from '~/notes/stores/modules';
+
+Vue.use(Vuex);
+
+export default function createDiffsStore() {
+ return new Vuex.Store({
+ modules: {
+ notes: notesModule(),
+ batchComments: batchCommentsModule(),
+ },
+ });
+}
diff --git a/spec/frontend/blob/notebook/notebook_viever_spec.js b/spec/frontend/blob/notebook/notebook_viever_spec.js
index 604104bb31f..93406db2675 100644
--- a/spec/frontend/blob/notebook/notebook_viever_spec.js
+++ b/spec/frontend/blob/notebook/notebook_viever_spec.js
@@ -11,6 +11,7 @@ describe('iPython notebook renderer', () => {
let mock;
const endpoint = 'test';
+ const relativeRawPath = '';
const mockNotebook = {
cells: [
{
@@ -27,7 +28,7 @@ describe('iPython notebook renderer', () => {
};
const mountComponent = () => {
- wrapper = shallowMount(component, { propsData: { endpoint } });
+ wrapper = shallowMount(component, { propsData: { endpoint, relativeRawPath } });
};
const findLoading = () => wrapper.find(GlLoadingIcon);
diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js
index 7d3ecc773a6..e0446811f64 100644
--- a/spec/frontend/boards/board_card_inner_spec.js
+++ b/spec/frontend/boards/board_card_inner_spec.js
@@ -2,6 +2,7 @@ import { GlLabel, GlLoadingIcon, GlTooltip } from '@gitlab/ui';
import { range } from 'lodash';
import Vuex from 'vuex';
import setWindowLocation from 'helpers/set_window_location_helper';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import BoardBlockedIcon from '~/boards/components/board_blocked_icon.vue';
import BoardCardInner from '~/boards/components/board_card_inner.vue';
@@ -44,6 +45,7 @@ describe('Board card component', () => {
const findEpicBadgeProgress = () => wrapper.findByTestId('epic-progress');
const findEpicCountablesTotalWeight = () => wrapper.findByTestId('epic-countables-total-weight');
const findEpicProgressTooltip = () => wrapper.findByTestId('epic-progress-tooltip-content');
+ const findHiddenIssueIcon = () => wrapper.findByTestId('hidden-icon');
const createStore = ({ isEpicBoard = false, isProjectBoard = false } = {}) => {
store = new Vuex.Store({
@@ -72,6 +74,9 @@ describe('Board card component', () => {
GlLabel: true,
GlLoadingIcon: true,
},
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
mocks: {
$apollo: {
queries: {
@@ -122,6 +127,10 @@ describe('Board card component', () => {
expect(wrapper.find('.confidential-icon').exists()).toBe(false);
});
+ it('does not render hidden issue icon', () => {
+ expect(findHiddenIssueIcon().exists()).toBe(false);
+ });
+
it('renders issue ID with #', () => {
expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.iid}`);
});
@@ -184,6 +193,30 @@ describe('Board card component', () => {
});
});
+ describe('hidden issue', () => {
+ beforeEach(() => {
+ wrapper.setProps({
+ item: {
+ ...wrapper.props('item'),
+ hidden: true,
+ },
+ });
+ });
+
+ it('renders hidden issue icon', () => {
+ expect(findHiddenIssueIcon().exists()).toBe(true);
+ });
+
+ it('displays a tooltip which explains the meaning of the icon', () => {
+ const tooltip = getBinding(findHiddenIssueIcon().element, 'gl-tooltip');
+
+ expect(tooltip).toBeDefined();
+ expect(findHiddenIssueIcon().attributes('title')).toBe(
+ 'This issue is hidden because its author has been banned',
+ );
+ });
+ });
+
describe('with assignee', () => {
describe('with avatar', () => {
beforeEach(() => {
diff --git a/spec/frontend/boards/board_list_deprecated_spec.js b/spec/frontend/boards/board_list_deprecated_spec.js
deleted file mode 100644
index b71564f7858..00000000000
--- a/spec/frontend/boards/board_list_deprecated_spec.js
+++ /dev/null
@@ -1,274 +0,0 @@
-/* global List */
-/* global ListIssue */
-import MockAdapter from 'axios-mock-adapter';
-import Vue from 'vue';
-import waitForPromises from 'helpers/wait_for_promises';
-import BoardList from '~/boards/components/board_list_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 '~/boards/models/issue';
-import '~/boards/models/list';
-import { listObj, boardsMockInterceptor } from './mock_data';
-
-const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listProps = {} }) => {
- const el = document.createElement('div');
-
- document.body.appendChild(el);
- const mock = new MockAdapter(axios);
- mock.onAny().reply(boardsMockInterceptor);
- boardsStore.create();
-
- const BoardListComp = Vue.extend(BoardList);
- const list = new List({ ...listObj, ...listProps });
- const issue = new ListIssue({
- title: 'Testing',
- id: 1,
- iid: 1,
- confidential: false,
- labels: [],
- assignees: [],
- ...listIssueProps,
- });
- if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) {
- list.issuesSize = 1;
- }
- list.issues.push(issue);
-
- const component = new BoardListComp({
- el,
- store,
- propsData: {
- disabled: false,
- list,
- issues: list.issues,
- ...componentProps,
- },
- provide: {
- groupId: null,
- rootPath: '/',
- },
- }).$mount();
-
- Vue.nextTick(() => {
- done();
- });
-
- return { component, mock };
-};
-
-describe('Board list component', () => {
- let mock;
- let component;
- let getIssues;
- function generateIssues(compWrapper) {
- for (let i = 1; i < 20; i += 1) {
- const issue = { ...compWrapper.list.issues[0] };
- issue.id += i;
- compWrapper.list.issues.push(issue);
- }
- }
-
- describe('When Expanded', () => {
- beforeEach((done) => {
- getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {}));
- ({ mock, component } = createComponent({ done }));
- });
-
- afterEach(() => {
- mock.restore();
- component.$destroy();
- });
-
- it('loads first page of issues', () => {
- return waitForPromises().then(() => {
- expect(getIssues).toHaveBeenCalled();
- });
- });
-
- it('renders component', () => {
- expect(component.$el.classList.contains('board-list-component')).toBe(true);
- });
-
- it('renders loading icon', () => {
- component.list.loading = true;
-
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.board-list-loading')).not.toBeNull();
- });
- });
-
- it('renders issues', () => {
- expect(component.$el.querySelectorAll('.board-card').length).toBe(1);
- });
-
- it('sets data attribute with issue id', () => {
- expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1');
- });
-
- it('shows new issue form', () => {
- component.toggleForm();
-
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
-
- expect(component.$el.querySelector('.is-smaller')).not.toBeNull();
- });
- });
-
- it('shows new issue form after eventhub event', () => {
- eventHub.$emit(`toggle-issue-form-${component.list.id}`);
-
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
-
- expect(component.$el.querySelector('.is-smaller')).not.toBeNull();
- });
- });
-
- it('does not show new issue form for closed list', () => {
- component.list.type = 'closed';
- component.toggleForm();
-
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.board-new-issue-form')).toBeNull();
- });
- });
-
- it('shows count list item', () => {
- component.showCount = true;
-
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.board-list-count')).not.toBeNull();
-
- expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
- 'Showing all issues',
- );
- });
- });
-
- it('sets data attribute with invalid id', () => {
- component.showCount = true;
-
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe(
- '-1',
- );
- });
- });
-
- it('shows how many more issues to load', () => {
- component.showCount = true;
- component.list.issuesSize = 20;
-
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
- 'Showing 1 of 20 issues',
- );
- });
- });
-
- it('loads more issues after scrolling', () => {
- jest.spyOn(component.list, 'nextPage').mockImplementation(() => {});
- generateIssues(component);
- component.$refs.list.dispatchEvent(new Event('scroll'));
-
- return waitForPromises().then(() => {
- expect(component.list.nextPage).toHaveBeenCalled();
- });
- });
-
- it('does not load issues if already loading', () => {
- component.list.nextPage = jest
- .spyOn(component.list, 'nextPage')
- .mockReturnValue(new Promise(() => {}));
-
- component.onScroll();
- component.onScroll();
-
- return waitForPromises().then(() => {
- expect(component.list.nextPage).toHaveBeenCalledTimes(1);
- });
- });
-
- it('shows loading more spinner', () => {
- component.showCount = true;
- component.list.loadingMore = true;
-
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull();
- });
- });
- });
-
- describe('When Collapsed', () => {
- beforeEach((done) => {
- getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {}));
- ({ mock, component } = createComponent({
- done,
- listProps: { type: 'closed', collapsed: true, issuesSize: 50 },
- }));
- generateIssues(component);
- component.scrollHeight = jest.spyOn(component, 'scrollHeight').mockReturnValue(0);
- });
-
- afterEach(() => {
- mock.restore();
- component.$destroy();
- });
-
- it('does not load all issues', () => {
- return waitForPromises().then(() => {
- // Initial getIssues from list constructor
- expect(getIssues).toHaveBeenCalledTimes(1);
- });
- });
- });
-
- describe('max issue count warning', () => {
- beforeEach((done) => {
- ({ mock, component } = createComponent({
- done,
- listProps: { type: 'closed', collapsed: true, issuesSize: 50 },
- }));
- });
-
- afterEach(() => {
- mock.restore();
- component.$destroy();
- });
-
- describe('when issue count exceeds max issue count', () => {
- it('sets background to bg-danger-100', () => {
- component.list.issuesSize = 4;
- component.list.maxIssueCount = 3;
-
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.bg-danger-100')).not.toBeNull();
- });
- });
- });
-
- describe('when list issue count does NOT exceed list max issue count', () => {
- it('does not sets background to bg-danger-100', () => {
- component.list.issuesSize = 2;
- component.list.maxIssueCount = 3;
-
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.bg-danger-100')).toBeNull();
- });
- });
- });
-
- describe('when list max issue count is 0', () => {
- it('does not sets background to bg-danger-100', () => {
- component.list.maxIssueCount = 0;
-
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.bg-danger-100')).toBeNull();
- });
- });
- });
- });
-});
diff --git a/spec/frontend/boards/board_new_issue_deprecated_spec.js b/spec/frontend/boards/board_new_issue_deprecated_spec.js
deleted file mode 100644
index 3beaf870bf5..00000000000
--- a/spec/frontend/boards/board_new_issue_deprecated_spec.js
+++ /dev/null
@@ -1,211 +0,0 @@
-/* global List */
-
-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';
-
-import '~/boards/models/list';
-import { listObj, boardsMockInterceptor } from './mock_data';
-
-Vue.use(Vuex);
-
-describe('Issue boards new issue form', () => {
- let wrapper;
- let vm;
- let list;
- let mock;
- let newIssueMock;
- const promiseReturn = {
- data: {
- iid: 100,
- },
- };
-
- const submitIssue = () => {
- const dummySubmitEvent = {
- preventDefault() {},
- };
- wrapper.vm.$refs.submitButton = wrapper.find({ ref: 'submitButton' });
- return wrapper.vm.submit(dummySubmitEvent);
- };
-
- beforeEach(() => {
- const BoardNewIssueComp = Vue.extend(boardNewIssue);
-
- mock = new MockAdapter(axios);
- mock.onAny().reply(boardsMockInterceptor);
-
- boardsStore.create();
-
- list = new List(listObj);
-
- 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,
- },
- });
-
- vm = wrapper.vm;
-
- return Vue.nextTick();
- });
-
- afterEach(() => {
- wrapper.destroy();
- mock.restore();
- });
-
- it('calls submit if submit button is clicked', () => {
- jest.spyOn(wrapper.vm, 'submit').mockImplementation();
- vm.title = 'Testing Title';
-
- return Vue.nextTick()
- .then(submitIssue)
- .then(() => {
- expect(wrapper.vm.submit).toHaveBeenCalled();
- });
- });
-
- it('disables submit button if title is empty', () => {
- expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(true);
- });
-
- it('enables submit button if title is not empty', () => {
- wrapper.setData({ title: 'Testing Title' });
-
- return Vue.nextTick().then(() => {
- expect(wrapper.find({ ref: 'input' }).element.value).toBe('Testing Title');
- expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(false);
- });
- });
-
- it('clears title after clicking cancel', () => {
- wrapper.find({ ref: 'cancelButton' }).trigger('click');
-
- return Vue.nextTick().then(() => {
- expect(vm.title).toBe('');
- });
- });
-
- it('does not create new issue if title is empty', () => {
- return submitIssue().then(() => {
- expect(list.newIssue).not.toHaveBeenCalled();
- });
- });
-
- describe('submit success', () => {
- it('creates new issue', () => {
- wrapper.setData({ title: 'create issue' });
-
- return Vue.nextTick()
- .then(submitIssue)
- .then(() => {
- expect(list.newIssue).toHaveBeenCalled();
- });
- });
-
- it('enables button after submit', () => {
- jest.spyOn(wrapper.vm, 'submit').mockImplementation();
- wrapper.setData({ title: 'create issue' });
-
- return Vue.nextTick()
- .then(submitIssue)
- .then(() => {
- expect(wrapper.vm.$refs.submitButton.props().disabled).toBe(false);
- });
- });
-
- it('clears title after submit', () => {
- wrapper.setData({ title: 'create issue' });
-
- return Vue.nextTick()
- .then(submitIssue)
- .then(() => {
- expect(vm.title).toBe('');
- });
- });
-
- it('sets detail issue after submit', () => {
- expect(boardsStore.detail.issue.title).toBe(undefined);
- wrapper.setData({ title: 'create issue' });
-
- return Vue.nextTick()
- .then(submitIssue)
- .then(() => {
- expect(boardsStore.detail.issue.title).toBe('create issue');
- });
- });
-
- it('sets detail list after submit', () => {
- wrapper.setData({ title: 'create issue' });
-
- return Vue.nextTick()
- .then(submitIssue)
- .then(() => {
- expect(boardsStore.detail.list.id).toBe(list.id);
- });
- });
-
- it('sets detail weight after submit', () => {
- boardsStore.weightFeatureAvailable = true;
- wrapper.setData({ title: 'create issue' });
-
- return Vue.nextTick()
- .then(submitIssue)
- .then(() => {
- expect(boardsStore.detail.list.weight).toBe(list.weight);
- });
- });
-
- it('does not set detail weight after submit', () => {
- boardsStore.weightFeatureAvailable = false;
- wrapper.setData({ title: 'create issue' });
-
- return Vue.nextTick()
- .then(submitIssue)
- .then(() => {
- expect(boardsStore.detail.list.weight).toBe(list.weight);
- });
- });
- });
-
- describe('submit error', () => {
- beforeEach(() => {
- newIssueMock = Promise.reject(new Error('My hovercraft is full of eels!'));
- vm.title = 'error';
- });
-
- it('removes issue', () => {
- const lengthBefore = list.issues.length;
- return Vue.nextTick()
- .then(submitIssue)
- .then(() => {
- expect(list.issues.length).toBe(lengthBefore);
- });
- });
-
- it('shows error', () => {
- return Vue.nextTick()
- .then(submitIssue)
- .then(() => {
- expect(vm.error).toBe(true);
- });
- });
- });
-});
diff --git a/spec/frontend/boards/boards_store_spec.js b/spec/frontend/boards/boards_store_spec.js
deleted file mode 100644
index 02881333273..00000000000
--- a/spec/frontend/boards/boards_store_spec.js
+++ /dev/null
@@ -1,1013 +0,0 @@
-import AxiosMockAdapter from 'axios-mock-adapter';
-import { TEST_HOST } from 'helpers/test_constants';
-import eventHub from '~/boards/eventhub';
-
-import ListIssue from '~/boards/models/issue';
-import List from '~/boards/models/list';
-import boardsStore from '~/boards/stores/boards_store';
-import axios from '~/lib/utils/axios_utils';
-import { listObj, listObjDuplicate } from './mock_data';
-
-jest.mock('js-cookie');
-
-const createTestIssue = () => ({
- title: 'Testing',
- id: 1,
- iid: 1,
- confidential: false,
- labels: [],
- assignees: [],
-});
-
-describe('boardsStore', () => {
- const dummyResponse = "without type checking this doesn't matter";
- const boardId = 'dummy-board-id';
- const endpoints = {
- boardsEndpoint: `${TEST_HOST}/boards`,
- listsEndpoint: `${TEST_HOST}/lists`,
- bulkUpdatePath: `${TEST_HOST}/bulk/update`,
- recentBoardsEndpoint: `${TEST_HOST}/recent/boards`,
- };
-
- let axiosMock;
-
- beforeEach(() => {
- axiosMock = new AxiosMockAdapter(axios);
- boardsStore.setEndpoints({
- ...endpoints,
- boardId,
- });
- });
-
- afterEach(() => {
- axiosMock.restore();
- });
-
- const setupDefaultResponses = () => {
- axiosMock
- .onGet(`${endpoints.listsEndpoint}/${listObj.id}/issues?id=${listObj.id}&page=1`)
- .reply(200, { issues: [createTestIssue()] });
- axiosMock.onPost(endpoints.listsEndpoint).reply(200, listObj);
- axiosMock.onPut();
- };
-
- describe('all', () => {
- it('makes a request to fetch lists', () => {
- axiosMock.onGet(endpoints.listsEndpoint).replyOnce(200, dummyResponse);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
-
- return expect(boardsStore.all()).resolves.toEqual(expectedResponse);
- });
-
- it('fails for error response', () => {
- axiosMock.onGet(endpoints.listsEndpoint).replyOnce(500);
-
- return expect(boardsStore.all()).rejects.toThrow();
- });
- });
-
- describe('createList', () => {
- const entityType = 'moorhen';
- const entityId = 'quack';
- const expectedRequest = expect.objectContaining({
- data: JSON.stringify({ list: { [entityType]: entityId } }),
- });
-
- let requestSpy;
-
- beforeEach(() => {
- requestSpy = jest.fn();
- axiosMock.onPost(endpoints.listsEndpoint).replyOnce((config) => requestSpy(config));
- });
-
- it('makes a request to create a list', () => {
- requestSpy.mockReturnValue([200, dummyResponse]);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
-
- return expect(boardsStore.createList(entityId, entityType))
- .resolves.toEqual(expectedResponse)
- .then(() => {
- expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
- });
- });
-
- it('fails for error response', () => {
- requestSpy.mockReturnValue([500]);
-
- return expect(boardsStore.createList(entityId, entityType))
- .rejects.toThrow()
- .then(() => {
- expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
- });
- });
- });
-
- describe('updateList', () => {
- const id = 'David Webb';
- const position = 'unknown';
- const collapsed = false;
- const expectedRequest = expect.objectContaining({
- data: JSON.stringify({ list: { position, collapsed } }),
- });
-
- let requestSpy;
-
- beforeEach(() => {
- requestSpy = jest.fn();
- axiosMock.onPut(`${endpoints.listsEndpoint}/${id}`).replyOnce((config) => requestSpy(config));
- });
-
- it('makes a request to update a list position', () => {
- requestSpy.mockReturnValue([200, dummyResponse]);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
-
- return expect(boardsStore.updateList(id, position, collapsed))
- .resolves.toEqual(expectedResponse)
- .then(() => {
- expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
- });
- });
-
- it('fails for error response', () => {
- requestSpy.mockReturnValue([500]);
-
- return expect(boardsStore.updateList(id, position, collapsed))
- .rejects.toThrow()
- .then(() => {
- expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
- });
- });
- });
-
- describe('destroyList', () => {
- const id = '-42';
-
- let requestSpy;
-
- beforeEach(() => {
- requestSpy = jest.fn();
- axiosMock
- .onDelete(`${endpoints.listsEndpoint}/${id}`)
- .replyOnce((config) => requestSpy(config));
- });
-
- it('makes a request to delete a list', () => {
- requestSpy.mockReturnValue([200, dummyResponse]);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
-
- return expect(boardsStore.destroyList(id))
- .resolves.toEqual(expectedResponse)
- .then(() => {
- expect(requestSpy).toHaveBeenCalled();
- });
- });
-
- it('fails for error response', () => {
- requestSpy.mockReturnValue([500]);
-
- return expect(boardsStore.destroyList(id))
- .rejects.toThrow()
- .then(() => {
- expect(requestSpy).toHaveBeenCalled();
- });
- });
- });
-
- describe('saveList', () => {
- let list;
-
- beforeEach(() => {
- list = new List(listObj);
- setupDefaultResponses();
- });
-
- it('makes a request to save a list', () => {
- const expectedResponse = expect.objectContaining({ issues: [createTestIssue()] });
- const expectedListValue = {
- id: listObj.id,
- position: listObj.position,
- type: listObj.list_type,
- label: listObj.label,
- };
- expect(list.id).toBe(listObj.id);
- expect(list.position).toBe(listObj.position);
- expect(list).toMatchObject(expectedListValue);
-
- return expect(boardsStore.saveList(list)).resolves.toEqual(expectedResponse);
- });
- });
-
- describe('getListIssues', () => {
- let list;
-
- beforeEach(() => {
- list = new List(listObj);
- setupDefaultResponses();
- });
-
- it('makes a request to get issues', () => {
- const expectedResponse = expect.objectContaining({ issues: [createTestIssue()] });
- expect(list.issues).toEqual([]);
-
- return expect(boardsStore.getListIssues(list, true)).resolves.toEqual(expectedResponse);
- });
- });
-
- describe('getIssuesForList', () => {
- const id = 'TOO-MUCH';
- const url = `${endpoints.listsEndpoint}/${id}/issues?id=${id}`;
-
- it('makes a request to fetch list issues', () => {
- axiosMock.onGet(url).replyOnce(200, dummyResponse);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
-
- return expect(boardsStore.getIssuesForList(id)).resolves.toEqual(expectedResponse);
- });
-
- it('makes a request to fetch list issues with filter', () => {
- const filter = { algal: 'scrubber' };
- axiosMock.onGet(`${url}&algal=scrubber`).replyOnce(200, dummyResponse);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
-
- return expect(boardsStore.getIssuesForList(id, filter)).resolves.toEqual(expectedResponse);
- });
-
- it('fails for error response', () => {
- axiosMock.onGet(url).replyOnce(500);
-
- return expect(boardsStore.getIssuesForList(id)).rejects.toThrow();
- });
- });
-
- describe('moveIssue', () => {
- const urlRoot = 'potato';
- const id = 'over 9000';
- const fromListId = 'left';
- const toListId = 'right';
- const moveBeforeId = 'up';
- const moveAfterId = 'down';
- const expectedRequest = expect.objectContaining({
- data: JSON.stringify({
- from_list_id: fromListId,
- to_list_id: toListId,
- move_before_id: moveBeforeId,
- move_after_id: moveAfterId,
- }),
- });
-
- let requestSpy;
-
- beforeAll(() => {
- global.gon.relative_url_root = urlRoot;
- });
-
- afterAll(() => {
- delete global.gon.relative_url_root;
- });
-
- beforeEach(() => {
- requestSpy = jest.fn();
- axiosMock
- .onPut(`${urlRoot}/-/boards/${boardId}/issues/${id}`)
- .replyOnce((config) => requestSpy(config));
- });
-
- it('makes a request to move an issue between lists', () => {
- requestSpy.mockReturnValue([200, dummyResponse]);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
-
- return expect(boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId))
- .resolves.toEqual(expectedResponse)
- .then(() => {
- expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
- });
- });
-
- it('fails for error response', () => {
- requestSpy.mockReturnValue([500]);
-
- return expect(boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId))
- .rejects.toThrow()
- .then(() => {
- expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
- });
- });
- });
-
- describe('newIssue', () => {
- const id = 1;
- const issue = { some: 'issue data' };
- const url = `${endpoints.listsEndpoint}/${id}/issues`;
- const expectedRequest = expect.objectContaining({
- data: JSON.stringify({
- issue,
- }),
- });
-
- let requestSpy;
-
- beforeEach(() => {
- requestSpy = jest.fn();
- axiosMock.onPost(url).replyOnce((config) => requestSpy(config));
- });
-
- it('makes a request to create a new issue', () => {
- requestSpy.mockReturnValue([200, dummyResponse]);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
-
- return expect(boardsStore.newIssue(id, issue))
- .resolves.toEqual(expectedResponse)
- .then(() => {
- expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
- });
- });
-
- it('fails for error response', () => {
- requestSpy.mockReturnValue([500]);
-
- return expect(boardsStore.newIssue(id, issue))
- .rejects.toThrow()
- .then(() => {
- expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
- });
- });
- });
-
- describe('getBacklog', () => {
- const urlRoot = 'deep';
- const url = `${urlRoot}/-/boards/${boardId}/issues.json?not=relevant`;
- const requestParams = {
- not: 'relevant',
- };
-
- beforeAll(() => {
- global.gon.relative_url_root = urlRoot;
- });
-
- afterAll(() => {
- delete global.gon.relative_url_root;
- });
-
- it('makes a request to fetch backlog', () => {
- axiosMock.onGet(url).replyOnce(200, dummyResponse);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
-
- return expect(boardsStore.getBacklog(requestParams)).resolves.toEqual(expectedResponse);
- });
-
- it('fails for error response', () => {
- axiosMock.onGet(url).replyOnce(500);
-
- return expect(boardsStore.getBacklog(requestParams)).rejects.toThrow();
- });
- });
-
- describe('bulkUpdate', () => {
- const issueIds = [1, 2, 3];
- const extraData = { moar: 'data' };
- const expectedRequest = expect.objectContaining({
- data: JSON.stringify({
- update: {
- ...extraData,
- issuable_ids: '1,2,3',
- },
- }),
- });
-
- let requestSpy;
-
- beforeEach(() => {
- requestSpy = jest.fn();
- axiosMock.onPost(endpoints.bulkUpdatePath).replyOnce((config) => requestSpy(config));
- });
-
- it('makes a request to create a list', () => {
- requestSpy.mockReturnValue([200, dummyResponse]);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
-
- return expect(boardsStore.bulkUpdate(issueIds, extraData))
- .resolves.toEqual(expectedResponse)
- .then(() => {
- expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
- });
- });
-
- it('fails for error response', () => {
- requestSpy.mockReturnValue([500]);
-
- return expect(boardsStore.bulkUpdate(issueIds, extraData))
- .rejects.toThrow()
- .then(() => {
- expect(requestSpy).toHaveBeenCalledWith(expectedRequest);
- });
- });
- });
-
- describe('getIssueInfo', () => {
- const dummyEndpoint = `${TEST_HOST}/some/where`;
-
- it('makes a request to the given endpoint', () => {
- axiosMock.onGet(dummyEndpoint).replyOnce(200, dummyResponse);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
-
- return expect(boardsStore.getIssueInfo(dummyEndpoint)).resolves.toEqual(expectedResponse);
- });
-
- it('fails for error response', () => {
- axiosMock.onGet(dummyEndpoint).replyOnce(500);
-
- return expect(boardsStore.getIssueInfo(dummyEndpoint)).rejects.toThrow();
- });
- });
-
- describe('toggleIssueSubscription', () => {
- const dummyEndpoint = `${TEST_HOST}/some/where`;
-
- it('makes a request to the given endpoint', () => {
- axiosMock.onPost(dummyEndpoint).replyOnce(200, dummyResponse);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
-
- return expect(boardsStore.toggleIssueSubscription(dummyEndpoint)).resolves.toEqual(
- expectedResponse,
- );
- });
-
- it('fails for error response', () => {
- axiosMock.onPost(dummyEndpoint).replyOnce(500);
-
- return expect(boardsStore.toggleIssueSubscription(dummyEndpoint)).rejects.toThrow();
- });
- });
-
- describe('recentBoards', () => {
- const url = `${endpoints.recentBoardsEndpoint}.json`;
-
- it('makes a request to fetch all boards', () => {
- axiosMock.onGet(url).replyOnce(200, dummyResponse);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
-
- return expect(boardsStore.recentBoards()).resolves.toEqual(expectedResponse);
- });
-
- it('fails for error response', () => {
- axiosMock.onGet(url).replyOnce(500);
-
- return expect(boardsStore.recentBoards()).rejects.toThrow();
- });
- });
-
- describe('when created', () => {
- beforeEach(() => {
- setupDefaultResponses();
-
- jest.spyOn(boardsStore, 'moveIssue').mockReturnValue(Promise.resolve());
- jest.spyOn(boardsStore, 'moveMultipleIssues').mockReturnValue(Promise.resolve());
-
- boardsStore.create();
- });
-
- it('starts with a blank state', () => {
- expect(boardsStore.state.lists.length).toBe(0);
- });
-
- describe('addList', () => {
- it('sorts by position', () => {
- boardsStore.addList({ position: 2 });
- boardsStore.addList({ position: 1 });
-
- expect(boardsStore.state.lists.map(({ position }) => position)).toEqual([1, 2]);
- });
- });
-
- describe('toggleFilter', () => {
- const dummyFilter = 'x=42';
- let updateTokensSpy;
-
- beforeEach(() => {
- updateTokensSpy = jest.fn();
- eventHub.$once('updateTokens', updateTokensSpy);
-
- // prevent using window.history
- jest.spyOn(boardsStore, 'updateFiltersUrl').mockReturnValue();
- });
-
- it('adds the filter if it is not present', () => {
- boardsStore.filter.path = 'something';
-
- boardsStore.toggleFilter(dummyFilter);
-
- expect(boardsStore.filter.path).toEqual(`something&${dummyFilter}`);
- expect(updateTokensSpy).toHaveBeenCalled();
- expect(boardsStore.updateFiltersUrl).toHaveBeenCalled();
- });
-
- it('removes the filter if it is present', () => {
- boardsStore.filter.path = `something&${dummyFilter}`;
-
- boardsStore.toggleFilter(dummyFilter);
-
- expect(boardsStore.filter.path).toEqual('something');
- expect(updateTokensSpy).toHaveBeenCalled();
- expect(boardsStore.updateFiltersUrl).toHaveBeenCalled();
- });
- });
-
- describe('lists', () => {
- it('creates new list without persisting to DB', () => {
- expect(boardsStore.state.lists.length).toBe(0);
-
- boardsStore.addList(listObj);
-
- expect(boardsStore.state.lists.length).toBe(1);
- });
-
- it('finds list by ID', () => {
- boardsStore.addList(listObj);
- const list = boardsStore.findList('id', listObj.id);
-
- expect(list.id).toBe(listObj.id);
- });
-
- it('finds list by type', () => {
- boardsStore.addList(listObj);
- const list = boardsStore.findList('type', 'label');
-
- expect(list).toBeDefined();
- });
-
- it('finds list by label ID', () => {
- boardsStore.addList(listObj);
- const list = boardsStore.findListByLabelId(listObj.label.id);
-
- expect(list.id).toBe(listObj.id);
- });
-
- it('gets issue when new list added', () => {
- boardsStore.addList(listObj);
- const list = boardsStore.findList('id', listObj.id);
-
- expect(boardsStore.state.lists.length).toBe(1);
-
- return axios.waitForAll().then(() => {
- expect(list.issues.length).toBe(1);
- expect(list.issues[0].id).toBe(1);
- });
- });
-
- it('persists new list', () => {
- boardsStore.new({
- title: 'Test',
- list_type: 'label',
- label: {
- id: 1,
- title: 'Testing',
- color: 'red',
- description: 'testing;',
- },
- });
-
- expect(boardsStore.state.lists.length).toBe(1);
-
- return axios.waitForAll().then(() => {
- const list = boardsStore.findList('id', listObj.id);
-
- expect(list).toEqual(
- expect.objectContaining({
- id: listObj.id,
- position: 0,
- }),
- );
- });
- });
-
- it('removes list from state', () => {
- boardsStore.addList(listObj);
-
- expect(boardsStore.state.lists.length).toBe(1);
-
- boardsStore.removeList(listObj.id);
-
- expect(boardsStore.state.lists.length).toBe(0);
- });
-
- it('moves the position of lists', () => {
- const listOne = boardsStore.addList(listObj);
- boardsStore.addList(listObjDuplicate);
-
- expect(boardsStore.state.lists.length).toBe(2);
-
- boardsStore.moveList(listOne, [listObjDuplicate.id, listObj.id]);
-
- expect(listOne.position).toBe(1);
- });
-
- it('moves an issue from one list to another', () => {
- const listOne = boardsStore.addList(listObj);
- const listTwo = boardsStore.addList(listObjDuplicate);
-
- expect(boardsStore.state.lists.length).toBe(2);
-
- return axios.waitForAll().then(() => {
- expect(listOne.issues.length).toBe(1);
- expect(listTwo.issues.length).toBe(1);
-
- boardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(1));
-
- expect(listOne.issues.length).toBe(0);
- expect(listTwo.issues.length).toBe(1);
- });
- });
-
- it('moves an issue from backlog to a list', () => {
- const backlog = boardsStore.addList({
- ...listObj,
- list_type: 'backlog',
- });
- const listTwo = boardsStore.addList(listObjDuplicate);
-
- expect(boardsStore.state.lists.length).toBe(2);
-
- return axios.waitForAll().then(() => {
- expect(backlog.issues.length).toBe(1);
- expect(listTwo.issues.length).toBe(1);
-
- boardsStore.moveIssueToList(backlog, listTwo, backlog.findIssue(1));
-
- expect(backlog.issues.length).toBe(0);
- expect(listTwo.issues.length).toBe(1);
- });
- });
-
- it('moves issue to top of another list', () => {
- const listOne = boardsStore.addList(listObj);
- const listTwo = boardsStore.addList(listObjDuplicate);
-
- expect(boardsStore.state.lists.length).toBe(2);
-
- return axios.waitForAll().then(() => {
- listOne.issues[0].id = 2;
-
- expect(listOne.issues.length).toBe(1);
- expect(listTwo.issues.length).toBe(1);
-
- boardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(2), 0);
-
- expect(listOne.issues.length).toBe(0);
- expect(listTwo.issues.length).toBe(2);
- expect(listTwo.issues[0].id).toBe(2);
- expect(boardsStore.moveIssue).toHaveBeenCalledWith(2, listOne.id, listTwo.id, null, 1);
- });
- });
-
- it('moves issue to bottom of another list', () => {
- const listOne = boardsStore.addList(listObj);
- const listTwo = boardsStore.addList(listObjDuplicate);
-
- expect(boardsStore.state.lists.length).toBe(2);
-
- return axios.waitForAll().then(() => {
- listOne.issues[0].id = 2;
-
- expect(listOne.issues.length).toBe(1);
- expect(listTwo.issues.length).toBe(1);
-
- boardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(2), 1);
-
- expect(listOne.issues.length).toBe(0);
- expect(listTwo.issues.length).toBe(2);
- expect(listTwo.issues[1].id).toBe(2);
- expect(boardsStore.moveIssue).toHaveBeenCalledWith(2, listOne.id, listTwo.id, 1, null);
- });
- });
-
- it('moves issue in list', () => {
- const issue = new ListIssue({
- title: 'Testing',
- id: 2,
- iid: 2,
- confidential: false,
- labels: [],
- assignees: [],
- });
- const list = boardsStore.addList(listObj);
-
- return axios.waitForAll().then(() => {
- list.addIssue(issue);
-
- expect(list.issues.length).toBe(2);
-
- boardsStore.moveIssueInList(list, issue, 0, 1, [1, 2]);
-
- expect(list.issues[0].id).toBe(2);
- expect(boardsStore.moveIssue).toHaveBeenCalledWith(2, null, null, 1, null);
- });
- });
- });
-
- describe('setListDetail', () => {
- it('sets the list detail', () => {
- boardsStore.detail.list = 'not a list';
-
- const dummyValue = 'new list';
- boardsStore.setListDetail(dummyValue);
-
- expect(boardsStore.detail.list).toEqual(dummyValue);
- });
- });
-
- describe('clearDetailIssue', () => {
- it('resets issue details', () => {
- boardsStore.detail.issue = 'something';
-
- boardsStore.clearDetailIssue();
-
- expect(boardsStore.detail.issue).toEqual({});
- });
- });
-
- describe('setIssueDetail', () => {
- it('sets issue details', () => {
- boardsStore.detail.issue = 'some details';
-
- const dummyValue = 'new details';
- boardsStore.setIssueDetail(dummyValue);
-
- expect(boardsStore.detail.issue).toEqual(dummyValue);
- });
- });
-
- describe('startMoving', () => {
- it('stores list and issue', () => {
- const dummyIssue = 'some issue';
- const dummyList = 'some list';
-
- boardsStore.startMoving(dummyList, dummyIssue);
-
- expect(boardsStore.moving.issue).toEqual(dummyIssue);
- expect(boardsStore.moving.list).toEqual(dummyList);
- });
- });
-
- describe('setTimeTrackingLimitToHours', () => {
- it('sets the timeTracking.LimitToHours option', () => {
- boardsStore.timeTracking.limitToHours = false;
-
- boardsStore.setTimeTrackingLimitToHours('true');
-
- expect(boardsStore.timeTracking.limitToHours).toEqual(true);
- });
- });
-
- describe('setCurrentBoard', () => {
- const dummyBoard = 'hoverboard';
-
- it('sets the current board', () => {
- const { state } = boardsStore;
- state.currentBoard = null;
-
- boardsStore.setCurrentBoard(dummyBoard);
-
- expect(state.currentBoard).toEqual(dummyBoard);
- });
- });
-
- describe('toggleMultiSelect', () => {
- let basicIssueObj;
-
- beforeAll(() => {
- basicIssueObj = { id: 987654 };
- });
-
- afterEach(() => {
- boardsStore.clearMultiSelect();
- });
-
- it('adds issue when not present', () => {
- boardsStore.toggleMultiSelect(basicIssueObj);
-
- const selectedIds = boardsStore.multiSelect.list.map(({ id }) => id);
-
- expect(selectedIds.includes(basicIssueObj.id)).toEqual(true);
- });
-
- it('removes issue when issue is present', () => {
- boardsStore.toggleMultiSelect(basicIssueObj);
- let selectedIds = boardsStore.multiSelect.list.map(({ id }) => id);
-
- expect(selectedIds.includes(basicIssueObj.id)).toEqual(true);
-
- boardsStore.toggleMultiSelect(basicIssueObj);
- selectedIds = boardsStore.multiSelect.list.map(({ id }) => id);
-
- expect(selectedIds.includes(basicIssueObj.id)).toEqual(false);
- });
- });
-
- describe('clearMultiSelect', () => {
- it('clears all the multi selected issues', () => {
- const issue1 = { id: 12345 };
- const issue2 = { id: 12346 };
-
- boardsStore.toggleMultiSelect(issue1);
- boardsStore.toggleMultiSelect(issue2);
-
- expect(boardsStore.multiSelect.list.length).toEqual(2);
-
- boardsStore.clearMultiSelect();
-
- expect(boardsStore.multiSelect.list.length).toEqual(0);
- });
- });
-
- describe('moveMultipleIssuesToList', () => {
- it('move issues on the new index', () => {
- const listOne = boardsStore.addList(listObj);
- const listTwo = boardsStore.addList(listObjDuplicate);
-
- expect(boardsStore.state.lists.length).toBe(2);
-
- return axios.waitForAll().then(() => {
- expect(listOne.issues.length).toBe(1);
- expect(listTwo.issues.length).toBe(1);
-
- boardsStore.moveMultipleIssuesToList({
- listFrom: listOne,
- listTo: listTwo,
- issues: listOne.issues,
- newIndex: 0,
- });
-
- expect(listTwo.issues.length).toBe(1);
- });
- });
- });
-
- describe('moveMultipleIssuesInList', () => {
- it('moves multiple issues in list', () => {
- const issueObj = {
- title: 'Issue #1',
- id: 12345,
- iid: 2,
- confidential: false,
- labels: [],
- assignees: [],
- };
- const issue1 = new ListIssue(issueObj);
- const issue2 = new ListIssue({
- ...issueObj,
- title: 'Issue #2',
- id: 12346,
- });
-
- const list = boardsStore.addList(listObj);
-
- return axios.waitForAll().then(() => {
- list.addIssue(issue1);
- list.addIssue(issue2);
-
- expect(list.issues.length).toBe(3);
- expect(list.issues[0].id).not.toBe(issue2.id);
-
- boardsStore.moveMultipleIssuesInList({
- list,
- issues: [issue1, issue2],
- oldIndicies: [0],
- newIndex: 1,
- idArray: [1, 12345, 12346],
- });
-
- expect(list.issues[0].id).toBe(issue1.id);
-
- expect(boardsStore.moveMultipleIssues).toHaveBeenCalledWith({
- ids: [issue1.id, issue2.id],
- fromListId: null,
- toListId: null,
- moveBeforeId: 1,
- moveAfterId: null,
- });
- });
- });
- });
-
- describe('addListIssue', () => {
- let list;
- const issue1 = new ListIssue({
- title: 'Testing',
- id: 2,
- iid: 2,
- confidential: false,
- labels: [
- {
- color: '#ff0000',
- description: 'testing;',
- id: 5000,
- priority: undefined,
- textColor: 'white',
- title: 'Test',
- },
- ],
- assignees: [],
- });
- const issue2 = new ListIssue({
- title: 'Testing',
- id: 1,
- iid: 1,
- confidential: false,
- labels: [
- {
- id: 1,
- title: 'test',
- color: 'red',
- description: 'testing',
- },
- ],
- assignees: [
- {
- id: 1,
- name: 'name',
- username: 'username',
- avatar_url: 'http://avatar_url',
- },
- ],
- real_path: 'path/to/issue',
- });
-
- beforeEach(() => {
- list = new List(listObj);
- list.addIssue(issue1);
- setupDefaultResponses();
- });
-
- it('adds issues that are not already on the list', () => {
- expect(list.findIssue(issue2.id)).toBe(undefined);
- expect(list.issues).toEqual([issue1]);
-
- boardsStore.addListIssue(list, issue2);
- expect(list.findIssue(issue2.id)).toBe(issue2);
- expect(list.issues.length).toBe(2);
- expect(list.issues).toEqual([issue1, issue2]);
- });
- });
-
- describe('updateIssue', () => {
- let issue;
- let patchSpy;
-
- beforeEach(() => {
- issue = new ListIssue({
- title: 'Testing',
- id: 1,
- iid: 1,
- confidential: false,
- labels: [
- {
- id: 1,
- title: 'test',
- color: 'red',
- description: 'testing',
- },
- ],
- assignees: [
- {
- id: 1,
- name: 'name',
- username: 'username',
- avatar_url: 'http://avatar_url',
- },
- ],
- real_path: 'path/to/issue',
- });
-
- patchSpy = jest.fn().mockReturnValue([200, { labels: [] }]);
- axiosMock.onPatch(`path/to/issue.json`).reply(({ data }) => patchSpy(JSON.parse(data)));
- });
-
- it('passes assignee ids when there are assignees', () => {
- boardsStore.updateIssue(issue);
- return boardsStore.updateIssue(issue).then(() => {
- expect(patchSpy).toHaveBeenCalledWith({
- issue: {
- milestone_id: null,
- assignee_ids: [1],
- label_ids: [1],
- },
- });
- });
- });
-
- it('passes assignee ids of [0] when there are no assignees', () => {
- issue.removeAllAssignees();
-
- return boardsStore.updateIssue(issue).then(() => {
- expect(patchSpy).toHaveBeenCalledWith({
- issue: {
- milestone_id: null,
- assignee_ids: [0],
- label_ids: [1],
- },
- });
- });
- });
- });
- });
-});
diff --git a/spec/frontend/boards/components/board_add_new_column_spec.js b/spec/frontend/boards/components/board_add_new_column_spec.js
index 61f210f566b..5fae1c4359f 100644
--- a/spec/frontend/boards/components/board_add_new_column_spec.js
+++ b/spec/frontend/boards/components/board_add_new_column_spec.js
@@ -48,7 +48,6 @@ describe('Board card layout', () => {
...actions,
},
getters: {
- shouldUseGraphQL: () => true,
getListByLabelId: () => getListByLabelId,
},
state: {
diff --git a/spec/frontend/boards/components/board_app_spec.js b/spec/frontend/boards/components/board_app_spec.js
new file mode 100644
index 00000000000..dee097bfb08
--- /dev/null
+++ b/spec/frontend/boards/components/board_app_spec.js
@@ -0,0 +1,54 @@
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
+
+import BoardApp from '~/boards/components/board_app.vue';
+
+describe('BoardApp', () => {
+ let wrapper;
+ let store;
+
+ Vue.use(Vuex);
+
+ const createStore = ({ mockGetters = {} } = {}) => {
+ store = new Vuex.Store({
+ state: {},
+ actions: {
+ performSearch: jest.fn(),
+ },
+ getters: {
+ isSidebarOpen: () => true,
+ ...mockGetters,
+ },
+ });
+ };
+
+ const createComponent = ({ provide = { disabled: true } } = {}) => {
+ wrapper = shallowMount(BoardApp, {
+ store,
+ provide: {
+ ...provide,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ store = null;
+ });
+
+ it("should have 'is-compact' class when sidebar is open", () => {
+ createStore();
+ createComponent();
+
+ expect(wrapper.classes()).toContain('is-compact');
+ });
+
+ it("should not have 'is-compact' class when sidebar is closed", () => {
+ createStore({ mockGetters: { isSidebarOpen: () => false } });
+ createComponent();
+
+ expect(wrapper.classes()).not.toContain('is-compact');
+ });
+});
diff --git a/spec/frontend/boards/components/board_card_deprecated_spec.js b/spec/frontend/boards/components/board_card_deprecated_spec.js
deleted file mode 100644
index 266cbc7106d..00000000000
--- a/spec/frontend/boards/components/board_card_deprecated_spec.js
+++ /dev/null
@@ -1,219 +0,0 @@
-/* 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
deleted file mode 100644
index 9853c9f434f..00000000000
--- a/spec/frontend/boards/components/board_card_layout_deprecated_spec.js
+++ /dev/null
@@ -1,158 +0,0 @@
-/* global List */
-/* global ListLabel */
-
-import { createLocalVue, shallowMount } from '@vue/test-utils';
-
-import MockAdapter from 'axios-mock-adapter';
-import Vuex from 'vuex';
-import waitForPromises from 'helpers/wait_for_promises';
-
-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_deprecated.vue';
-import { ISSUABLE } from '~/boards/constants';
-import boardsVuexStore from '~/boards/stores';
-import boardsStore from '~/boards/stores/boards_store';
-import axios from '~/lib/utils/axios_utils';
-import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data';
-
-describe('Board card layout', () => {
- let wrapper;
- let mock;
- let list;
- let store;
-
- const localVue = createLocalVue();
- localVue.use(Vuex);
-
- const createStore = ({ getters = {}, actions = {} } = {}) => {
- store = new Vuex.Store({
- ...boardsVuexStore,
- 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,
- issue: list.issues[0],
- disabled: false,
- index: 0,
- ...propsData,
- },
- provide: {
- groupId: null,
- rootPath: '/',
- scopedLabelsAvailable: false,
- ...provide,
- },
- });
- };
-
- const setupData = () => {
- 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',
- });
- return waitForPromises().then(() => {
- 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();
- });
-
- 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' when 'graphqlBoardLists' feature flag is turned on", async () => {
- const setActiveId = jest.fn();
- createStore({
- actions: {
- setActiveId,
- },
- });
- mountComponent({
- provide: {
- glFeatures: { graphqlBoardLists: true },
- },
- });
-
- wrapper.trigger('mouseup');
- await wrapper.vm.$nextTick();
-
- expect(setActiveId).toHaveBeenCalledTimes(1);
- expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), {
- id: list.issues[0].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: list.issues[0].id,
- sidebarType: ISSUABLE,
- });
- });
- });
-});
diff --git a/spec/frontend/boards/components/board_column_deprecated_spec.js b/spec/frontend/boards/components/board_column_deprecated_spec.js
deleted file mode 100644
index e6d65e48c3f..00000000000
--- a/spec/frontend/boards/components/board_column_deprecated_spec.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import AxiosMockAdapter from 'axios-mock-adapter';
-import Vue, { nextTick } from 'vue';
-
-import { TEST_HOST } from 'helpers/test_constants';
-import { listObj } from 'jest/boards/mock_data';
-import Board from '~/boards/components/board_column_deprecated.vue';
-import { ListType } from '~/boards/constants';
-import List from '~/boards/models/list';
-import axios from '~/lib/utils/axios_utils';
-
-describe('Board Column Component', () => {
- let wrapper;
- let axiosMock;
-
- beforeEach(() => {
- window.gon = {};
- axiosMock = new AxiosMockAdapter(axios);
- axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
- });
-
- afterEach(() => {
- axiosMock.restore();
-
- wrapper.destroy();
-
- localStorage.clear();
- });
-
- const createComponent = ({
- listType = ListType.backlog,
- collapsed = false,
- highlighted = false,
- withLocalStorage = true,
- } = {}) => {
- const boardId = '1';
-
- const listMock = {
- ...listObj,
- list_type: listType,
- highlighted,
- collapsed,
- };
-
- if (listType === ListType.assignee) {
- delete listMock.label;
- listMock.user = {};
- }
-
- // Making List reactive
- const list = Vue.observable(new List(listMock));
-
- if (withLocalStorage) {
- localStorage.setItem(
- `boards.${boardId}.${list.type}.${list.id}.expanded`,
- (!collapsed).toString(),
- );
- }
-
- wrapper = shallowMount(Board, {
- propsData: {
- boardId,
- disabled: false,
- list,
- },
- provide: {
- boardId,
- },
- });
- };
-
- const isExpandable = () => wrapper.classes('is-expandable');
- const isCollapsed = () => wrapper.classes('is-collapsed');
-
- describe('Given different list types', () => {
- it('is expandable when List Type is `backlog`', () => {
- createComponent({ listType: ListType.backlog });
-
- expect(isExpandable()).toBe(true);
- });
- });
-
- describe('expanded / collapsed column', () => {
- it('has class is-collapsed when list is collapsed', () => {
- createComponent({ collapsed: false });
-
- expect(wrapper.vm.list.isExpanded).toBe(true);
- });
-
- it('does not have class is-collapsed when list is expanded', () => {
- createComponent({ collapsed: true });
-
- expect(isCollapsed()).toBe(true);
- });
- });
-
- describe('highlighting', () => {
- it('scrolls to column when highlighted', async () => {
- createComponent({ highlighted: true });
-
- await nextTick();
-
- expect(wrapper.element.scrollIntoView).toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js
index 5a799b6388e..f535679b8a0 100644
--- a/spec/frontend/boards/components/board_content_spec.js
+++ b/spec/frontend/boards/components/board_content_spec.js
@@ -5,9 +5,10 @@ import Draggable from 'vuedraggable';
import Vuex from 'vuex';
import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue';
import getters from 'ee_else_ce/boards/stores/getters';
-import BoardColumnDeprecated from '~/boards/components/board_column_deprecated.vue';
+import BoardColumn from '~/boards/components/board_column.vue';
import BoardContent from '~/boards/components/board_content.vue';
-import { mockLists, mockListsWithModel } from '../mock_data';
+import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue';
+import { mockLists } from '../mock_data';
Vue.use(Vuex);
@@ -23,6 +24,7 @@ describe('BoardContent', () => {
isShowingEpicsSwimlanes: false,
boardLists: mockLists,
error: undefined,
+ issuableType: 'issue',
};
const createStore = (state = defaultState) => {
@@ -33,25 +35,19 @@ describe('BoardContent', () => {
});
};
- const createComponent = ({
- state,
- props = {},
- graphqlBoardListsEnabled = false,
- canAdminList = true,
- } = {}) => {
+ const createComponent = ({ state, props = {}, canAdminList = true } = {}) => {
const store = createStore({
...defaultState,
...state,
});
wrapper = shallowMount(BoardContent, {
propsData: {
- lists: mockListsWithModel,
+ lists: mockLists,
disabled: false,
...props,
},
provide: {
canAdminList,
- glFeatures: { graphqlBoardLists: graphqlBoardListsEnabled },
},
store,
});
@@ -61,53 +57,48 @@ describe('BoardContent', () => {
wrapper.destroy();
});
- it('renders a BoardColumnDeprecated component per list', () => {
- createComponent();
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
- expect(wrapper.findAllComponents(BoardColumnDeprecated)).toHaveLength(
- mockListsWithModel.length,
- );
- });
+ it('renders a BoardColumn component per list', () => {
+ expect(wrapper.findAllComponents(BoardColumn)).toHaveLength(mockLists.length);
+ });
- it('does not display EpicsSwimlanes component', () => {
- createComponent();
+ it('renders BoardContentSidebar', () => {
+ expect(wrapper.find(BoardContentSidebar).exists()).toBe(true);
+ });
- expect(wrapper.find(EpicsSwimlanes).exists()).toBe(false);
- expect(wrapper.find(GlAlert).exists()).toBe(false);
+ it('does not display EpicsSwimlanes component', () => {
+ expect(wrapper.find(EpicsSwimlanes).exists()).toBe(false);
+ expect(wrapper.find(GlAlert).exists()).toBe(false);
+ });
});
- describe('graphqlBoardLists feature flag enabled', () => {
+ describe('when issuableType is not issue', () => {
beforeEach(() => {
- createComponent({ graphqlBoardListsEnabled: true });
- gon.features = {
- graphqlBoardLists: true,
- };
+ createComponent({ state: { issuableType: 'foo' } });
});
- describe('can admin list', () => {
- beforeEach(() => {
- createComponent({ graphqlBoardListsEnabled: true, canAdminList: true });
- });
-
- it('renders draggable component', () => {
- expect(wrapper.find(Draggable).exists()).toBe(true);
- });
+ it('does not render BoardContentSidebar', () => {
+ expect(wrapper.find(BoardContentSidebar).exists()).toBe(false);
});
+ });
- describe('can not admin list', () => {
- beforeEach(() => {
- createComponent({ graphqlBoardListsEnabled: true, canAdminList: false });
- });
+ describe('can admin list', () => {
+ beforeEach(() => {
+ createComponent({ canAdminList: true });
+ });
- it('does not render draggable component', () => {
- expect(wrapper.find(Draggable).exists()).toBe(false);
- });
+ it('renders draggable component', () => {
+ expect(wrapper.find(Draggable).exists()).toBe(true);
});
});
- describe('graphqlBoardLists feature flag disabled', () => {
+ describe('can not admin list', () => {
beforeEach(() => {
- createComponent({ graphqlBoardListsEnabled: false });
+ createComponent({ canAdminList: false });
});
it('does not render draggable component', () => {
diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js
index 50f86e92adb..dc93890f27a 100644
--- a/spec/frontend/boards/components/board_filtered_search_spec.js
+++ b/spec/frontend/boards/components/board_filtered_search_spec.js
@@ -2,7 +2,6 @@ import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue';
-import { createStore } from '~/boards/stores';
import * as urlUtility from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
@@ -44,6 +43,12 @@ describe('BoardFilteredSearch', () => {
];
const createComponent = ({ initialFilterParams = {} } = {}) => {
+ store = new Vuex.Store({
+ actions: {
+ performSearch: jest.fn(),
+ },
+ });
+
wrapper = shallowMount(BoardFilteredSearch, {
provide: { initialFilterParams, fullPath: '' },
store,
@@ -55,22 +60,15 @@ describe('BoardFilteredSearch', () => {
const findFilteredSearch = () => wrapper.findComponent(FilteredSearchBarRoot);
- beforeEach(() => {
- // this needed for actions call for performSearch
- window.gon = { features: {} };
- });
-
afterEach(() => {
wrapper.destroy();
});
describe('default', () => {
beforeEach(() => {
- store = createStore();
+ createComponent();
jest.spyOn(store, 'dispatch');
-
- createComponent();
});
it('renders FilteredSearch', () => {
@@ -103,8 +101,6 @@ describe('BoardFilteredSearch', () => {
describe('when searching', () => {
beforeEach(() => {
- store = createStore();
-
createComponent();
jest.spyOn(wrapper.vm, 'performSearch').mockImplementation();
@@ -133,11 +129,9 @@ describe('BoardFilteredSearch', () => {
describe('when url params are already set', () => {
beforeEach(() => {
- store = createStore();
+ createComponent({ initialFilterParams: { authorUsername: 'root', labelName: ['label'] } });
jest.spyOn(store, 'dispatch');
-
- createComponent({ initialFilterParams: { authorUsername: 'root', labelName: ['label'] } });
});
it('passes the correct props to FilterSearchBar', () => {
diff --git a/spec/frontend/boards/components/board_list_header_deprecated_spec.js b/spec/frontend/boards/components/board_list_header_deprecated_spec.js
deleted file mode 100644
index db79e67fe78..00000000000
--- a/spec/frontend/boards/components/board_list_header_deprecated_spec.js
+++ /dev/null
@@ -1,174 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import AxiosMockAdapter from 'axios-mock-adapter';
-import Vue from 'vue';
-
-import { TEST_HOST } from 'helpers/test_constants';
-import { listObj } from 'jest/boards/mock_data';
-import BoardListHeader from '~/boards/components/board_list_header_deprecated.vue';
-import { ListType } from '~/boards/constants';
-import List from '~/boards/models/list';
-import axios from '~/lib/utils/axios_utils';
-
-describe('Board List Header Component', () => {
- let wrapper;
- let axiosMock;
-
- beforeEach(() => {
- window.gon = {};
- axiosMock = new AxiosMockAdapter(axios);
- axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
- });
-
- afterEach(() => {
- axiosMock.restore();
-
- wrapper.destroy();
-
- localStorage.clear();
- });
-
- const createComponent = ({
- listType = ListType.backlog,
- collapsed = false,
- withLocalStorage = true,
- currentUserId = 1,
- } = {}) => {
- const boardId = '1';
-
- const listMock = {
- ...listObj,
- list_type: listType,
- collapsed,
- };
-
- if (listType === ListType.assignee) {
- delete listMock.label;
- listMock.user = {};
- }
-
- // Making List reactive
- const list = Vue.observable(new List(listMock));
-
- if (withLocalStorage) {
- localStorage.setItem(
- `boards.${boardId}.${list.type}.${list.id}.expanded`,
- (!collapsed).toString(),
- );
- }
-
- wrapper = shallowMount(BoardListHeader, {
- propsData: {
- disabled: false,
- list,
- },
- provide: {
- boardId,
- currentUserId,
- },
- });
- };
-
- const isCollapsed = () => !wrapper.props().list.isExpanded;
- const isExpanded = () => wrapper.vm.list.isExpanded;
-
- const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' });
- const findCaret = () => wrapper.find('.board-title-caret');
-
- describe('Add issue button', () => {
- const hasNoAddButton = [ListType.closed];
- const hasAddButton = [
- ListType.backlog,
- ListType.label,
- ListType.milestone,
- ListType.iteration,
- ListType.assignee,
- ];
-
- it.each(hasNoAddButton)('does not render when List Type is `%s`', (listType) => {
- createComponent({ listType });
-
- expect(findAddIssueButton().exists()).toBe(false);
- });
-
- it.each(hasAddButton)('does render when List Type is `%s`', (listType) => {
- createComponent({ listType });
-
- expect(findAddIssueButton().exists()).toBe(true);
- });
-
- it('has a test for each list type', () => {
- Object.values(ListType).forEach((value) => {
- expect([...hasAddButton, ...hasNoAddButton]).toContain(value);
- });
- });
-
- it('does not render when logged out', () => {
- createComponent({
- currentUserId: null,
- });
-
- expect(findAddIssueButton().exists()).toBe(false);
- });
- });
-
- describe('expanding / collapsing the column', () => {
- it('does not collapse when clicking the header', () => {
- createComponent();
-
- expect(isCollapsed()).toBe(false);
- wrapper.find('[data-testid="board-list-header"]').trigger('click');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(isCollapsed()).toBe(false);
- });
- });
-
- it('collapses expanded Column when clicking the collapse icon', () => {
- createComponent();
-
- expect(isExpanded()).toBe(true);
- findCaret().vm.$emit('click');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(isCollapsed()).toBe(true);
- });
- });
-
- it('expands collapsed Column when clicking the expand icon', () => {
- createComponent({ collapsed: true });
-
- expect(isCollapsed()).toBe(true);
- findCaret().vm.$emit('click');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(isCollapsed()).toBe(false);
- });
- });
-
- it("when logged in it calls list update and doesn't set localStorage", () => {
- jest.spyOn(List.prototype, 'update');
-
- createComponent({ withLocalStorage: false });
-
- findCaret().vm.$emit('click');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1);
- expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null);
- });
- });
-
- it("when logged out it doesn't call list update and sets localStorage", () => {
- jest.spyOn(List.prototype, 'update');
-
- createComponent({ currentUserId: null });
-
- findCaret().vm.$emit('click');
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.list.update).not.toHaveBeenCalled();
- expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded()));
- });
- });
- });
-});
diff --git a/spec/frontend/boards/components/board_settings_sidebar_spec.js b/spec/frontend/boards/components/board_settings_sidebar_spec.js
index 20a08be6c19..46dd109ffb1 100644
--- a/spec/frontend/boards/components/board_settings_sidebar_spec.js
+++ b/spec/frontend/boards/components/board_settings_sidebar_spec.js
@@ -1,38 +1,55 @@
-import '~/boards/models/list';
import { GlDrawer, GlLabel } from '@gitlab/ui';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import axios from 'axios';
-import MockAdapter from 'axios-mock-adapter';
+import { shallowMount } from '@vue/test-utils';
import { MountingPortal } from 'portal-vue';
+import Vue from 'vue';
import Vuex from 'vuex';
+import { stubComponent } from 'helpers/stub_component';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue';
import { inactiveId, LIST } from '~/boards/constants';
-import { createStore } from '~/boards/stores';
-import boardsStore from '~/boards/stores/boards_store';
+import actions from '~/boards/stores/actions';
+import getters from '~/boards/stores/getters';
+import mutations from '~/boards/stores/mutations';
import sidebarEventHub from '~/sidebar/event_hub';
+import { mockLabelList } from '../mock_data';
-const localVue = createLocalVue();
-
-localVue.use(Vuex);
+Vue.use(Vuex);
describe('BoardSettingsSidebar', () => {
let wrapper;
- let mock;
- let store;
- const labelTitle = 'test';
- const labelColor = '#FFFF';
- const listId = 1;
+ const labelTitle = mockLabelList.label.title;
+ const labelColor = mockLabelList.label.color;
+ const listId = mockLabelList.id;
const findRemoveButton = () => wrapper.findByTestId('remove-list');
- const createComponent = ({ canAdminList = false } = {}) => {
+ const createComponent = ({
+ canAdminList = false,
+ list = {},
+ sidebarType = LIST,
+ activeId = inactiveId,
+ } = {}) => {
+ const boardLists = {
+ [listId]: list,
+ };
+ const store = new Vuex.Store({
+ state: { sidebarType, activeId, boardLists },
+ getters,
+ mutations,
+ actions,
+ });
+
wrapper = extendedWrapper(
shallowMount(BoardSettingsSidebar, {
store,
- localVue,
provide: {
canAdminList,
+ scopedLabelsAvailable: false,
+ },
+ stubs: {
+ GlDrawer: stubComponent(GlDrawer, {
+ template: '<div><slot name="header"></slot><slot></slot></div>',
+ }),
},
}),
);
@@ -40,16 +57,10 @@ describe('BoardSettingsSidebar', () => {
const findLabel = () => wrapper.find(GlLabel);
const findDrawer = () => wrapper.find(GlDrawer);
- beforeEach(() => {
- store = createStore();
- store.state.activeId = inactiveId;
- store.state.sidebarType = LIST;
- boardsStore.create();
- });
-
afterEach(() => {
jest.restoreAllMocks();
wrapper.destroy();
+ wrapper = null;
});
it('finds a MountingPortal component', () => {
@@ -100,86 +111,40 @@ describe('BoardSettingsSidebar', () => {
});
describe('when activeId is greater than zero', () => {
- beforeEach(() => {
- mock = new MockAdapter(axios);
-
- boardsStore.addList({
- id: listId,
- label: { title: labelTitle, color: labelColor },
- list_type: 'label',
- });
- store.state.activeId = 1;
- store.state.sidebarType = LIST;
- });
-
- afterEach(() => {
- boardsStore.removeList(listId);
- });
-
- it('renders GlDrawer with open false', () => {
- createComponent();
+ it('renders GlDrawer with open true', () => {
+ createComponent({ list: mockLabelList, activeId: listId });
expect(findDrawer().props('open')).toBe(true);
});
});
- describe('when activeId is in boardsStore', () => {
- beforeEach(() => {
- mock = new MockAdapter(axios);
-
- boardsStore.addList({
- id: listId,
- label: { title: labelTitle, color: labelColor },
- list_type: 'label',
- });
-
- store.state.activeId = listId;
- store.state.sidebarType = LIST;
-
- createComponent();
- });
-
- afterEach(() => {
- mock.restore();
- });
-
+ describe('when activeId is in state', () => {
it('renders label title', () => {
+ createComponent({ list: mockLabelList, activeId: listId });
+
expect(findLabel().props('title')).toBe(labelTitle);
});
it('renders label background color', () => {
+ createComponent({ list: mockLabelList, activeId: listId });
+
expect(findLabel().props('backgroundColor')).toBe(labelColor);
});
});
- describe('when activeId is not in boardsStore', () => {
- beforeEach(() => {
- mock = new MockAdapter(axios);
-
- boardsStore.addList({ id: listId, label: { title: labelTitle, color: labelColor } });
-
- store.state.activeId = inactiveId;
-
- createComponent();
- });
-
- afterEach(() => {
- mock.restore();
- });
-
+ describe('when activeId is not in state', () => {
it('does not render GlLabel', () => {
+ createComponent({ list: mockLabelList });
+
expect(findLabel().exists()).toBe(false);
});
});
});
describe('when sidebarType is not List', () => {
- beforeEach(() => {
- store.state.sidebarType = '';
- createComponent();
- });
-
it('does not render GlDrawer', () => {
+ createComponent({ sidebarType: '' });
+
expect(findDrawer().exists()).toBe(false);
});
});
@@ -191,20 +156,9 @@ describe('BoardSettingsSidebar', () => {
});
describe('when user can admin the boards list', () => {
- beforeEach(() => {
- store.state.activeId = listId;
- store.state.sidebarType = LIST;
-
- boardsStore.addList({
- id: listId,
- label: { title: labelTitle, color: labelColor },
- list_type: 'label',
- });
-
- createComponent({ canAdminList: true });
- });
-
it('renders "Remove list" button', () => {
+ createComponent({ canAdminList: true, activeId: listId, list: mockLabelList });
+
expect(findRemoveButton().exists()).toBe(true);
});
});
diff --git a/spec/frontend/boards/components/boards_selector_deprecated_spec.js b/spec/frontend/boards/components/boards_selector_deprecated_spec.js
deleted file mode 100644
index cc078861d75..00000000000
--- a/spec/frontend/boards/components/boards_selector_deprecated_spec.js
+++ /dev/null
@@ -1,214 +0,0 @@
-import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import { TEST_HOST } from 'spec/test_constants';
-import BoardsSelector from '~/boards/components/boards_selector_deprecated.vue';
-import boardsStore from '~/boards/stores/boards_store';
-
-const throttleDuration = 1;
-
-function boardGenerator(n) {
- return new Array(n).fill().map((board, index) => {
- const id = `${index}`;
- const name = `board${id}`;
-
- return {
- id,
- name,
- };
- });
-}
-
-describe('BoardsSelector', () => {
- let wrapper;
- let allBoardsResponse;
- let recentBoardsResponse;
- const boards = boardGenerator(20);
- const recentBoards = boardGenerator(5);
-
- const fillSearchBox = (filterTerm) => {
- const searchBox = wrapper.find({ ref: 'searchBox' });
- const searchBoxInput = searchBox.find('input');
- searchBoxInput.setValue(filterTerm);
- searchBoxInput.trigger('input');
- };
-
- const getDropdownItems = () => wrapper.findAll('.js-dropdown-item');
- const getDropdownHeaders = () => wrapper.findAll(GlDropdownSectionHeader);
- const getLoadingIcon = () => wrapper.find(GlLoadingIcon);
- const findDropdown = () => wrapper.find(GlDropdown);
-
- beforeEach(() => {
- const $apollo = {
- queries: {
- boards: {
- loading: false,
- },
- },
- };
-
- boardsStore.setEndpoints({
- boardsEndpoint: '',
- recentBoardsEndpoint: '',
- listsEndpoint: '',
- bulkUpdatePath: '',
- boardId: '',
- });
-
- allBoardsResponse = Promise.resolve({
- data: {
- group: {
- boards: {
- edges: boards.map((board) => ({ node: board })),
- },
- },
- },
- });
- recentBoardsResponse = Promise.resolve({
- data: recentBoards,
- });
-
- boardsStore.allBoards = jest.fn(() => allBoardsResponse);
- boardsStore.recentBoards = jest.fn(() => recentBoardsResponse);
-
- wrapper = mount(BoardsSelector, {
- propsData: {
- throttleDuration,
- currentBoard: {
- id: 1,
- name: 'Development',
- milestone_id: null,
- weight: null,
- assignee_id: null,
- labels: [],
- },
- boardBaseUrl: `${TEST_HOST}/board/base/url`,
- hasMissingBoards: false,
- canAdminBoard: true,
- multipleIssueBoardsAvailable: true,
- labelsPath: `${TEST_HOST}/labels/path`,
- labelsWebUrl: `${TEST_HOST}/labels`,
- projectId: 42,
- groupId: 19,
- scopedIssueBoardFeatureEnabled: true,
- weights: [],
- },
- mocks: { $apollo },
- attachTo: document.body,
- });
-
- wrapper.vm.$apollo.addSmartQuery = jest.fn((_, options) => {
- wrapper.setData({
- [options.loadingKey]: true,
- });
- });
-
- // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time
- findDropdown().vm.$emit('show');
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- describe('loading', () => {
- // we are testing loading state, so don't resolve responses until after the tests
- afterEach(() => {
- return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
- });
-
- it('shows loading spinner', () => {
- expect(getDropdownHeaders()).toHaveLength(0);
- expect(getDropdownItems()).toHaveLength(0);
- expect(getLoadingIcon().exists()).toBe(true);
- });
- });
-
- describe('loaded', () => {
- beforeEach(async () => {
- await wrapper.setData({
- loadingBoards: false,
- });
- return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
- });
-
- it('hides loading spinner', () => {
- expect(getLoadingIcon().exists()).toBe(false);
- });
-
- describe('filtering', () => {
- beforeEach(() => {
- wrapper.setData({
- boards,
- });
-
- return nextTick();
- });
-
- it('shows all boards without filtering', () => {
- expect(getDropdownItems()).toHaveLength(boards.length + recentBoards.length);
- });
-
- it('shows only matching boards when filtering', () => {
- const filterTerm = 'board1';
- const expectedCount = boards.filter((board) => board.name.includes(filterTerm)).length;
-
- fillSearchBox(filterTerm);
-
- return nextTick().then(() => {
- expect(getDropdownItems()).toHaveLength(expectedCount);
- });
- });
-
- it('shows message if there are no matching boards', () => {
- fillSearchBox('does not exist');
-
- return nextTick().then(() => {
- expect(getDropdownItems()).toHaveLength(0);
- expect(wrapper.text().includes('No matching boards found')).toBe(true);
- });
- });
- });
-
- describe('recent boards section', () => {
- it('shows only when boards are greater than 10', () => {
- wrapper.setData({
- boards,
- });
-
- return nextTick().then(() => {
- expect(getDropdownHeaders()).toHaveLength(2);
- });
- });
-
- it('does not show when boards are less than 10', () => {
- wrapper.setData({
- boards: boards.slice(0, 5),
- });
-
- return nextTick().then(() => {
- expect(getDropdownHeaders()).toHaveLength(0);
- });
- });
-
- it('does not show when recentBoards api returns empty array', () => {
- wrapper.setData({
- recentBoards: [],
- });
-
- return nextTick().then(() => {
- expect(getDropdownHeaders()).toHaveLength(0);
- });
- });
-
- it('does not show when search is active', () => {
- fillSearchBox('Random string');
-
- return nextTick().then(() => {
- expect(getDropdownHeaders()).toHaveLength(0);
- });
- });
- });
- });
-});
diff --git a/spec/frontend/boards/components/issue_time_estimate_deprecated_spec.js b/spec/frontend/boards/components/issue_time_estimate_deprecated_spec.js
deleted file mode 100644
index fafebaf3a4e..00000000000
--- a/spec/frontend/boards/components/issue_time_estimate_deprecated_spec.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import IssueTimeEstimate from '~/boards/components/issue_time_estimate_deprecated.vue';
-import boardsStore from '~/boards/stores/boards_store';
-
-describe('Issue Time Estimate component', () => {
- let wrapper;
-
- beforeEach(() => {
- boardsStore.create();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('when limitToHours is false', () => {
- beforeEach(() => {
- boardsStore.timeTracking.limitToHours = false;
- wrapper = shallowMount(IssueTimeEstimate, {
- propsData: {
- estimate: 374460,
- },
- });
- });
-
- it('renders the correct time estimate', () => {
- expect(wrapper.find('time').text().trim()).toEqual('2w 3d 1m');
- });
-
- it('renders expanded time estimate in tooltip', () => {
- expect(wrapper.find('.js-issue-time-estimate').text()).toContain('2 weeks 3 days 1 minute');
- });
-
- it('prevents tooltip xss', (done) => {
- const alertSpy = jest.spyOn(window, 'alert');
- wrapper.setProps({ estimate: 'Foo <script>alert("XSS")</script>' });
- wrapper.vm.$nextTick(() => {
- expect(alertSpy).not.toHaveBeenCalled();
- expect(wrapper.find('time').text().trim()).toEqual('0m');
- expect(wrapper.find('.js-issue-time-estimate').text()).toContain('0m');
- done();
- });
- });
- });
-
- describe('when limitToHours is true', () => {
- beforeEach(() => {
- boardsStore.timeTracking.limitToHours = true;
- wrapper = shallowMount(IssueTimeEstimate, {
- propsData: {
- estimate: 374460,
- },
- });
- });
-
- it('renders the correct time estimate', () => {
- expect(wrapper.find('time').text().trim()).toEqual('104h 1m');
- });
-
- it('renders expanded time estimate in tooltip', () => {
- expect(wrapper.find('.js-issue-time-estimate').text()).toContain('104 hours 1 minute');
- });
- });
-});
diff --git a/spec/frontend/boards/issue_card_deprecated_spec.js b/spec/frontend/boards/issue_card_deprecated_spec.js
deleted file mode 100644
index 909be275030..00000000000
--- a/spec/frontend/boards/issue_card_deprecated_spec.js
+++ /dev/null
@@ -1,332 +0,0 @@
-/* global ListAssignee, ListLabel, ListIssue */
-import { GlLabel } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
-import { range } from 'lodash';
-import '~/boards/models/label';
-import '~/boards/models/assignee';
-import '~/boards/models/issue';
-import '~/boards/models/list';
-import IssueCardInner from '~/boards/components/issue_card_inner_deprecated.vue';
-import store from '~/boards/stores';
-import { listObj } from './mock_data';
-
-describe('Issue card component', () => {
- const user = new ListAssignee({
- id: 1,
- name: 'testing 123',
- username: 'test',
- avatar: 'test_image',
- });
-
- const label1 = new ListLabel({
- id: 3,
- title: 'testing 123',
- color: '#000CFF',
- text_color: 'white',
- description: 'test',
- });
-
- let wrapper;
- let issue;
- let list;
-
- beforeEach(() => {
- list = { ...listObj, type: 'label' };
- issue = new ListIssue({
- title: 'Testing',
- id: 1,
- iid: 1,
- confidential: false,
- labels: [list.label],
- assignees: [],
- reference_path: '#1',
- real_path: '/test/1',
- weight: 1,
- });
- wrapper = mount(IssueCardInner, {
- propsData: {
- list,
- issue,
- },
- store,
- stubs: {
- GlLabel: true,
- },
- provide: {
- groupId: null,
- rootPath: '/',
- },
- });
- });
-
- it('renders issue title', () => {
- expect(wrapper.find('.board-card-title').text()).toContain(issue.title);
- });
-
- it('includes issue base in link', () => {
- expect(wrapper.find('.board-card-title a').attributes('href')).toContain('/test');
- });
-
- it('includes issue title on link', () => {
- expect(wrapper.find('.board-card-title a').attributes('title')).toBe(issue.title);
- });
-
- it('does not render confidential icon', () => {
- expect(wrapper.find('.confidential-icon').exists()).toBe(false);
- });
-
- it('does not render blocked icon', () => {
- expect(wrapper.find('.issue-blocked-icon').exists()).toBe(false);
- });
-
- it('renders confidential icon', (done) => {
- wrapper.setProps({
- issue: {
- ...wrapper.props('issue'),
- confidential: true,
- },
- });
- wrapper.vm.$nextTick(() => {
- expect(wrapper.find('.confidential-icon').exists()).toBe(true);
- done();
- });
- });
-
- it('renders issue ID with #', () => {
- expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.id}`);
- });
-
- describe('assignee', () => {
- it('does not render assignee', () => {
- expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(false);
- });
-
- describe('exists', () => {
- beforeEach((done) => {
- wrapper.setProps({
- issue: {
- ...wrapper.props('issue'),
- assignees: [user],
- updateData(newData) {
- Object.assign(this, newData);
- },
- },
- });
-
- wrapper.vm.$nextTick(done);
- });
-
- it('renders assignee', () => {
- expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(true);
- });
-
- it('sets title', () => {
- expect(wrapper.find('.js-assignee-tooltip').text()).toContain(`${user.name}`);
- });
-
- it('sets users path', () => {
- expect(wrapper.find('.board-card-assignee a').attributes('href')).toBe('/test');
- });
-
- it('renders avatar', () => {
- expect(wrapper.find('.board-card-assignee img').exists()).toBe(true);
- });
-
- it('renders the avatar using avatar_url property', (done) => {
- wrapper.props('issue').updateData({
- ...wrapper.props('issue'),
- assignees: [
- {
- id: '1',
- name: 'test',
- state: 'active',
- username: 'test_name',
- avatar_url: 'test_image_from_avatar_url',
- },
- ],
- });
-
- wrapper.vm.$nextTick(() => {
- expect(wrapper.find('.board-card-assignee img').attributes('src')).toBe(
- 'test_image_from_avatar_url?width=24',
- );
- done();
- });
- });
- });
-
- describe('assignee default avatar', () => {
- beforeEach((done) => {
- global.gon.default_avatar_url = 'default_avatar';
-
- wrapper.setProps({
- issue: {
- ...wrapper.props('issue'),
- assignees: [
- new ListAssignee({
- id: 1,
- name: 'testing 123',
- username: 'test',
- }),
- ],
- },
- });
-
- wrapper.vm.$nextTick(done);
- });
-
- afterEach(() => {
- global.gon.default_avatar_url = null;
- });
-
- it('displays defaults avatar if users avatar is null', () => {
- expect(wrapper.find('.board-card-assignee img').exists()).toBe(true);
- expect(wrapper.find('.board-card-assignee img').attributes('src')).toBe(
- 'default_avatar?width=24',
- );
- });
- });
- });
-
- describe('multiple assignees', () => {
- beforeEach((done) => {
- wrapper.setProps({
- issue: {
- ...wrapper.props('issue'),
- assignees: [
- new ListAssignee({
- id: 2,
- name: 'user2',
- username: 'user2',
- avatar: 'test_image',
- }),
- new ListAssignee({
- id: 3,
- name: 'user3',
- username: 'user3',
- avatar: 'test_image',
- }),
- new ListAssignee({
- id: 4,
- name: 'user4',
- username: 'user4',
- avatar: 'test_image',
- }),
- ],
- },
- });
-
- wrapper.vm.$nextTick(done);
- });
-
- it('renders all three assignees', () => {
- expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(3);
- });
-
- describe('more than three assignees', () => {
- beforeEach((done) => {
- const { assignees } = wrapper.props('issue');
- assignees.push(
- new ListAssignee({
- id: 5,
- name: 'user5',
- username: 'user5',
- avatar: 'test_image',
- }),
- );
-
- wrapper.setProps({
- issue: {
- ...wrapper.props('issue'),
- assignees,
- },
- });
- wrapper.vm.$nextTick(done);
- });
-
- it('renders more avatar counter', () => {
- expect(wrapper.find('.board-card-assignee .avatar-counter').text().trim()).toEqual('+2');
- });
-
- it('renders two assignees', () => {
- expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(2);
- });
-
- it('renders 99+ avatar counter', (done) => {
- const assignees = [
- ...wrapper.props('issue').assignees,
- ...range(5, 103).map(
- (i) =>
- new ListAssignee({
- id: i,
- name: 'name',
- username: 'username',
- avatar: 'test_image',
- }),
- ),
- ];
- wrapper.setProps({
- issue: {
- ...wrapper.props('issue'),
- assignees,
- },
- });
-
- wrapper.vm.$nextTick(() => {
- expect(wrapper.find('.board-card-assignee .avatar-counter').text().trim()).toEqual('99+');
- done();
- });
- });
- });
- });
-
- describe('labels', () => {
- beforeEach((done) => {
- issue.addLabel(label1);
- wrapper.setProps({ issue: { ...issue } });
-
- wrapper.vm.$nextTick(done);
- });
-
- it('does not render list label but renders all other labels', () => {
- expect(wrapper.findAll(GlLabel).length).toBe(1);
- const label = wrapper.find(GlLabel);
- expect(label.props('title')).toEqual(label1.title);
- expect(label.props('description')).toEqual(label1.description);
- expect(label.props('backgroundColor')).toEqual(label1.color);
- });
-
- it('does not render label if label does not have an ID', (done) => {
- issue.addLabel(
- new ListLabel({
- title: 'closed',
- }),
- );
- wrapper.setProps({ issue: { ...issue } });
- wrapper.vm
- .$nextTick()
- .then(() => {
- expect(wrapper.findAll(GlLabel).length).toBe(1);
- expect(wrapper.text()).not.toContain('closed');
- done();
- })
- .catch(done.fail);
- });
- });
-
- describe('blocked', () => {
- beforeEach((done) => {
- wrapper.setProps({
- issue: {
- ...wrapper.props('issue'),
- blocked: true,
- },
- });
- wrapper.vm.$nextTick(done);
- });
-
- it('renders blocked icon if issue is blocked', () => {
- expect(wrapper.find('.issue-blocked-icon').exists()).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/boards/issue_spec.js b/spec/frontend/boards/issue_spec.js
deleted file mode 100644
index 1f354fb04db..00000000000
--- a/spec/frontend/boards/issue_spec.js
+++ /dev/null
@@ -1,162 +0,0 @@
-/* global ListIssue */
-
-import '~/boards/models/label';
-import '~/boards/models/assignee';
-import '~/boards/models/issue';
-import '~/boards/models/list';
-import boardsStore from '~/boards/stores/boards_store';
-import { setMockEndpoints, mockIssue } from './mock_data';
-
-describe('Issue model', () => {
- let issue;
-
- beforeEach(() => {
- setMockEndpoints();
- boardsStore.create();
-
- issue = new ListIssue(mockIssue);
- });
-
- it('has label', () => {
- expect(issue.labels.length).toBe(1);
- });
-
- it('add new label', () => {
- issue.addLabel({
- id: 2,
- title: 'bug',
- color: 'blue',
- description: 'bugs!',
- });
-
- expect(issue.labels.length).toBe(2);
- });
-
- it('does not add label if label id exists', () => {
- issue.addLabel({
- id: 1,
- title: 'test 2',
- color: 'blue',
- description: 'testing',
- });
-
- expect(issue.labels.length).toBe(1);
- expect(issue.labels[0].color).toBe('#F0AD4E');
- });
-
- it('adds other label with same title', () => {
- issue.addLabel({
- id: 2,
- title: 'test',
- color: 'blue',
- description: 'other test',
- });
-
- expect(issue.labels.length).toBe(2);
- });
-
- it('finds label', () => {
- const label = issue.findLabel(issue.labels[0]);
-
- expect(label).toBeDefined();
- });
-
- it('removes label', () => {
- const label = issue.findLabel(issue.labels[0]);
- issue.removeLabel(label);
-
- expect(issue.labels.length).toBe(0);
- });
-
- it('removes multiple labels', () => {
- issue.addLabel({
- id: 2,
- title: 'bug',
- color: 'blue',
- description: 'bugs!',
- });
-
- expect(issue.labels.length).toBe(2);
-
- issue.removeLabels([issue.labels[0], issue.labels[1]]);
-
- expect(issue.labels.length).toBe(0);
- });
-
- it('adds assignee', () => {
- issue.addAssignee({
- id: 2,
- name: 'Bruce Wayne',
- username: 'batman',
- avatar_url: 'http://batman',
- });
-
- expect(issue.assignees.length).toBe(2);
- });
-
- it('finds assignee', () => {
- const assignee = issue.findAssignee(issue.assignees[0]);
-
- expect(assignee).toBeDefined();
- });
-
- it('removes assignee', () => {
- const assignee = issue.findAssignee(issue.assignees[0]);
- issue.removeAssignee(assignee);
-
- expect(issue.assignees.length).toBe(0);
- });
-
- it('removes all assignees', () => {
- issue.removeAllAssignees();
-
- expect(issue.assignees.length).toBe(0);
- });
-
- it('sets position to infinity if no position is stored', () => {
- expect(issue.position).toBe(Infinity);
- });
-
- it('sets position', () => {
- const relativePositionIssue = new ListIssue({
- title: 'Testing',
- iid: 1,
- confidential: false,
- relative_position: 1,
- labels: [],
- assignees: [],
- });
-
- expect(relativePositionIssue.position).toBe(1);
- });
-
- it('updates data', () => {
- issue.updateData({ subscribed: true });
-
- expect(issue.subscribed).toBe(true);
- });
-
- it('sets fetching state', () => {
- expect(issue.isFetching.subscriptions).toBe(true);
-
- issue.setFetchingState('subscriptions', false);
-
- expect(issue.isFetching.subscriptions).toBe(false);
- });
-
- it('sets loading state', () => {
- issue.setLoadingState('foo', true);
-
- expect(issue.isLoading.foo).toBe(true);
- });
-
- describe('update', () => {
- it('passes update to boardsStore', () => {
- jest.spyOn(boardsStore, 'updateIssue').mockImplementation();
-
- issue.update();
-
- expect(boardsStore.updateIssue).toHaveBeenCalledWith(issue);
- });
- });
-});
diff --git a/spec/frontend/boards/list_spec.js b/spec/frontend/boards/list_spec.js
deleted file mode 100644
index 4d6a82bdff0..00000000000
--- a/spec/frontend/boards/list_spec.js
+++ /dev/null
@@ -1,230 +0,0 @@
-/* global List */
-/* global ListAssignee */
-/* global ListIssue */
-/* global ListLabel */
-import MockAdapter from 'axios-mock-adapter';
-import waitForPromises from 'helpers/wait_for_promises';
-import '~/boards/models/label';
-import '~/boards/models/assignee';
-import '~/boards/models/issue';
-import '~/boards/models/list';
-import { ListType } from '~/boards/constants';
-import boardsStore from '~/boards/stores/boards_store';
-import axios from '~/lib/utils/axios_utils';
-import { listObj, listObjDuplicate, boardsMockInterceptor } from './mock_data';
-
-describe('List model', () => {
- let list;
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- mock.onAny().reply(boardsMockInterceptor);
- boardsStore.create();
- boardsStore.setEndpoints({
- listsEndpoint: '/test/-/boards/1/lists',
- });
-
- list = new List(listObj);
- return waitForPromises();
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- describe('list type', () => {
- const notExpandableList = ['blank'];
-
- const table = Object.keys(ListType).map((k) => {
- const value = ListType[k];
- return [value, !notExpandableList.includes(value)];
- });
- it.each(table)(`when list_type is %s boards isExpandable is %p`, (type, result) => {
- expect(new List({ id: 1, list_type: type }).isExpandable).toBe(result);
- });
- });
-
- it('gets issues when created', () => {
- expect(list.issues.length).toBe(1);
- });
-
- it('saves list and returns ID', () => {
- list = new List({
- title: 'test',
- label: {
- id: 1,
- title: 'test',
- color: '#ff0000',
- text_color: 'white',
- },
- });
- return list.save().then(() => {
- expect(list.id).toBe(listObj.id);
- expect(list.type).toBe('label');
- expect(list.position).toBe(0);
- expect(list.label).toEqual(listObj.label);
- });
- });
-
- it('destroys the list', () => {
- boardsStore.addList(listObj);
- list = boardsStore.findList('id', listObj.id);
-
- expect(boardsStore.state.lists.length).toBe(1);
- list.destroy();
-
- return waitForPromises().then(() => {
- expect(boardsStore.state.lists.length).toBe(0);
- });
- });
-
- it('gets issue from list', () => {
- const issue = list.findIssue(1);
-
- expect(issue).toBeDefined();
- });
-
- it('removes issue', () => {
- const issue = list.findIssue(1);
-
- expect(list.issues.length).toBe(1);
- list.removeIssue(issue);
-
- expect(list.issues.length).toBe(0);
- });
-
- it('sends service request to update issue label', () => {
- const listDup = new List(listObjDuplicate);
- const issue = new ListIssue({
- title: 'Testing',
- id: 1,
- iid: 1,
- confidential: false,
- labels: [list.label, listDup.label],
- assignees: [],
- });
-
- list.issues.push(issue);
- listDup.issues.push(issue);
-
- jest.spyOn(boardsStore, 'moveIssue');
-
- listDup.updateIssueLabel(issue, list);
-
- expect(boardsStore.moveIssue).toHaveBeenCalledWith(
- issue.id,
- list.id,
- listDup.id,
- undefined,
- undefined,
- );
- });
-
- describe('page number', () => {
- beforeEach(() => {
- jest.spyOn(list, 'getIssues').mockImplementation(() => {});
- list.issues = [];
- });
-
- it('increase page number if current issue count is more than the page size', () => {
- for (let i = 0; i < 30; i += 1) {
- list.issues.push(
- new ListIssue({
- title: 'Testing',
- id: i,
- iid: i,
- confidential: false,
- labels: [list.label],
- assignees: [],
- }),
- );
- }
- list.issuesSize = 50;
-
- expect(list.issues.length).toBe(30);
-
- list.nextPage();
-
- expect(list.page).toBe(2);
- expect(list.getIssues).toHaveBeenCalled();
- });
-
- it('does not increase page number if issue count is less than the page size', () => {
- list.issues.push(
- new ListIssue({
- title: 'Testing',
- id: 1,
- confidential: false,
- labels: [list.label],
- assignees: [],
- }),
- );
- list.issuesSize = 2;
-
- list.nextPage();
-
- expect(list.page).toBe(1);
- expect(list.getIssues).toHaveBeenCalled();
- });
- });
-
- describe('newIssue', () => {
- beforeEach(() => {
- jest.spyOn(boardsStore, 'newIssue').mockReturnValue(
- Promise.resolve({
- data: {
- id: 42,
- subscribed: false,
- assignable_labels_endpoint: '/issue/42/labels',
- toggle_subscription_endpoint: '/issue/42/subscriptions',
- issue_sidebar_endpoint: '/issue/42/sidebar_info',
- },
- }),
- );
- list.issues = [];
- });
-
- it('adds new issue to top of list', (done) => {
- const user = new ListAssignee({
- id: 1,
- name: 'testing 123',
- username: 'test',
- avatar: 'test_image',
- });
-
- list.issues.push(
- new ListIssue({
- title: 'Testing',
- id: 1,
- confidential: false,
- labels: [new ListLabel(list.label)],
- assignees: [],
- }),
- );
- const dummyIssue = new ListIssue({
- title: 'new issue',
- id: 2,
- confidential: false,
- labels: [new ListLabel(list.label)],
- assignees: [user],
- subscribed: false,
- });
-
- list
- .newIssue(dummyIssue)
- .then(() => {
- expect(list.issues.length).toBe(2);
- expect(list.issues[0]).toBe(dummyIssue);
- expect(list.issues[0].subscribed).toBe(false);
- expect(list.issues[0].assignableLabelsEndpoint).toBe('/issue/42/labels');
- expect(list.issues[0].toggleSubscriptionEndpoint).toBe('/issue/42/subscriptions');
- expect(list.issues[0].sidebarInfoEndpoint).toBe('/issue/42/sidebar_info');
- expect(list.issues[0].labels).toBe(dummyIssue.labels);
- expect(list.issues[0].assignees).toBe(dummyIssue.assignees);
- })
- .then(done)
- .catch(done.fail);
- });
- });
-});
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index 106f7b04c4b..6a4f344bbfb 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -1,12 +1,8 @@
-/* global List */
-
import { GlFilteredSearchToken } from '@gitlab/ui';
import { keyBy } from 'lodash';
-import Vue from 'vue';
-import '~/boards/models/list';
import { ListType } from '~/boards/constants';
-import boardsStore from '~/boards/stores/boards_store';
import { __ } from '~/locale';
+import { DEFAULT_MILESTONES_GRAPHQL } from '~/vue_shared/components/filtered_search_bar/constants';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
@@ -196,8 +192,7 @@ export const mockIssue = {
export const mockActiveIssue = {
...mockIssue,
- fullId: 'gid://gitlab/Issue/436',
- id: 436,
+ id: 'gid://gitlab/Issue/436',
iid: '27',
subscribed: false,
emailsDisabled: false,
@@ -289,20 +284,6 @@ export const boardsMockInterceptor = (config) => {
return [200, body];
};
-export const setMockEndpoints = (opts = {}) => {
- const boardsEndpoint = opts.boardsEndpoint || '/test/issue-boards/-/boards.json';
- const listsEndpoint = opts.listsEndpoint || '/test/-/boards/1/lists';
- const bulkUpdatePath = opts.bulkUpdatePath || '';
- const boardId = opts.boardId || '1';
-
- boardsStore.setEndpoints({
- boardsEndpoint,
- listsEndpoint,
- bulkUpdatePath,
- boardId,
- });
-};
-
export const mockList = {
id: 'gid://gitlab/List/1',
title: 'Open',
@@ -335,14 +316,26 @@ export const mockLabelList = {
issuesCount: 0,
};
+export const mockMilestoneList = {
+ id: 'gid://gitlab/List/3',
+ title: 'To Do',
+ position: 0,
+ listType: 'milestone',
+ collapsed: false,
+ label: null,
+ assignee: null,
+ milestone: {
+ webUrl: 'https://gitlab.com/h5bp/html5-boilerplate/-/milestones/1',
+ title: 'Backlog',
+ },
+ loading: false,
+ issuesCount: 0,
+};
+
export const mockLists = [mockList, mockLabelList];
export const mockListsById = keyBy(mockLists, 'id');
-export const mockListsWithModel = mockLists.map((listMock) =>
- Vue.observable(new List({ ...listMock, doNotFetchIssues: true })),
-);
-
export const mockIssuesByListId = {
'gid://gitlab/List/1': [mockIssue.id, mockIssue3.id, mockIssue4.id],
'gid://gitlab/List/2': mockIssues.map(({ id }) => id),
@@ -547,17 +540,17 @@ export const mockMoveData = {
export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones) => [
{
- icon: 'labels',
- title: __('Label'),
- type: 'label_name',
+ icon: 'user',
+ title: __('Assignee'),
+ type: 'assignee_username',
operators: [
{ value: '=', description: 'is' },
{ value: '!=', description: 'is not' },
],
- token: LabelToken,
- unique: false,
- symbol: '~',
- fetchLabels,
+ token: AuthorToken,
+ unique: true,
+ fetchAuthors,
+ preloadedAuthors: [],
},
{
icon: 'pencil',
@@ -574,17 +567,27 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones) => [
preloadedAuthors: [],
},
{
- icon: 'user',
- title: __('Assignee'),
- type: 'assignee_username',
+ icon: 'labels',
+ title: __('Label'),
+ type: 'label_name',
operators: [
{ value: '=', description: 'is' },
{ value: '!=', description: 'is not' },
],
- token: AuthorToken,
+ token: LabelToken,
+ unique: false,
+ symbol: '~',
+ fetchLabels,
+ },
+ {
+ icon: 'clock',
+ title: __('Milestone'),
+ symbol: '%',
+ type: 'milestone_title',
+ token: MilestoneToken,
unique: true,
- fetchAuthors,
- preloadedAuthors: [],
+ defaultMilestones: DEFAULT_MILESTONES_GRAPHQL,
+ fetchMilestones,
},
{
icon: 'issues',
@@ -599,16 +602,6 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones) => [
],
},
{
- icon: 'clock',
- title: __('Milestone'),
- symbol: '%',
- type: 'milestone_title',
- token: MilestoneToken,
- unique: true,
- defaultMilestones: [],
- fetchMilestones,
- },
- {
icon: 'weight',
title: __('Weight'),
type: 'weight',
diff --git a/spec/frontend/boards/project_select_deprecated_spec.js b/spec/frontend/boards/project_select_deprecated_spec.js
deleted file mode 100644
index 4494de43083..00000000000
--- a/spec/frontend/boards/project_select_deprecated_spec.js
+++ /dev/null
@@ -1,263 +0,0 @@
-import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
-import axios from 'axios';
-import AxiosMockAdapter from 'axios-mock-adapter';
-import ProjectSelect from '~/boards/components/project_select_deprecated.vue';
-import { ListType } from '~/boards/constants';
-import eventHub from '~/boards/eventhub';
-import createFlash from '~/flash';
-import httpStatus from '~/lib/utils/http_status';
-import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
-
-import { listObj, mockRawGroupProjects } from './mock_data';
-
-jest.mock('~/boards/eventhub');
-jest.mock('~/flash');
-
-const dummyGon = {
- api_version: 'v4',
- relative_url_root: '/gitlab',
-};
-
-const mockGroupId = 1;
-const mockProjectsList1 = mockRawGroupProjects.slice(0, 1);
-const mockProjectsList2 = mockRawGroupProjects.slice(1);
-const mockDefaultFetchOptions = {
- with_issues_enabled: true,
- with_shared: false,
- include_subgroups: true,
- order_by: 'similarity',
- archived: false,
-};
-
-const itemsPerPage = 20;
-
-describe('ProjectSelect component', () => {
- let wrapper;
- let axiosMock;
-
- const findLabel = () => wrapper.find("[data-testid='header-label']");
- const findGlDropdown = () => wrapper.find(GlDropdown);
- const findGlDropdownLoadingIcon = () =>
- findGlDropdown().find('button:first-child').find(GlLoadingIcon);
- const findGlSearchBoxByType = () => wrapper.find(GlSearchBoxByType);
- const findGlDropdownItems = () => wrapper.findAll(GlDropdownItem);
- const findFirstGlDropdownItem = () => findGlDropdownItems().at(0);
- const findInMenuLoadingIcon = () => wrapper.find("[data-testid='dropdown-text-loading-icon']");
- const findEmptySearchMessage = () => wrapper.find("[data-testid='empty-result-message']");
-
- const mockGetRequest = (data = [], statusCode = httpStatus.OK) => {
- axiosMock
- .onGet(`/gitlab/api/v4/groups/${mockGroupId}/projects.json`)
- .replyOnce(statusCode, data);
- };
-
- const searchForProject = async (keyword, waitForAll = true) => {
- findGlSearchBoxByType().vm.$emit('input', keyword);
-
- if (waitForAll) {
- await axios.waitForAll();
- }
- };
-
- const createWrapper = async ({ list = listObj } = {}, waitForAll = true) => {
- wrapper = mount(ProjectSelect, {
- propsData: {
- list,
- },
- provide: {
- groupId: 1,
- },
- });
-
- if (waitForAll) {
- await axios.waitForAll();
- }
- };
-
- beforeEach(() => {
- axiosMock = new AxiosMockAdapter(axios);
- window.gon = dummyGon;
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- axiosMock.restore();
- jest.clearAllMocks();
- });
-
- it('displays a header title', async () => {
- createWrapper({});
-
- expect(findLabel().text()).toBe('Projects');
- });
-
- it('renders a default dropdown text', async () => {
- createWrapper({});
-
- expect(findGlDropdown().exists()).toBe(true);
- expect(findGlDropdown().text()).toContain('Select a project');
- });
-
- describe('when mounted', () => {
- it('displays a loading icon while projects are being fetched', async () => {
- mockGetRequest([]);
-
- createWrapper({}, false);
-
- expect(findGlDropdownLoadingIcon().exists()).toBe(true);
-
- await axios.waitForAll();
-
- expect(axiosMock.history.get[0].params).toMatchObject({ search: '' });
- expect(axiosMock.history.get[0].url).toBe(
- `/gitlab/api/v4/groups/${mockGroupId}/projects.json`,
- );
-
- expect(findGlDropdownLoadingIcon().exists()).toBe(false);
- });
- });
-
- describe('when dropdown menu is open', () => {
- describe('by default', () => {
- beforeEach(async () => {
- mockGetRequest(mockProjectsList1);
-
- await createWrapper();
- });
-
- it('shows GlSearchBoxByType with default attributes', () => {
- expect(findGlSearchBoxByType().exists()).toBe(true);
- expect(findGlSearchBoxByType().vm.$attrs).toMatchObject({
- placeholder: 'Search projects',
- debounce: '250',
- });
- });
-
- it("displays the fetched project's name", () => {
- expect(findFirstGlDropdownItem().exists()).toBe(true);
- expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList1[0].name);
- });
-
- it("doesn't render loading icon in the menu", () => {
- expect(findInMenuLoadingIcon().isVisible()).toBe(false);
- });
-
- it('renders empty search result message', async () => {
- await createWrapper();
-
- expect(findEmptySearchMessage().exists()).toBe(true);
- });
- });
-
- describe('when a project is selected', () => {
- beforeEach(async () => {
- mockGetRequest(mockProjectsList1);
-
- await createWrapper();
-
- await findFirstGlDropdownItem().find('button').trigger('click');
- });
-
- it('emits setSelectedProject with correct project metadata', () => {
- expect(eventHub.$emit).toHaveBeenCalledWith('setSelectedProject', {
- id: mockProjectsList1[0].id,
- path: mockProjectsList1[0].path_with_namespace,
- name: mockProjectsList1[0].name,
- namespacedName: mockProjectsList1[0].name_with_namespace,
- });
- });
-
- it('renders the name of the selected project', () => {
- expect(findGlDropdown().find('.gl-new-dropdown-button-text').text()).toBe(
- mockProjectsList1[0].name,
- );
- });
- });
-
- describe('when user searches for a project', () => {
- beforeEach(async () => {
- mockGetRequest(mockProjectsList1);
-
- await createWrapper();
- });
-
- it('calls API with correct parameters with default fetch options', async () => {
- await searchForProject('foobar');
-
- const expectedApiParams = {
- search: 'foobar',
- per_page: itemsPerPage,
- ...mockDefaultFetchOptions,
- };
-
- expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams);
- expect(axiosMock.history.get[1].url).toBe(
- `/gitlab/api/v4/groups/${mockGroupId}/projects.json`,
- );
- });
-
- describe("when list type is defined and isn't backlog", () => {
- it('calls API with an additional fetch option (min_access_level)', async () => {
- axiosMock.reset();
-
- await createWrapper({ list: { ...listObj, type: ListType.label } });
-
- await searchForProject('foobar');
-
- const expectedApiParams = {
- search: 'foobar',
- per_page: itemsPerPage,
- ...mockDefaultFetchOptions,
- min_access_level: featureAccessLevel.EVERYONE,
- };
-
- expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams);
- expect(axiosMock.history.get[1].url).toBe(
- `/gitlab/api/v4/groups/${mockGroupId}/projects.json`,
- );
- });
- });
-
- it('displays and hides gl-loading-icon while and after fetching data', async () => {
- await searchForProject('some keyword', false);
-
- await wrapper.vm.$nextTick();
-
- expect(findInMenuLoadingIcon().isVisible()).toBe(true);
-
- await axios.waitForAll();
-
- expect(findInMenuLoadingIcon().isVisible()).toBe(false);
- });
-
- it('flashes an error message when fetching fails', async () => {
- mockGetRequest([], httpStatus.INTERNAL_SERVER_ERROR);
-
- await searchForProject('foobar');
-
- expect(createFlash).toHaveBeenCalledTimes(1);
- expect(createFlash).toHaveBeenCalledWith({
- message: 'Something went wrong while fetching projects',
- });
- });
-
- describe('with non-empty search result', () => {
- beforeEach(async () => {
- mockGetRequest(mockProjectsList2);
-
- await searchForProject('foobar');
- });
-
- it('displays the retrieved list of projects', async () => {
- expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList2[0].name);
- });
-
- it('does not render empty search result message', async () => {
- expect(findEmptySearchMessage().exists()).toBe(false);
- });
- });
- });
- });
-});
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index 1272a573d2f..62e0fa7a68a 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -26,7 +26,6 @@ import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'
import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
import mutations from '~/boards/stores/mutations';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import {
mockLists,
@@ -107,12 +106,7 @@ describe('setFilters', () => {
});
describe('performSearch', () => {
- it('should dispatch setFilters action', (done) => {
- testAction(actions.performSearch, {}, {}, [], [{ type: 'setFilters', payload: {} }], done);
- });
-
- it('should dispatch setFilters, fetchLists and resetIssues action when graphqlBoardLists FF is on', (done) => {
- window.gon = { features: { graphqlBoardLists: true } };
+ it('should dispatch setFilters, fetchLists and resetIssues action', (done) => {
testAction(
actions.performSearch,
{},
@@ -496,12 +490,9 @@ describe('fetchLabels', () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
const commit = jest.fn();
- const getters = {
- shouldUseGraphQL: () => true,
- };
const state = { boardType: 'group' };
- await actions.fetchLabels({ getters, state, commit });
+ await actions.fetchLabels({ state, commit });
expect(commit).toHaveBeenCalledWith(types.RECEIVE_LABELS_SUCCESS, labels);
});
@@ -954,7 +945,7 @@ describe('moveIssue', () => {
});
describe('moveIssueCard and undoMoveIssueCard', () => {
- describe('card should move without clonning', () => {
+ describe('card should move without cloning', () => {
let state;
let params;
let moveMutations;
@@ -1221,8 +1212,8 @@ describe('updateMovedIssueCard', () => {
describe('updateIssueOrder', () => {
const issues = {
- 436: mockIssue,
- 437: mockIssue2,
+ [mockIssue.id]: mockIssue,
+ [mockIssue2.id]: mockIssue2,
};
const state = {
@@ -1231,7 +1222,7 @@ describe('updateIssueOrder', () => {
};
const moveData = {
- itemId: 436,
+ itemId: mockIssue.id,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
};
@@ -1490,7 +1481,7 @@ describe('addListNewIssue', () => {
type: 'addListItem',
payload: {
list: fakeList,
- item: formatIssue({ ...mockIssue, id: getIdFromGraphQLId(mockIssue.id) }),
+ item: formatIssue(mockIssue),
position: 0,
},
},
diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js
index c0774dd3ae1..b30968c45d7 100644
--- a/spec/frontend/boards/stores/getters_spec.js
+++ b/spec/frontend/boards/stores/getters_spec.js
@@ -77,12 +77,12 @@ describe('Boards - Getters', () => {
});
describe('getBoardItemById', () => {
- const state = { boardItems: { 1: 'issue' } };
+ const state = { boardItems: { 'gid://gitlab/Issue/1': 'issue' } };
it.each`
- id | expected
- ${'1'} | ${'issue'}
- ${''} | ${{}}
+ id | expected
+ ${'gid://gitlab/Issue/1'} | ${'issue'}
+ ${''} | ${{}}
`('returns $expected when $id is passed to state', ({ id, expected }) => {
expect(getters.getBoardItemById(state)(id)).toEqual(expected);
});
@@ -90,11 +90,11 @@ describe('Boards - Getters', () => {
describe('activeBoardItem', () => {
it.each`
- id | expected
- ${'1'} | ${'issue'}
- ${''} | ${{ id: '', iid: '', fullId: '' }}
+ id | expected
+ ${'gid://gitlab/Issue/1'} | ${'issue'}
+ ${''} | ${{ id: '', iid: '' }}
`('returns $expected when $id is passed to state', ({ id, expected }) => {
- const state = { boardItems: { 1: 'issue' }, activeId: id };
+ const state = { boardItems: { 'gid://gitlab/Issue/1': 'issue' }, activeId: id };
expect(getters.activeBoardItem(state)).toEqual(expected);
});
diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js
index a2ba1e9eb5e..0e830258327 100644
--- a/spec/frontend/boards/stores/mutations_spec.js
+++ b/spec/frontend/boards/stores/mutations_spec.js
@@ -407,7 +407,7 @@ describe('Board Store Mutations', () => {
describe('MUTATE_ISSUE_SUCCESS', () => {
it('updates issue in issues state', () => {
const issues = {
- 436: { id: rawIssue.id },
+ [rawIssue.id]: { id: rawIssue.id },
};
state = {
@@ -419,7 +419,7 @@ describe('Board Store Mutations', () => {
issue: rawIssue,
});
- expect(state.boardItems).toEqual({ 436: { ...mockIssue, id: 436 } });
+ expect(state.boardItems).toEqual({ [mockIssue.id]: mockIssue });
});
});
@@ -545,7 +545,7 @@ describe('Board Store Mutations', () => {
expect(state.groupProjectsFlags.isLoading).toBe(true);
});
- it('Should set isLoading in groupProjectsFlags to true in state when fetchNext is true', () => {
+ it('Should set isLoadingMore in groupProjectsFlags to true in state when fetchNext is true', () => {
mutations[types.REQUEST_GROUP_PROJECTS](state, true);
expect(state.groupProjectsFlags.isLoadingMore).toBe(true);
diff --git a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap
index 0e1fe790771..b34265b7234 100644
--- a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap
+++ b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap
@@ -47,8 +47,26 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ
<!---->
<div
+ class="gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5"
+ >
+ <div
+ class="gl-display-flex"
+ >
+ <!---->
+ </div>
+
+ <div
+ class="gl-display-flex"
+ >
+ <!---->
+ </div>
+ </div>
+
+ <div
class="gl-new-dropdown-contents"
>
+ <!---->
+
<li
class="gl-new-dropdown-item"
role="presentation"
diff --git a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap
index 271c6356f7e..c2fa6556847 100644
--- a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap
+++ b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap
@@ -17,11 +17,15 @@ exports[`Confidential merge request project form group component renders empty s
No forks are available to you.
<br />
-
- <gl-sprintf-stub
- message="To protect this issue's confidentiality, %{forkLink} and set the fork's visibility to private."
- />
-
+ To protect this issue's confidentiality,
+ <a
+ class="help-link"
+ href="https://test.com"
+ target="_blank"
+ >
+ fork this project
+ </a>
+ and set the fork's visibility to private.
<gl-link-stub
class="w-auto p-0 d-inline-block text-primary bg-transparent"
href="/help"
@@ -52,18 +56,16 @@ exports[`Confidential merge request project form group component renders fork dr
</label>
<div>
- <!---->
+ <dropdown-stub
+ projects="[object Object],[object Object]"
+ selectedproject="[object Object]"
+ />
<p
class="text-muted mt-1 mb-0"
>
- No forks are available to you.
- <br />
-
- <gl-sprintf-stub
- message="To protect this issue's confidentiality, %{forkLink} and set the fork's visibility to private."
- />
+ To protect this issue's confidentiality, a private fork of this project was selected.
<gl-link-stub
class="w-auto p-0 d-inline-block text-primary bg-transparent"
diff --git a/spec/frontend/confidential_merge_request/components/project_form_group_spec.js b/spec/frontend/confidential_merge_request/components/project_form_group_spec.js
index 67f6d360f52..0e73d50fdb5 100644
--- a/spec/frontend/confidential_merge_request/components/project_form_group_spec.js
+++ b/spec/frontend/confidential_merge_request/components/project_form_group_spec.js
@@ -1,3 +1,4 @@
+import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import ProjectFormGroup from '~/confidential_merge_request/components/project_form_group.vue';
@@ -21,55 +22,52 @@ const mockData = [
},
},
];
-let vm;
+let wrapper;
let mock;
function factory(projects = mockData) {
mock = new MockAdapter(axios);
mock.onGet(/api\/(.*)\/projects\/gitlab-org%2Fgitlab-ce\/forks/).reply(200, projects);
- vm = shallowMount(ProjectFormGroup, {
+ wrapper = shallowMount(ProjectFormGroup, {
propsData: {
namespacePath: 'gitlab-org',
projectPath: 'gitlab-org/gitlab-ce',
newForkPath: 'https://test.com',
helpPagePath: '/help',
},
+ stubs: { GlSprintf },
});
+
+ return axios.waitForAll();
}
describe('Confidential merge request project form group component', () => {
afterEach(() => {
mock.restore();
- vm.destroy();
+ wrapper.destroy();
});
- it('renders fork dropdown', () => {
- factory();
+ it('renders fork dropdown', async () => {
+ await factory();
- return vm.vm.$nextTick(() => {
- expect(vm.element).toMatchSnapshot();
- });
+ expect(wrapper.element).toMatchSnapshot();
});
- it('sets selected project as first fork', () => {
- factory();
+ it('sets selected project as first fork', async () => {
+ await factory();
- return vm.vm.$nextTick(() => {
- expect(vm.vm.selectedProject).toEqual({
- id: 1,
- name: 'root / gitlab-ce',
- pathWithNamespace: 'root/gitlab-ce',
- namespaceFullpath: 'root',
- });
+ expect(wrapper.vm.selectedProject).toEqual({
+ id: 1,
+ name: 'root / gitlab-ce',
+ pathWithNamespace: 'root/gitlab-ce',
+ namespaceFullpath: 'root',
});
});
- it('renders empty state when response is empty', () => {
- factory([]);
+ it('renders empty state when response is empty', async () => {
+ await factory([]);
- return vm.vm.$nextTick(() => {
- expect(vm.element).toMatchSnapshot();
- });
+ expect(wrapper.element).toMatchSnapshot();
});
});
diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap
index 3c88c05a4b4..8f5516545eb 100644
--- a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap
+++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap
@@ -11,7 +11,16 @@ exports[`content_editor/components/toolbar_link_button renders dropdown componen
<ul role=\\"menu\\" tabindex=\\"-1\\" class=\\"dropdown-menu\\">
<div class=\\"gl-new-dropdown-inner\\">
<!---->
+ <div class=\\"gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5\\">
+ <div class=\\"gl-display-flex\\">
+ <!---->
+ </div>
+ <div class=\\"gl-display-flex\\">
+ <!---->
+ </div>
+ </div>
<div class=\\"gl-new-dropdown-contents\\">
+ <!---->
<li role=\\"presentation\\" class=\\"gl-px-3!\\">
<form tabindex=\\"-1\\" class=\\"b-dropdown-form gl-p-0\\">
<div placeholder=\\"Link URL\\">
diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js
index d516baf6f0f..3d1ef03083d 100644
--- a/spec/frontend/content_editor/components/content_editor_spec.js
+++ b/spec/frontend/content_editor/components/content_editor_spec.js
@@ -6,6 +6,7 @@ import ContentEditor from '~/content_editor/components/content_editor.vue';
import ContentEditorError from '~/content_editor/components/content_editor_error.vue';
import ContentEditorProvider from '~/content_editor/components/content_editor_provider.vue';
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
+import FormattingBubbleMenu from '~/content_editor/components/formatting_bubble_menu.vue';
import TopToolbar from '~/content_editor/components/top_toolbar.vue';
import {
LOADING_CONTENT_EVENT,
@@ -25,6 +26,7 @@ describe('ContentEditor', () => {
const findEditorElement = () => wrapper.findByTestId('content-editor');
const findEditorContent = () => wrapper.findComponent(EditorContent);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findBubbleMenu = () => wrapper.findComponent(FormattingBubbleMenu);
const createWrapper = (propsData = {}) => {
renderMarkdown = jest.fn();
@@ -131,6 +133,10 @@ describe('ContentEditor', () => {
it('hides EditorContent component', () => {
expect(findEditorContent().exists()).toBe(false);
});
+
+ it('hides formatting bubble menu', () => {
+ expect(findBubbleMenu().exists()).toBe(false);
+ });
});
describe('when loading content succeeds', () => {
@@ -171,5 +177,9 @@ describe('ContentEditor', () => {
it('displays EditorContent component', () => {
expect(findEditorContent().exists()).toBe(true);
});
+
+ it('displays formatting bubble menu', () => {
+ expect(findBubbleMenu().exists()).toBe(true);
+ });
});
});
diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js
new file mode 100644
index 00000000000..e48f59f6d9c
--- /dev/null
+++ b/spec/frontend/content_editor/components/wrappers/table_cell_base_spec.js
@@ -0,0 +1,193 @@
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { NodeViewWrapper } from '@tiptap/vue-2';
+import { selectedRect as getSelectedRect } from 'prosemirror-tables';
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue';
+import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../../test_utils';
+
+jest.mock('prosemirror-tables');
+
+describe('content/components/wrappers/table_cell_base', () => {
+ let wrapper;
+ let editor;
+ let getPos;
+
+ const createWrapper = async (propsData = { cellType: 'td' }) => {
+ wrapper = shallowMountExtended(TableCellBaseWrapper, {
+ propsData: {
+ editor,
+ getPos,
+ ...propsData,
+ },
+ });
+ };
+
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdownItemWithLabel = (name) =>
+ wrapper
+ .findAllComponents(GlDropdownItem)
+ .filter((dropdownItem) => dropdownItem.text().includes(name))
+ .at(0);
+ const findDropdownItemWithLabelExists = (name) =>
+ wrapper
+ .findAllComponents(GlDropdownItem)
+ .filter((dropdownItem) => dropdownItem.text().includes(name)).length > 0;
+ const setCurrentPositionInCell = () => {
+ const { $cursor } = editor.state.selection;
+
+ getPos.mockReturnValue($cursor.pos - $cursor.parentOffset - 1);
+ };
+ const mockDropdownHide = () => {
+ /*
+ * TODO: Replace this method with using the scoped hide function
+ * provided by BootstrapVue https://bootstrap-vue.org/docs/components/dropdown.
+ * GitLab UI is not exposing it in the default scope
+ */
+ findDropdown().vm.hide = jest.fn();
+ };
+
+ beforeEach(() => {
+ getPos = jest.fn();
+ editor = createTestEditor({});
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders a td node-view-wrapper with relative position', () => {
+ createWrapper();
+ expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('gl-relative');
+ expect(wrapper.findComponent(NodeViewWrapper).props().as).toBe('td');
+ });
+
+ it('displays dropdown when selection cursor is on the cell', async () => {
+ setCurrentPositionInCell();
+ createWrapper();
+
+ await nextTick();
+
+ expect(findDropdown().props()).toMatchObject({
+ category: 'tertiary',
+ icon: 'chevron-down',
+ size: 'small',
+ split: false,
+ });
+ expect(findDropdown().attributes()).toMatchObject({
+ boundary: 'viewport',
+ 'no-caret': '',
+ });
+ });
+
+ it('does not display dropdown when selection cursor is not on the cell', async () => {
+ createWrapper();
+
+ await nextTick();
+
+ expect(findDropdown().exists()).toBe(false);
+ });
+
+ describe('when dropdown is visible', () => {
+ beforeEach(async () => {
+ setCurrentPositionInCell();
+ getSelectedRect.mockReturnValue({
+ map: {
+ height: 1,
+ width: 1,
+ },
+ });
+
+ createWrapper();
+ await nextTick();
+
+ mockDropdownHide();
+ });
+
+ it.each`
+ dropdownItemLabel | commandName
+ ${'Insert column before'} | ${'addColumnBefore'}
+ ${'Insert column after'} | ${'addColumnAfter'}
+ ${'Insert row before'} | ${'addRowBefore'}
+ ${'Insert row after'} | ${'addRowAfter'}
+ ${'Delete table'} | ${'deleteTable'}
+ `(
+ 'executes $commandName when $dropdownItemLabel button is clicked',
+ ({ commandName, dropdownItemLabel }) => {
+ const mocks = mockChainedCommands(editor, [commandName, 'run']);
+
+ findDropdownItemWithLabel(dropdownItemLabel).vm.$emit('click');
+
+ expect(mocks[commandName]).toHaveBeenCalled();
+ },
+ );
+
+ it('does not allow deleting rows and columns', async () => {
+ expect(findDropdownItemWithLabelExists('Delete row')).toBe(false);
+ expect(findDropdownItemWithLabelExists('Delete column')).toBe(false);
+ });
+
+ it('allows deleting rows when there are more than 2 rows in the table', async () => {
+ const mocks = mockChainedCommands(editor, ['deleteRow', 'run']);
+
+ getSelectedRect.mockReturnValue({
+ map: {
+ height: 3,
+ },
+ });
+
+ emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' });
+
+ await nextTick();
+
+ findDropdownItemWithLabel('Delete row').vm.$emit('click');
+
+ expect(mocks.deleteRow).toHaveBeenCalled();
+ });
+
+ it('allows deleting columns when there are more than 1 column in the table', async () => {
+ const mocks = mockChainedCommands(editor, ['deleteColumn', 'run']);
+
+ getSelectedRect.mockReturnValue({
+ map: {
+ width: 2,
+ },
+ });
+
+ emitEditorEvent({ tiptapEditor: editor, event: 'selectionUpdate' });
+
+ await nextTick();
+
+ findDropdownItemWithLabel('Delete column').vm.$emit('click');
+
+ expect(mocks.deleteColumn).toHaveBeenCalled();
+ });
+
+ describe('when current row is the table’s header', () => {
+ beforeEach(async () => {
+ // Remove 2 rows condition
+ getSelectedRect.mockReturnValue({
+ map: {
+ height: 3,
+ },
+ });
+
+ createWrapper({ cellType: 'th' });
+
+ await nextTick();
+ });
+
+ it('does not allow adding a row before the header', async () => {
+ expect(findDropdownItemWithLabelExists('Insert row before')).toBe(false);
+ });
+
+ it('does not allow removing the header row', async () => {
+ createWrapper({ cellType: 'th' });
+
+ await nextTick();
+
+ expect(findDropdownItemWithLabelExists('Delete row')).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js
new file mode 100644
index 00000000000..5d26c44ba03
--- /dev/null
+++ b/spec/frontend/content_editor/components/wrappers/table_cell_body_spec.js
@@ -0,0 +1,37 @@
+import { shallowMount } from '@vue/test-utils';
+import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue';
+import TableCellBodyWrapper from '~/content_editor/components/wrappers/table_cell_body.vue';
+import { createTestEditor } from '../../test_utils';
+
+describe('content/components/wrappers/table_cell_body', () => {
+ let wrapper;
+ let editor;
+ let getPos;
+
+ const createWrapper = async () => {
+ wrapper = shallowMount(TableCellBodyWrapper, {
+ propsData: {
+ editor,
+ getPos,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ getPos = jest.fn();
+ editor = createTestEditor({});
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders a TableCellBase component', () => {
+ createWrapper();
+ expect(wrapper.findComponent(TableCellBaseWrapper).props()).toEqual({
+ editor,
+ getPos,
+ cellType: 'td',
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js b/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js
new file mode 100644
index 00000000000..e561191418d
--- /dev/null
+++ b/spec/frontend/content_editor/components/wrappers/table_cell_header_spec.js
@@ -0,0 +1,37 @@
+import { shallowMount } from '@vue/test-utils';
+import TableCellBaseWrapper from '~/content_editor/components/wrappers/table_cell_base.vue';
+import TableCellHeaderWrapper from '~/content_editor/components/wrappers/table_cell_header.vue';
+import { createTestEditor } from '../../test_utils';
+
+describe('content/components/wrappers/table_cell_header', () => {
+ let wrapper;
+ let editor;
+ let getPos;
+
+ const createWrapper = async () => {
+ wrapper = shallowMount(TableCellHeaderWrapper, {
+ propsData: {
+ editor,
+ getPos,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ getPos = jest.fn();
+ editor = createTestEditor({});
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders a TableCellBase component', () => {
+ createWrapper();
+ expect(wrapper.findComponent(TableCellBaseWrapper).props()).toEqual({
+ editor,
+ getPos,
+ cellType: 'th',
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js
index 1334b1ddaad..d4f05a25bd6 100644
--- a/spec/frontend/content_editor/extensions/attachment_spec.js
+++ b/spec/frontend/content_editor/extensions/attachment_spec.js
@@ -1,18 +1,23 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
-import { once } from 'lodash';
-import waitForPromises from 'helpers/wait_for_promises';
import Attachment from '~/content_editor/extensions/attachment';
import Image from '~/content_editor/extensions/image';
import Link from '~/content_editor/extensions/link';
import Loading from '~/content_editor/extensions/loading';
import httpStatus from '~/lib/utils/http_status';
-import { loadMarkdownApiResult } from '../markdown_processing_examples';
import { createTestEditor, createDocBuilder } from '../test_utils';
+const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="auto">
+ <a class="no-attachment-icon" href="/group1/project1/-/wikis/test-file.png" target="_blank" rel="noopener noreferrer" data-canonical-src="test-file.png">
+ <img alt="test-file" class="lazy" data-src="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png">
+ </a>
+</p>`;
+const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `<p data-sourcepos="1:1-1:26" dir="auto">
+ <a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a>
+</p>`;
+
describe('content_editor/extensions/attachment', () => {
let tiptapEditor;
- let eq;
let doc;
let p;
let image;
@@ -25,6 +30,24 @@ describe('content_editor/extensions/attachment', () => {
const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' });
const attachmentFile = new File(['foo'], 'test-file.zip', { type: 'application/zip' });
+ const expectDocumentAfterTransaction = ({ number, expectedDoc, action }) => {
+ return new Promise((resolve) => {
+ let counter = 1;
+ const handleTransaction = () => {
+ if (counter === number) {
+ expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
+ tiptapEditor.off('update', handleTransaction);
+ resolve();
+ }
+
+ counter += 1;
+ };
+
+ tiptapEditor.on('update', handleTransaction);
+ action();
+ });
+ };
+
beforeEach(() => {
renderMarkdown = jest.fn();
@@ -34,7 +57,6 @@ describe('content_editor/extensions/attachment', () => {
({
builders: { doc, p, image, loading, link },
- eq,
} = createDocBuilder({
tiptapEditor,
names: {
@@ -76,9 +98,7 @@ describe('content_editor/extensions/attachment', () => {
const base64EncodedFile = 'data:image/png;base64,Zm9v';
beforeEach(() => {
- renderMarkdown.mockResolvedValue(
- loadMarkdownApiResult('project_wiki_attachment_image').body,
- );
+ renderMarkdown.mockResolvedValue(PROJECT_WIKI_ATTACHMENT_IMAGE_HTML);
});
describe('when uploading succeeds', () => {
@@ -92,18 +112,14 @@ describe('content_editor/extensions/attachment', () => {
mock.onPost().reply(httpStatus.OK, successResponse);
});
- it('inserts an image with src set to the encoded image file and uploading true', (done) => {
+ it('inserts an image with src set to the encoded image file and uploading true', async () => {
const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile })));
- tiptapEditor.on(
- 'update',
- once(() => {
- expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
- done();
- }),
- );
-
- tiptapEditor.commands.uploadAttachment({ file: imageFile });
+ await expectDocumentAfterTransaction({
+ number: 1,
+ expectedDoc,
+ action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }),
+ });
});
it('updates the inserted image with canonicalSrc when upload is successful', async () => {
@@ -118,11 +134,11 @@ describe('content_editor/extensions/attachment', () => {
),
);
- tiptapEditor.commands.uploadAttachment({ file: imageFile });
-
- await waitForPromises();
-
- expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ await expectDocumentAfterTransaction({
+ number: 2,
+ expectedDoc,
+ action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }),
+ });
});
});
@@ -131,14 +147,14 @@ describe('content_editor/extensions/attachment', () => {
mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR);
});
- it('resets the doc to orginal state', async () => {
+ it('resets the doc to original state', async () => {
const expectedDoc = doc(p(''));
- tiptapEditor.commands.uploadAttachment({ file: imageFile });
-
- await waitForPromises();
-
- expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ await expectDocumentAfterTransaction({
+ number: 2,
+ expectedDoc,
+ action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }),
+ });
});
it('emits an error event that includes an error message', (done) => {
@@ -153,7 +169,7 @@ describe('content_editor/extensions/attachment', () => {
});
describe('when the file has a zip (or any other attachment) mime type', () => {
- const markdownApiResult = loadMarkdownApiResult('project_wiki_attachment_link').body;
+ const markdownApiResult = PROJECT_WIKI_ATTACHMENT_LINK_HTML;
beforeEach(() => {
renderMarkdown.mockResolvedValue(markdownApiResult);
@@ -170,18 +186,14 @@ describe('content_editor/extensions/attachment', () => {
mock.onPost().reply(httpStatus.OK, successResponse);
});
- it('inserts a loading mark', (done) => {
+ it('inserts a loading mark', async () => {
const expectedDoc = doc(p(loading({ label: 'test-file' })));
- tiptapEditor.on(
- 'update',
- once(() => {
- expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
- done();
- }),
- );
-
- tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
+ await expectDocumentAfterTransaction({
+ number: 1,
+ expectedDoc,
+ action: () => tiptapEditor.commands.uploadAttachment({ file: attachmentFile }),
+ });
});
it('updates the loading mark with a link with canonicalSrc and href attrs', async () => {
@@ -198,11 +210,11 @@ describe('content_editor/extensions/attachment', () => {
),
);
- tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
-
- await waitForPromises();
-
- expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ await expectDocumentAfterTransaction({
+ number: 2,
+ expectedDoc,
+ action: () => tiptapEditor.commands.uploadAttachment({ file: attachmentFile }),
+ });
});
});
@@ -214,11 +226,11 @@ describe('content_editor/extensions/attachment', () => {
it('resets the doc to orginal state', async () => {
const expectedDoc = doc(p(''));
- tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
-
- await waitForPromises();
-
- expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true);
+ await expectDocumentAfterTransaction({
+ number: 2,
+ expectedDoc,
+ action: () => tiptapEditor.commands.uploadAttachment({ file: attachmentFile }),
+ });
});
it('emits an error event that includes an error message', (done) => {
diff --git a/spec/frontend/content_editor/extensions/blockquote_spec.js b/spec/frontend/content_editor/extensions/blockquote_spec.js
new file mode 100644
index 00000000000..c5b5044352d
--- /dev/null
+++ b/spec/frontend/content_editor/extensions/blockquote_spec.js
@@ -0,0 +1,19 @@
+import { multilineInputRegex } from '~/content_editor/extensions/blockquote';
+
+describe('content_editor/extensions/blockquote', () => {
+ describe.each`
+ input | matches
+ ${'>>> '} | ${true}
+ ${' >>> '} | ${true}
+ ${'\t>>> '} | ${true}
+ ${'>> '} | ${false}
+ ${'>>>x '} | ${false}
+ ${'> '} | ${false}
+ `('multilineInputRegex', ({ input, matches }) => {
+ it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => {
+ const match = new RegExp(multilineInputRegex).test(input);
+
+ expect(match).toBe(matches);
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
index 188e6580dc6..6a0a0c76825 100644
--- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
+++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
@@ -1,9 +1,15 @@
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
-import { loadMarkdownApiResult } from '../markdown_processing_examples';
import { createTestEditor } from '../test_utils';
+const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true">
+ <code>
+ <span id="LC1" class="line" lang="javascript">
+ <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">hello world</span><span class="dl">'</span><span class="p">)</span>
+ </span>
+ </code>
+</pre>`;
+
describe('content_editor/extensions/code_block_highlight', () => {
- let codeBlockHtmlFixture;
let parsedCodeBlockHtmlFixture;
let tiptapEditor;
@@ -11,13 +17,10 @@ describe('content_editor/extensions/code_block_highlight', () => {
const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre');
beforeEach(() => {
- const { html } = loadMarkdownApiResult('code_block');
-
tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] });
- codeBlockHtmlFixture = html;
- parsedCodeBlockHtmlFixture = parseHTML(codeBlockHtmlFixture);
+ parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML);
- tiptapEditor.commands.setContent(codeBlockHtmlFixture);
+ tiptapEditor.commands.setContent(CODE_BLOCK_HTML);
});
it('extracts language and params attributes from Markdown API output', () => {
diff --git a/spec/frontend/content_editor/markdown_processing_examples.js b/spec/frontend/content_editor/markdown_processing_examples.js
index 12eed00f3c6..b3aabfeb145 100644
--- a/spec/frontend/content_editor/markdown_processing_examples.js
+++ b/spec/frontend/content_editor/markdown_processing_examples.js
@@ -6,7 +6,8 @@ import { getJSONFixture } from 'helpers/fixtures';
export const loadMarkdownApiResult = (testName) => {
const fixturePathPrefix = `api/markdown/${testName}.json`;
- return getJSONFixture(fixturePathPrefix);
+ const fixture = getJSONFixture(fixturePathPrefix);
+ return fixture.body || fixture.html;
};
export const loadMarkdownApiExamples = () => {
@@ -16,3 +17,9 @@ export const loadMarkdownApiExamples = () => {
return apiMarkdownExampleObjects.map(({ name, context, markdown }) => [name, context, markdown]);
};
+
+export const loadMarkdownApiExample = (testName) => {
+ return loadMarkdownApiExamples().find(([name, context]) => {
+ return (context ? `${context}_${name}` : name) === testName;
+ })[2];
+};
diff --git a/spec/frontend/content_editor/markdown_processing_spec.js b/spec/frontend/content_editor/markdown_processing_spec.js
index da3f6e64db8..71565768558 100644
--- a/spec/frontend/content_editor/markdown_processing_spec.js
+++ b/spec/frontend/content_editor/markdown_processing_spec.js
@@ -9,8 +9,9 @@ describe('markdown processing', () => {
'correctly handles %s (context: %s)',
async (name, context, markdown) => {
const testName = context ? `${context}_${name}` : name;
- const { html, body } = loadMarkdownApiResult(testName);
- const contentEditor = createContentEditor({ renderMarkdown: () => html || body });
+ const contentEditor = createContentEditor({
+ renderMarkdown: () => loadMarkdownApiResult(testName),
+ });
await contentEditor.setSerializedContent(markdown);
expect(contentEditor.getSerializedContent()).toBe(markdown);
diff --git a/spec/frontend/content_editor/services/mark_utils_spec.js b/spec/frontend/content_editor/services/mark_utils_spec.js
new file mode 100644
index 00000000000..bbfb8f26f99
--- /dev/null
+++ b/spec/frontend/content_editor/services/mark_utils_spec.js
@@ -0,0 +1,38 @@
+import {
+ markInputRegex,
+ extractMarkAttributesFromMatch,
+} from '~/content_editor/services/mark_utils';
+
+describe('content_editor/services/mark_utils', () => {
+ describe.each`
+ tag | input | matches
+ ${'tag'} | ${'<tag>hello</tag>'} | ${true}
+ ${'tag'} | ${'<tag title="tooltip">hello</tag>'} | ${true}
+ ${'kbd'} | ${'Hold <kbd>Ctrl</kbd>'} | ${true}
+ ${'time'} | ${'Lets meet at <time title="today" datetime="20:00">20:00</time>'} | ${true}
+ ${'tag'} | ${'<tag width=30 height=30>attrs not quoted</tag>'} | ${false}
+ ${'tag'} | ${"<tag title='abc'>single quote attrs not supported</tag>"} | ${false}
+ ${'tag'} | ${'<tag title>attr has no value</tag>'} | ${false}
+ ${'tag'} | ${'<tag>tag opened but not closed'} | ${false}
+ ${'tag'} | ${'</tag>tag closed before opened<tag>'} | ${false}
+ `('inputRegex("$tag")', ({ tag, input, matches }) => {
+ it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => {
+ const match = markInputRegex(tag).test(input);
+
+ expect(match).toBe(matches);
+ });
+ });
+
+ describe.each`
+ tag | input | attrs
+ ${'kbd'} | ${'Hold <kbd>Ctrl</kbd>'} | ${{}}
+ ${'tag'} | ${'<tag title="tooltip">hello</tag>'} | ${{ title: 'tooltip' }}
+ ${'time'} | ${'Lets meet at <time title="today" datetime="20:00">20:00</time>'} | ${{ title: 'today', datetime: '20:00' }}
+ ${'abbr'} | ${'Sure, you can try it out but <abbr title="Your mileage may vary">YMMV</abbr>'} | ${{ title: 'Your mileage may vary' }}
+ `('extractAttributesFromMatch(inputRegex("$tag").exec(\'$input\'))', ({ tag, input, attrs }) => {
+ it(`returns: "${JSON.stringify(attrs)}"`, () => {
+ const matches = markInputRegex(tag).exec(input);
+ expect(extractMarkAttributesFromMatch(matches)).toEqual(attrs);
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js
new file mode 100644
index 00000000000..6f2c908c289
--- /dev/null
+++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js
@@ -0,0 +1,1008 @@
+import Blockquote from '~/content_editor/extensions/blockquote';
+import Bold from '~/content_editor/extensions/bold';
+import BulletList from '~/content_editor/extensions/bullet_list';
+import Code from '~/content_editor/extensions/code';
+import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
+import DescriptionItem from '~/content_editor/extensions/description_item';
+import DescriptionList from '~/content_editor/extensions/description_list';
+import Division from '~/content_editor/extensions/division';
+import Emoji from '~/content_editor/extensions/emoji';
+import Figure from '~/content_editor/extensions/figure';
+import FigureCaption from '~/content_editor/extensions/figure_caption';
+import HardBreak from '~/content_editor/extensions/hard_break';
+import Heading from '~/content_editor/extensions/heading';
+import HorizontalRule from '~/content_editor/extensions/horizontal_rule';
+import Image from '~/content_editor/extensions/image';
+import InlineDiff from '~/content_editor/extensions/inline_diff';
+import Italic from '~/content_editor/extensions/italic';
+import Link from '~/content_editor/extensions/link';
+import ListItem from '~/content_editor/extensions/list_item';
+import OrderedList from '~/content_editor/extensions/ordered_list';
+import Paragraph from '~/content_editor/extensions/paragraph';
+import Strike from '~/content_editor/extensions/strike';
+import Table from '~/content_editor/extensions/table';
+import TableCell from '~/content_editor/extensions/table_cell';
+import TableHeader from '~/content_editor/extensions/table_header';
+import TableRow from '~/content_editor/extensions/table_row';
+import TaskItem from '~/content_editor/extensions/task_item';
+import TaskList from '~/content_editor/extensions/task_list';
+import Text from '~/content_editor/extensions/text';
+import markdownSerializer from '~/content_editor/services/markdown_serializer';
+import { createTestEditor, createDocBuilder } from '../test_utils';
+
+jest.mock('~/emoji');
+
+jest.mock('~/content_editor/services/feature_flags', () => ({
+ isBlockTablesFeatureEnabled: jest.fn().mockReturnValue(true),
+}));
+
+const tiptapEditor = createTestEditor({
+ extensions: [
+ Blockquote,
+ Bold,
+ BulletList,
+ Code,
+ CodeBlockHighlight,
+ DescriptionItem,
+ DescriptionList,
+ Division,
+ Emoji,
+ Figure,
+ FigureCaption,
+ HardBreak,
+ Heading,
+ HorizontalRule,
+ Image,
+ InlineDiff,
+ Italic,
+ Link,
+ ListItem,
+ OrderedList,
+ Paragraph,
+ Strike,
+ Table,
+ TableCell,
+ TableHeader,
+ TableRow,
+ TaskItem,
+ TaskList,
+ Text,
+ ],
+});
+
+const {
+ builders: {
+ doc,
+ blockquote,
+ bold,
+ bulletList,
+ code,
+ codeBlock,
+ division,
+ descriptionItem,
+ descriptionList,
+ emoji,
+ figure,
+ figureCaption,
+ heading,
+ hardBreak,
+ horizontalRule,
+ image,
+ inlineDiff,
+ italic,
+ link,
+ listItem,
+ orderedList,
+ paragraph,
+ strike,
+ table,
+ tableCell,
+ tableHeader,
+ tableRow,
+ taskItem,
+ taskList,
+ },
+} = createDocBuilder({
+ tiptapEditor,
+ names: {
+ blockquote: { nodeType: Blockquote.name },
+ bold: { markType: Bold.name },
+ bulletList: { nodeType: BulletList.name },
+ code: { markType: Code.name },
+ codeBlock: { nodeType: CodeBlockHighlight.name },
+ division: { nodeType: Division.name },
+ descriptionItem: { nodeType: DescriptionItem.name },
+ descriptionList: { nodeType: DescriptionList.name },
+ emoji: { markType: Emoji.name },
+ figure: { nodeType: Figure.name },
+ figureCaption: { nodeType: FigureCaption.name },
+ hardBreak: { nodeType: HardBreak.name },
+ heading: { nodeType: Heading.name },
+ horizontalRule: { nodeType: HorizontalRule.name },
+ image: { nodeType: Image.name },
+ inlineDiff: { markType: InlineDiff.name },
+ italic: { nodeType: Italic.name },
+ link: { markType: Link.name },
+ listItem: { nodeType: ListItem.name },
+ orderedList: { nodeType: OrderedList.name },
+ paragraph: { nodeType: Paragraph.name },
+ strike: { markType: Strike.name },
+ table: { nodeType: Table.name },
+ tableCell: { nodeType: TableCell.name },
+ tableHeader: { nodeType: TableHeader.name },
+ tableRow: { nodeType: TableRow.name },
+ taskItem: { nodeType: TaskItem.name },
+ taskList: { nodeType: TaskList.name },
+ },
+});
+
+const serialize = (...content) =>
+ markdownSerializer({}).serialize({
+ schema: tiptapEditor.schema,
+ content: doc(...content).toJSON(),
+ });
+
+describe('markdownSerializer', () => {
+ it('correctly serializes bold', () => {
+ expect(serialize(paragraph(bold('bold')))).toBe('**bold**');
+ });
+
+ it('correctly serializes italics', () => {
+ expect(serialize(paragraph(italic('italics')))).toBe('_italics_');
+ });
+
+ it('correctly serializes inline diff', () => {
+ expect(
+ serialize(
+ paragraph(
+ inlineDiff({ type: 'addition' }, '+30 lines'),
+ inlineDiff({ type: 'deletion' }, '-10 lines'),
+ ),
+ ),
+ ).toBe('{++30 lines+}{--10 lines-}');
+ });
+
+ it('correctly serializes a line break', () => {
+ expect(serialize(paragraph('hello', hardBreak(), 'world'))).toBe('hello\\\nworld');
+ });
+
+ it('correctly serializes a link', () => {
+ expect(serialize(paragraph(link({ href: 'https://example.com' }, 'example url')))).toBe(
+ '[example url](https://example.com)',
+ );
+ });
+
+ it('correctly serializes a plain URL link', () => {
+ expect(serialize(paragraph(link({ href: 'https://example.com' }, 'https://example.com')))).toBe(
+ '<https://example.com>',
+ );
+ });
+
+ it('correctly serializes a link with a title', () => {
+ expect(
+ serialize(
+ paragraph(link({ href: 'https://example.com', title: 'click this link' }, 'example url')),
+ ),
+ ).toBe('[example url](https://example.com "click this link")');
+ });
+
+ it('correctly serializes a plain URL link with a title', () => {
+ expect(
+ serialize(
+ paragraph(
+ link({ href: 'https://example.com', title: 'link title' }, 'https://example.com'),
+ ),
+ ),
+ ).toBe('[https://example.com](https://example.com "link title")');
+ });
+
+ it('correctly serializes a link with a canonicalSrc', () => {
+ expect(
+ serialize(
+ paragraph(
+ link(
+ {
+ href: '/uploads/abcde/file.zip',
+ canonicalSrc: 'file.zip',
+ title: 'click here to download',
+ },
+ 'download file',
+ ),
+ ),
+ ),
+ ).toBe('[download file](file.zip "click here to download")');
+ });
+
+ it('correctly serializes strikethrough', () => {
+ expect(serialize(paragraph(strike('deleted content')))).toBe('~~deleted content~~');
+ });
+
+ it('correctly serializes blockquotes with hard breaks', () => {
+ expect(serialize(blockquote('some text', hardBreak(), hardBreak(), 'new line'))).toBe(
+ `
+> some text\\
+> \\
+> new line
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes blockquote with multiple block nodes', () => {
+ expect(serialize(blockquote(paragraph('some paragraph'), codeBlock('var x = 10;')))).toBe(
+ `
+> some paragraph
+>
+> \`\`\`
+> var x = 10;
+> \`\`\`
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes a multiline blockquote', () => {
+ expect(
+ serialize(
+ blockquote(
+ { multiline: true },
+ paragraph('some paragraph with ', bold('bold')),
+ codeBlock('var y = 10;'),
+ ),
+ ),
+ ).toBe(
+ `
+>>>
+some paragraph with **bold**
+
+\`\`\`
+var y = 10;
+\`\`\`
+
+>>>
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes a code block with language', () => {
+ expect(
+ serialize(
+ codeBlock(
+ { language: 'json' },
+ 'this is not really json but just trying out whether this case works or not',
+ ),
+ ),
+ ).toBe(
+ `
+\`\`\`json
+this is not really json but just trying out whether this case works or not
+\`\`\`
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes emoji', () => {
+ expect(serialize(paragraph(emoji({ name: 'dog' })))).toBe(':dog:');
+ });
+
+ it('correctly serializes headings', () => {
+ expect(
+ serialize(
+ heading({ level: 1 }, 'Heading 1'),
+ heading({ level: 2 }, 'Heading 2'),
+ heading({ level: 3 }, 'Heading 3'),
+ heading({ level: 4 }, 'Heading 4'),
+ heading({ level: 5 }, 'Heading 5'),
+ heading({ level: 6 }, 'Heading 6'),
+ ),
+ ).toBe(
+ `
+# Heading 1
+
+## Heading 2
+
+### Heading 3
+
+#### Heading 4
+
+##### Heading 5
+
+###### Heading 6
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes horizontal rule', () => {
+ expect(serialize(horizontalRule(), horizontalRule(), horizontalRule())).toBe(
+ `
+---
+
+---
+
+---
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes an image', () => {
+ expect(serialize(paragraph(image({ src: 'img.jpg', alt: 'foo bar' })))).toBe(
+ '![foo bar](img.jpg)',
+ );
+ });
+
+ it('correctly serializes an image with a title', () => {
+ expect(serialize(paragraph(image({ src: 'img.jpg', title: 'baz', alt: 'foo bar' })))).toBe(
+ '![foo bar](img.jpg "baz")',
+ );
+ });
+
+ it('correctly serializes an image with a canonicalSrc', () => {
+ expect(
+ serialize(
+ paragraph(
+ image({
+ src: '/uploads/abcde/file.png',
+ alt: 'this is an image',
+ canonicalSrc: 'file.png',
+ title: 'foo bar baz',
+ }),
+ ),
+ ),
+ ).toBe('![this is an image](file.png "foo bar baz")');
+ });
+
+ it('correctly serializes bullet list', () => {
+ expect(
+ serialize(
+ bulletList(
+ listItem(paragraph('list item 1')),
+ listItem(paragraph('list item 2')),
+ listItem(paragraph('list item 3')),
+ ),
+ ),
+ ).toBe(
+ `
+* list item 1
+* list item 2
+* list item 3
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes bullet list with different bullet styles', () => {
+ expect(
+ serialize(
+ bulletList(
+ { bullet: '+' },
+ listItem(paragraph('list item 1')),
+ listItem(paragraph('list item 2')),
+ listItem(
+ paragraph('list item 3'),
+ bulletList(
+ { bullet: '-' },
+ listItem(paragraph('sub-list item 1')),
+ listItem(paragraph('sub-list item 2')),
+ ),
+ ),
+ ),
+ ),
+ ).toBe(
+ `
++ list item 1
++ list item 2
++ list item 3
+ - sub-list item 1
+ - sub-list item 2
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes a numeric list', () => {
+ expect(
+ serialize(
+ orderedList(
+ listItem(paragraph('list item 1')),
+ listItem(paragraph('list item 2')),
+ listItem(paragraph('list item 3')),
+ ),
+ ),
+ ).toBe(
+ `
+1. list item 1
+2. list item 2
+3. list item 3
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes a numeric list with parens', () => {
+ expect(
+ serialize(
+ orderedList(
+ { parens: true },
+ listItem(paragraph('list item 1')),
+ listItem(paragraph('list item 2')),
+ listItem(paragraph('list item 3')),
+ ),
+ ),
+ ).toBe(
+ `
+1) list item 1
+2) list item 2
+3) list item 3
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes a numeric list with a different start order', () => {
+ expect(
+ serialize(
+ orderedList(
+ { start: 17 },
+ listItem(paragraph('list item 1')),
+ listItem(paragraph('list item 2')),
+ listItem(paragraph('list item 3')),
+ ),
+ ),
+ ).toBe(
+ `
+17. list item 1
+18. list item 2
+19. list item 3
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes a numeric list with an invalid start order', () => {
+ expect(
+ serialize(
+ orderedList(
+ { start: NaN },
+ listItem(paragraph('list item 1')),
+ listItem(paragraph('list item 2')),
+ listItem(paragraph('list item 3')),
+ ),
+ ),
+ ).toBe(
+ `
+1. list item 1
+2. list item 2
+3. list item 3
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes a bullet list inside an ordered list', () => {
+ expect(
+ serialize(
+ orderedList(
+ { start: 17 },
+ listItem(paragraph('list item 1')),
+ listItem(paragraph('list item 2')),
+ listItem(
+ paragraph('list item 3'),
+ bulletList(
+ listItem(paragraph('sub-list item 1')),
+ listItem(paragraph('sub-list item 2')),
+ ),
+ ),
+ ),
+ ),
+ ).toBe(
+ // notice that 4 space indent works fine in this case,
+ // when it usually wouldn't
+ `
+17. list item 1
+18. list item 2
+19. list item 3
+ * sub-list item 1
+ * sub-list item 2
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes a task list', () => {
+ expect(
+ serialize(
+ taskList(
+ taskItem({ checked: true }, paragraph('list item 1')),
+ taskItem(paragraph('list item 2')),
+ taskItem(
+ paragraph('list item 3'),
+ taskList(
+ taskItem({ checked: true }, paragraph('sub-list item 1')),
+ taskItem(paragraph('sub-list item 2')),
+ ),
+ ),
+ ),
+ ),
+ ).toBe(
+ `
+* [x] list item 1
+* [ ] list item 2
+* [ ] list item 3
+ * [x] sub-list item 1
+ * [ ] sub-list item 2
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes a numeric task list + with start order', () => {
+ expect(
+ serialize(
+ taskList(
+ { numeric: true },
+ taskItem({ checked: true }, paragraph('list item 1')),
+ taskItem(paragraph('list item 2')),
+ taskItem(
+ paragraph('list item 3'),
+ taskList(
+ { numeric: true, start: 1351, parens: true },
+ taskItem({ checked: true }, paragraph('sub-list item 1')),
+ taskItem(paragraph('sub-list item 2')),
+ ),
+ ),
+ ),
+ ),
+ ).toBe(
+ `
+1. [x] list item 1
+2. [ ] list item 2
+3. [ ] list item 3
+ 1351) [x] sub-list item 1
+ 1352) [ ] sub-list item 2
+ `.trim(),
+ );
+ });
+
+ it('correctly renders a description list', () => {
+ expect(
+ serialize(
+ descriptionList(
+ descriptionItem(paragraph('Beast of Bodmin')),
+ descriptionItem({ isTerm: false }, paragraph('A large feline inhabiting Bodmin Moor.')),
+
+ descriptionItem(paragraph('Morgawr')),
+ descriptionItem({ isTerm: false }, paragraph('A sea serpent.')),
+
+ descriptionItem(paragraph('Owlman')),
+ descriptionItem(
+ { isTerm: false },
+ paragraph('A giant ', italic('owl-like'), ' creature.'),
+ ),
+ ),
+ ),
+ ).toBe(
+ `
+<dl>
+<dt>Beast of Bodmin</dt>
+<dd>A large feline inhabiting Bodmin Moor.</dd>
+<dt>Morgawr</dt>
+<dd>A sea serpent.</dd>
+<dt>Owlman</dt>
+<dd>
+
+A giant _owl-like_ creature.
+
+</dd>
+</dl>
+ `.trim(),
+ );
+ });
+
+ it('correctly renders div', () => {
+ expect(
+ serialize(
+ division(paragraph('just a paragraph in a div')),
+ division(paragraph('just some ', bold('styled'), ' ', italic('content'), ' in a div')),
+ ),
+ ).toBe(
+ '<div>just a paragraph in a div</div>\n<div>\n\njust some **styled** _content_ in a div\n\n</div>',
+ );
+ });
+
+ it('correctly renders figure', () => {
+ expect(
+ serialize(
+ figure(
+ paragraph(image({ src: 'elephant.jpg', alt: 'An elephant at sunset' })),
+ figureCaption('An elephant at sunset'),
+ ),
+ ),
+ ).toBe(
+ `
+<figure>
+
+![An elephant at sunset](elephant.jpg)
+
+<figcaption>An elephant at sunset</figcaption>
+</figure>
+ `.trim(),
+ );
+ });
+
+ it('correctly renders figure with styled caption', () => {
+ expect(
+ serialize(
+ figure(
+ paragraph(image({ src: 'elephant.jpg', alt: 'An elephant at sunset' })),
+ figureCaption(italic('An elephant at sunset')),
+ ),
+ ),
+ ).toBe(
+ `
+<figure>
+
+![An elephant at sunset](elephant.jpg)
+
+<figcaption>
+
+_An elephant at sunset_
+
+</figcaption>
+</figure>
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes a table with inline content', () => {
+ expect(
+ serialize(
+ table(
+ // each table cell must contain at least one paragraph
+ tableRow(
+ tableHeader(paragraph('header')),
+ tableHeader(paragraph('header')),
+ tableHeader(paragraph('header')),
+ ),
+ tableRow(
+ tableCell(paragraph('cell')),
+ tableCell(paragraph('cell')),
+ tableCell(paragraph('cell')),
+ ),
+ tableRow(
+ tableCell(paragraph('cell')),
+ tableCell(paragraph('cell')),
+ tableCell(paragraph('cell')),
+ ),
+ ),
+ ).trim(),
+ ).toBe(
+ `
+| header | header | header |
+|--------|--------|--------|
+| cell | cell | cell |
+| cell | cell | cell |
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes a table with line breaks', () => {
+ expect(
+ serialize(
+ table(
+ tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))),
+ tableRow(
+ tableCell(paragraph('cell with', hardBreak(), 'line', hardBreak(), 'breaks')),
+ tableCell(paragraph('cell')),
+ ),
+ tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
+ ),
+ ).trim(),
+ ).toBe(
+ `
+| header | header |
+|--------|--------|
+| cell with<br>line<br>breaks | cell |
+| cell | cell |
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes two consecutive tables', () => {
+ expect(
+ serialize(
+ table(
+ tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))),
+ tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
+ tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
+ ),
+ table(
+ tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))),
+ tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
+ tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
+ ),
+ ).trim(),
+ ).toBe(
+ `
+| header | header |
+|--------|--------|
+| cell | cell |
+| cell | cell |
+
+| header | header |
+|--------|--------|
+| cell | cell |
+| cell | cell |
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes a table with block content', () => {
+ expect(
+ serialize(
+ table(
+ tableRow(
+ tableHeader(paragraph('examples of')),
+ tableHeader(paragraph('block content')),
+ tableHeader(paragraph('in tables')),
+ tableHeader(paragraph('in content editor')),
+ ),
+ tableRow(
+ tableCell(heading({ level: 1 }, 'heading 1')),
+ tableCell(heading({ level: 2 }, 'heading 2')),
+ tableCell(paragraph(bold('just bold'))),
+ tableCell(paragraph(bold('bold'), ' ', italic('italic'), ' ', code('code'))),
+ ),
+ tableRow(
+ tableCell(
+ paragraph('all marks in three paragraphs:'),
+ paragraph('the ', bold('quick'), ' ', italic('brown'), ' ', code('fox')),
+ paragraph(
+ link({ href: '/home' }, 'jumps'),
+ ' over the ',
+ strike('lazy'),
+ ' ',
+ emoji({ name: 'dog' }),
+ ),
+ ),
+ tableCell(
+ paragraph(image({ src: 'img.jpg', alt: 'some image' }), hardBreak(), 'image content'),
+ ),
+ tableCell(
+ blockquote('some text', hardBreak(), hardBreak(), 'in a multiline blockquote'),
+ ),
+ tableCell(
+ codeBlock(
+ { language: 'javascript' },
+ 'var a = 2;\nvar b = 3;\nvar c = a + d;\n\nconsole.log(c);',
+ ),
+ ),
+ ),
+ tableRow(
+ tableCell(bulletList(listItem('item 1'), listItem('item 2'), listItem('item 2'))),
+ tableCell(orderedList(listItem('item 1'), listItem('item 2'), listItem('item 2'))),
+ tableCell(
+ paragraph('paragraphs separated by'),
+ horizontalRule(),
+ paragraph('a horizontal rule'),
+ ),
+ tableCell(
+ table(
+ tableRow(tableHeader(paragraph('table')), tableHeader(paragraph('inside'))),
+ tableRow(tableCell(paragraph('another')), tableCell(paragraph('table'))),
+ ),
+ ),
+ ),
+ ),
+ ).trim(),
+ ).toBe(
+ `
+<table>
+<tr>
+<th>examples of</th>
+<th>block content</th>
+<th>in tables</th>
+<th>in content editor</th>
+</tr>
+<tr>
+<td>
+
+# heading 1
+</td>
+<td>
+
+## heading 2
+</td>
+<td>
+
+**just bold**
+</td>
+<td>
+
+**bold** _italic_ \`code\`
+</td>
+</tr>
+<tr>
+<td>
+
+all marks in three paragraphs:
+
+the **quick** _brown_ \`fox\`
+
+[jumps](/home) over the ~~lazy~~ :dog:
+</td>
+<td>
+
+![some image](img.jpg)<br>image content
+</td>
+<td>
+
+> some text\\
+> \\
+> in a multiline blockquote
+</td>
+<td>
+
+\`\`\`javascript
+var a = 2;
+var b = 3;
+var c = a + d;
+
+console.log(c);
+\`\`\`
+</td>
+</tr>
+<tr>
+<td>
+
+* item 1
+* item 2
+* item 2
+</td>
+<td>
+
+1. item 1
+2. item 2
+3. item 2
+</td>
+<td>
+
+paragraphs separated by
+
+---
+
+a horizontal rule
+</td>
+<td>
+
+| table | inside |
+|-------|--------|
+| another | table |
+
+</td>
+</tr>
+</table>
+ `.trim(),
+ );
+ });
+
+ it('correctly renders content after a markdown table', () => {
+ expect(
+ serialize(
+ table(tableRow(tableHeader(paragraph('header'))), tableRow(tableCell(paragraph('cell')))),
+ heading({ level: 1 }, 'this is a heading'),
+ ).trim(),
+ ).toBe(
+ `
+| header |
+|--------|
+| cell |
+
+# this is a heading
+ `.trim(),
+ );
+ });
+
+ it('correctly renders content after an html table', () => {
+ expect(
+ serialize(
+ table(
+ tableRow(tableHeader(paragraph('header'))),
+ tableRow(tableCell(blockquote('hi'), paragraph('there'))),
+ ),
+ heading({ level: 1 }, 'this is a heading'),
+ ).trim(),
+ ).toBe(
+ `
+<table>
+<tr>
+<th>header</th>
+</tr>
+<tr>
+<td>
+
+> hi
+
+there
+</td>
+</tr>
+</table>
+
+# this is a heading
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes tables with misplaced header cells', () => {
+ expect(
+ serialize(
+ table(
+ tableRow(tableHeader(paragraph('cell')), tableCell(paragraph('cell'))),
+ tableRow(tableCell(paragraph('cell')), tableHeader(paragraph('cell'))),
+ ),
+ ).trim(),
+ ).toBe(
+ `
+<table>
+<tr>
+<th>cell</th>
+<td>cell</td>
+</tr>
+<tr>
+<td>cell</td>
+<th>cell</th>
+</tr>
+</table>
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes table without any headers', () => {
+ expect(
+ serialize(
+ table(
+ tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
+ tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
+ ),
+ ).trim(),
+ ).toBe(
+ `
+<table>
+<tr>
+<td>cell</td>
+<td>cell</td>
+</tr>
+<tr>
+<td>cell</td>
+<td>cell</td>
+</tr>
+</table>
+ `.trim(),
+ );
+ });
+
+ it('correctly serializes table with rowspan and colspan', () => {
+ expect(
+ serialize(
+ table(
+ tableRow(
+ tableHeader(paragraph('header')),
+ tableHeader(paragraph('header')),
+ tableHeader(paragraph('header')),
+ ),
+ tableRow(
+ tableCell({ colspan: 2 }, paragraph('cell with rowspan: 2')),
+ tableCell({ rowspan: 2 }, paragraph('cell')),
+ ),
+ tableRow(tableCell({ colspan: 2 }, paragraph('cell with rowspan: 2'))),
+ ),
+ ).trim(),
+ ).toBe(
+ `
+<table>
+<tr>
+<th>header</th>
+<th>header</th>
+<th>header</th>
+</tr>
+<tr>
+<td colspan="2">cell with rowspan: 2</td>
+<td rowspan="2">cell</td>
+</tr>
+<tr>
+<td colspan="2">cell with rowspan: 2</td>
+</tr>
+</table>
+ `.trim(),
+ );
+ });
+});
diff --git a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
new file mode 100644
index 00000000000..6f908f468f6
--- /dev/null
+++ b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
@@ -0,0 +1,81 @@
+import { Extension } from '@tiptap/core';
+import BulletList from '~/content_editor/extensions/bullet_list';
+import ListItem from '~/content_editor/extensions/list_item';
+import Paragraph from '~/content_editor/extensions/paragraph';
+import markdownSerializer from '~/content_editor/services/markdown_serializer';
+import { getMarkdownSource } from '~/content_editor/services/markdown_sourcemap';
+import { createTestEditor, createDocBuilder } from '../test_utils';
+
+const BULLET_LIST_MARKDOWN = `+ list item 1
++ list item 2
+ - embedded list item 3`;
+const BULLET_LIST_HTML = `<ul data-sourcepos="1:1-3:24" dir="auto">
+ <li data-sourcepos="1:1-1:13">list item 1</li>
+ <li data-sourcepos="2:1-3:24">list item 2
+ <ul data-sourcepos="3:3-3:24">
+ <li data-sourcepos="3:3-3:24">embedded list item 3</li>
+ </ul>
+ </li>
+</ul>`;
+
+const SourcemapExtension = Extension.create({
+ // lets add `source` attribute to every element using `getMarkdownSource`
+ addGlobalAttributes() {
+ return [
+ {
+ types: [Paragraph.name, BulletList.name, ListItem.name],
+ attributes: {
+ source: {
+ parseHTML: (element) => {
+ const source = getMarkdownSource(element);
+ return source;
+ },
+ },
+ },
+ },
+ ];
+ },
+});
+
+const tiptapEditor = createTestEditor({
+ extensions: [BulletList, ListItem, SourcemapExtension],
+});
+
+const {
+ builders: { doc, bulletList, listItem, paragraph },
+} = createDocBuilder({
+ tiptapEditor,
+ names: {
+ bulletList: { nodeType: BulletList.name },
+ listItem: { nodeType: ListItem.name },
+ },
+});
+
+describe('content_editor/services/markdown_sourcemap', () => {
+ it('gets markdown source for a rendered HTML element', async () => {
+ const deserialized = await markdownSerializer({
+ render: () => BULLET_LIST_HTML,
+ serializerConfig: {},
+ }).deserialize({
+ schema: tiptapEditor.schema,
+ content: BULLET_LIST_MARKDOWN,
+ });
+
+ const expected = doc(
+ bulletList(
+ { bullet: '+', source: '+ list item 1\n+ list item 2' },
+ listItem({ source: '+ list item 1' }, paragraph('list item 1')),
+ listItem(
+ { source: '+ list item 2' },
+ paragraph('list item 2'),
+ bulletList(
+ { bullet: '-', source: '- embedded list item 3' },
+ listItem({ source: '- embedded list item 3' }, paragraph('embedded list item 3')),
+ ),
+ ),
+ ),
+ );
+
+ expect(deserialized).toEqual(expected.toJSON());
+ });
+});
diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js
index b5a2abc2389..cf5aa3f2938 100644
--- a/spec/frontend/content_editor/test_utils.js
+++ b/spec/frontend/content_editor/test_utils.js
@@ -98,9 +98,7 @@ export const createTestContentEditorExtension = ({ commands = [] } = {}) => {
return {
labelName: {
default: null,
- parseHTML: (element) => {
- return { labelName: element.dataset.labelName };
- },
+ parseHTML: (element) => element.dataset.labelName,
},
};
},
diff --git a/spec/frontend/cycle_analytics/banner_spec.js b/spec/frontend/cycle_analytics/banner_spec.js
deleted file mode 100644
index ef7998c5ff5..00000000000
--- a/spec/frontend/cycle_analytics/banner_spec.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import Banner from '~/cycle_analytics/components/banner.vue';
-
-describe('Value Stream Analytics banner', () => {
- let wrapper;
-
- const createComponent = () => {
- wrapper = shallowMount(Banner, {
- propsData: {
- documentationLink: 'path',
- },
- });
- };
-
- beforeEach(() => {
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('should render value stream analytics information', () => {
- expect(wrapper.find('h4').text().trim()).toBe('Introducing Value Stream Analytics');
-
- expect(
- wrapper
- .find('p')
- .text()
- .trim()
- .replace(/[\r\n]+/g, ' '),
- ).toContain(
- 'Value Stream Analytics gives an overview of how much time it takes to go from idea to production in your project.',
- );
-
- expect(wrapper.find('a').text().trim()).toBe('Read more');
- expect(wrapper.find('a').attributes('href')).toBe('path');
- });
-
- it('should emit an event when close button is clicked', async () => {
- jest.spyOn(wrapper.vm, '$emit').mockImplementation(() => {});
-
- await wrapper.find('.js-ca-dismiss-button').trigger('click');
-
- expect(wrapper.vm.$emit).toHaveBeenCalled();
- });
-});
diff --git a/spec/frontend/cycle_analytics/base_spec.js b/spec/frontend/cycle_analytics/base_spec.js
index 71830eed3ef..5d3361bfa35 100644
--- a/spec/frontend/cycle_analytics/base_spec.js
+++ b/spec/frontend/cycle_analytics/base_spec.js
@@ -6,6 +6,7 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import BaseComponent from '~/cycle_analytics/components/base.vue';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue';
+import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import { NOT_ENOUGH_DATA_ERROR } from '~/cycle_analytics/constants';
import initState from '~/cycle_analytics/store/state';
@@ -30,13 +31,14 @@ Vue.use(Vuex);
let wrapper;
+const { id: groupId, path: groupPath } = currentGroup;
const defaultState = {
permissions,
currentGroup,
createdBefore,
createdAfter,
stageCounts,
- endpoints: { fullPath },
+ endpoints: { fullPath, groupId, groupPath },
};
function createStore({ initialState = {}, initialGetters = {} }) {
@@ -74,6 +76,7 @@ function createComponent({ initialState, initialGetters } = {}) {
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findPathNavigation = () => wrapper.findComponent(PathNavigation);
+const findFilters = () => wrapper.findComponent(ValueStreamFilters);
const findOverviewMetrics = () => wrapper.findComponent(ValueStreamMetrics);
const findStageTable = () => wrapper.findComponent(StageTable);
const findStageEvents = () => findStageTable().props('stageEvents');
@@ -123,6 +126,29 @@ describe('Value stream analytics component', () => {
expect(findStageEvents()).toEqual(selectedStageEvents);
});
+ it('renders the filters', () => {
+ expect(findFilters().exists()).toBe(true);
+ });
+
+ it('displays the date range selector and hides the project selector', () => {
+ expect(findFilters().props()).toMatchObject({
+ hasProjectFilter: false,
+ hasDateRangeFilter: true,
+ });
+ });
+
+ it('passes the paths to the filter bar', () => {
+ expect(findFilters().props()).toEqual({
+ groupId,
+ groupPath,
+ endDate: createdBefore,
+ hasDateRangeFilter: true,
+ hasProjectFilter: false,
+ selectedProjects: [],
+ startDate: createdAfter,
+ });
+ });
+
it('does not render the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
diff --git a/spec/frontend/cycle_analytics/stage_table_spec.js b/spec/frontend/cycle_analytics/stage_table_spec.js
index 47a2ce4444b..3158446c37d 100644
--- a/spec/frontend/cycle_analytics/stage_table_spec.js
+++ b/spec/frontend/cycle_analytics/stage_table_spec.js
@@ -22,6 +22,7 @@ const findStageEvents = () => wrapper.findAllByTestId('vsa-stage-event');
const findPagination = () => wrapper.findByTestId('vsa-stage-pagination');
const findTable = () => wrapper.findComponent(GlTable);
const findTableHead = () => wrapper.find('thead');
+const findTableHeadColumns = () => findTableHead().findAll('th');
const findStageEventTitle = (ev) => extendedWrapper(ev).findByTestId('vsa-stage-event-title');
const findStageTime = () => wrapper.findByTestId('vsa-stage-event-time');
const findIcon = (name) => wrapper.findByTestId(`${name}-icon`);
@@ -244,6 +245,12 @@ describe('StageTable', () => {
wrapper.destroy();
});
+ it('can sort the table by each column', () => {
+ findTableHeadColumns().wrappers.forEach((w) => {
+ expect(w.attributes('aria-sort')).toBe('none');
+ });
+ });
+
it('clicking a table column will send tracking information', () => {
triggerTableSort();
@@ -275,5 +282,17 @@ describe('StageTable', () => {
},
]);
});
+
+ describe('with sortable=false', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ sortable: false });
+ });
+
+ it('cannot sort the table', () => {
+ findTableHeadColumns().wrappers.forEach((w) => {
+ expect(w.attributes('aria-sort')).toBeUndefined();
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/cycle_analytics/store/actions_spec.js b/spec/frontend/cycle_analytics/store/actions_spec.js
index 915a828ff19..97b5bd03e18 100644
--- a/spec/frontend/cycle_analytics/store/actions_spec.js
+++ b/spec/frontend/cycle_analytics/store/actions_spec.js
@@ -4,21 +4,41 @@ import testAction from 'helpers/vuex_action_helper';
import * as actions from '~/cycle_analytics/store/actions';
import * as getters from '~/cycle_analytics/store/getters';
import httpStatusCodes from '~/lib/utils/http_status';
-import { allowedStages, selectedStage, selectedValueStream } from '../mock_data';
-
+import {
+ allowedStages,
+ selectedStage,
+ selectedValueStream,
+ currentGroup,
+ createdAfter,
+ createdBefore,
+} from '../mock_data';
+
+const { id: groupId, path: groupPath } = currentGroup;
+const mockMilestonesPath = 'mock-milestones.json';
+const mockLabelsPath = 'mock-labels.json';
const mockRequestPath = 'some/cool/path';
const mockFullPath = '/namespace/-/analytics/value_stream_analytics/value_streams';
-const mockStartDate = 30;
-const mockEndpoints = { fullPath: mockFullPath, requestPath: mockRequestPath };
-const mockSetDateActionCommit = { payload: { startDate: mockStartDate }, type: 'SET_DATE_RANGE' };
-
-const defaultState = { ...getters, selectedValueStream };
+const mockEndpoints = {
+ fullPath: mockFullPath,
+ requestPath: mockRequestPath,
+ labelsPath: mockLabelsPath,
+ milestonesPath: mockMilestonesPath,
+ groupId,
+ groupPath,
+};
+const mockSetDateActionCommit = {
+ payload: { createdAfter, createdBefore },
+ type: 'SET_DATE_RANGE',
+};
+
+const defaultState = { ...getters, selectedValueStream, createdAfter, createdBefore };
describe('Project Value Stream Analytics actions', () => {
let state;
let mock;
beforeEach(() => {
+ state = { ...defaultState };
mock = new MockAdapter(axios);
});
@@ -34,16 +54,17 @@ describe('Project Value Stream Analytics actions', () => {
{ type: 'fetchCycleAnalyticsData' },
{ type: 'fetchStageData' },
{ type: 'fetchStageMedians' },
+ { type: 'fetchStageCountValues' },
{ type: 'setLoading', payload: false },
];
describe.each`
- action | payload | expectedActions | expectedMutations
- ${'setLoading'} | ${true} | ${[]} | ${[{ type: 'SET_LOADING', payload: true }]}
- ${'setDateRange'} | ${{ startDate: mockStartDate }} | ${mockFetchStageDataActions} | ${[mockSetDateActionCommit]}
- ${'setFilters'} | ${[]} | ${mockFetchStageDataActions} | ${[]}
- ${'setSelectedStage'} | ${{ selectedStage }} | ${[{ type: 'fetchStageData' }]} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]}
- ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${[{ type: 'fetchValueStreamStages' }, { type: 'fetchCycleAnalyticsData' }]} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]}
+ action | payload | expectedActions | expectedMutations
+ ${'setLoading'} | ${true} | ${[]} | ${[{ type: 'SET_LOADING', payload: true }]}
+ ${'setDateRange'} | ${{ createdAfter, createdBefore }} | ${mockFetchStageDataActions} | ${[mockSetDateActionCommit]}
+ ${'setFilters'} | ${[]} | ${mockFetchStageDataActions} | ${[]}
+ ${'setSelectedStage'} | ${{ selectedStage }} | ${[{ type: 'fetchStageData' }]} | ${[{ type: 'SET_SELECTED_STAGE', payload: { selectedStage } }]}
+ ${'setSelectedValueStream'} | ${{ selectedValueStream }} | ${[{ type: 'fetchValueStreamStages' }, { type: 'fetchCycleAnalyticsData' }]} | ${[{ type: 'SET_SELECTED_VALUE_STREAM', payload: { selectedValueStream } }]}
`('$action', ({ action, payload, expectedActions, expectedMutations }) => {
const types = mutationTypes(expectedMutations);
it(`will dispatch ${expectedActions} and commit ${types}`, () =>
@@ -60,6 +81,12 @@ describe('Project Value Stream Analytics actions', () => {
let mockDispatch;
let mockCommit;
const payload = { endpoints: mockEndpoints };
+ const mockFilterEndpoints = {
+ groupEndpoint: 'foo',
+ labelsEndpoint: mockLabelsPath,
+ milestonesEndpoint: mockMilestonesPath,
+ projectEndpoint: '/namespace/-/analytics/value_stream_analytics/value_streams',
+ };
beforeEach(() => {
mockDispatch = jest.fn(() => Promise.resolve());
@@ -76,6 +103,9 @@ describe('Project Value Stream Analytics actions', () => {
payload,
);
expect(mockCommit).toHaveBeenCalledWith('INITIALIZE_VSA', { endpoints: mockEndpoints });
+
+ expect(mockDispatch).toHaveBeenCalledTimes(4);
+ expect(mockDispatch).toHaveBeenCalledWith('filters/setEndpoints', mockFilterEndpoints);
expect(mockDispatch).toHaveBeenCalledWith('setLoading', true);
expect(mockDispatch).toHaveBeenCalledWith('fetchValueStreams');
expect(mockDispatch).toHaveBeenCalledWith('setLoading', false);
@@ -84,7 +114,7 @@ describe('Project Value Stream Analytics actions', () => {
describe('fetchCycleAnalyticsData', () => {
beforeEach(() => {
- state = { endpoints: mockEndpoints };
+ state = { ...defaultState, endpoints: mockEndpoints };
mock = new MockAdapter(axios);
mock.onGet(mockRequestPath).reply(httpStatusCodes.OK);
});
@@ -129,7 +159,6 @@ describe('Project Value Stream Analytics actions', () => {
state = {
...defaultState,
endpoints: mockEndpoints,
- startDate: mockStartDate,
selectedStage,
};
mock = new MockAdapter(axios);
@@ -152,7 +181,6 @@ describe('Project Value Stream Analytics actions', () => {
state = {
...defaultState,
endpoints: mockEndpoints,
- startDate: mockStartDate,
selectedStage,
};
mock = new MockAdapter(axios);
@@ -177,7 +205,6 @@ describe('Project Value Stream Analytics actions', () => {
state = {
...defaultState,
endpoints: mockEndpoints,
- startDate: mockStartDate,
selectedStage,
};
mock = new MockAdapter(axios);
diff --git a/spec/frontend/cycle_analytics/store/mutations_spec.js b/spec/frontend/cycle_analytics/store/mutations_spec.js
index 7fcfef98547..628e2a4e7ae 100644
--- a/spec/frontend/cycle_analytics/store/mutations_spec.js
+++ b/spec/frontend/cycle_analytics/store/mutations_spec.js
@@ -1,5 +1,4 @@
import { useFakeDate } from 'helpers/fake_date';
-import { DEFAULT_DAYS_TO_DISPLAY } from '~/cycle_analytics/constants';
import * as types from '~/cycle_analytics/store/mutation_types';
import mutations from '~/cycle_analytics/store/mutations';
import {
@@ -65,15 +64,16 @@ describe('Project Value Stream Analytics mutations', () => {
expect(state).toMatchObject({ [stateKey]: value });
});
+ const mockSetDatePayload = { createdAfter: mockCreatedAfter, createdBefore: mockCreatedBefore };
const mockInitialPayload = {
endpoints: { requestPath: mockRequestPath },
currentGroup: { title: 'cool-group' },
id: 1337,
+ ...mockSetDatePayload,
};
const mockInitializedObj = {
endpoints: { requestPath: mockRequestPath },
- createdAfter: mockCreatedAfter,
- createdBefore: mockCreatedBefore,
+ ...mockSetDatePayload,
};
it.each`
@@ -89,9 +89,8 @@ describe('Project Value Stream Analytics mutations', () => {
it.each`
mutation | payload | stateKey | value
- ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'daysInPast'} | ${DEFAULT_DAYS_TO_DISPLAY}
- ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter}
- ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore}
+ ${types.SET_DATE_RANGE} | ${mockSetDatePayload} | ${'createdAfter'} | ${mockCreatedAfter}
+ ${types.SET_DATE_RANGE} | ${mockSetDatePayload} | ${'createdBefore'} | ${mockCreatedBefore}
${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true}
${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false}
${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream}
diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js
index 168ddcfeacc..403d0dce3fc 100644
--- a/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js
+++ b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js
@@ -1,3 +1,4 @@
+import { GlModal } from '@gitlab/ui';
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue';
@@ -29,6 +30,8 @@ describe('Deploy freeze table', () => {
const findAddDeployFreezeButton = () => wrapper.find('[data-testid="add-deploy-freeze"]');
const findEditDeployFreezeButton = () => wrapper.find('[data-testid="edit-deploy-freeze"]');
const findDeployFreezeTable = () => wrapper.find('[data-testid="deploy-freeze-table"]');
+ const findDeleteDeployFreezeButton = () => wrapper.find('[data-testid="delete-deploy-freeze"]');
+ const findDeleteDeployFreezeModal = () => wrapper.findComponent(GlModal);
beforeEach(() => {
createComponent();
@@ -73,6 +76,29 @@ describe('Deploy freeze table', () => {
store.state.freezePeriods[0],
);
});
+
+ it('displays delete deploy freeze button', () => {
+ expect(findDeleteDeployFreezeButton().exists()).toBe(true);
+ });
+
+ it('confirms a user wants to delete a deploy freeze', async () => {
+ const [{ freezeStart, freezeEnd, cronTimezone }] = store.state.freezePeriods;
+ await findDeleteDeployFreezeButton().trigger('click');
+ const modal = findDeleteDeployFreezeModal();
+ expect(modal.text()).toContain(
+ `Deploy freeze from ${freezeStart} to ${freezeEnd} in ${cronTimezone.formattedTimezone} will be removed.`,
+ );
+ });
+
+ it('deletes the freeze period on confirmation', async () => {
+ await findDeleteDeployFreezeButton().trigger('click');
+ const modal = findDeleteDeployFreezeModal();
+ modal.vm.$emit('primary');
+ expect(store.dispatch).toHaveBeenCalledWith(
+ 'deleteFreezePeriod',
+ store.state.freezePeriods[0],
+ );
+ });
});
});
diff --git a/spec/frontend/deploy_freeze/helpers.js b/spec/frontend/deploy_freeze/helpers.js
index bfb84142662..598f14d45f6 100644
--- a/spec/frontend/deploy_freeze/helpers.js
+++ b/spec/frontend/deploy_freeze/helpers.js
@@ -1,7 +1,7 @@
import { secondsToHours } from '~/lib/utils/datetime_utility';
export const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json');
-export const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json');
+export const timezoneDataFixture = getJSONFixture('/timezones/short.json');
export const findTzByName = (identifier = '') =>
timezoneDataFixture.find(({ name }) => name.toLowerCase() === identifier.toLowerCase());
diff --git a/spec/frontend/deploy_freeze/store/actions_spec.js b/spec/frontend/deploy_freeze/store/actions_spec.js
index 6bc9c4d374c..ad67afdce75 100644
--- a/spec/frontend/deploy_freeze/store/actions_spec.js
+++ b/spec/frontend/deploy_freeze/store/actions_spec.js
@@ -5,6 +5,7 @@ import * as actions from '~/deploy_freeze/store/actions';
import * as types from '~/deploy_freeze/store/mutation_types';
import getInitialState from '~/deploy_freeze/store/state';
import createFlash from '~/flash';
+import * as logger from '~/lib/logger';
import axios from '~/lib/utils/axios_utils';
import { freezePeriodsFixture, timezoneDataFixture } from '../helpers';
@@ -12,6 +13,7 @@ jest.mock('~/api.js');
jest.mock('~/flash.js');
describe('deploy freeze store actions', () => {
+ const freezePeriodFixture = freezePeriodsFixture[0];
let mock;
let state;
@@ -24,6 +26,7 @@ describe('deploy freeze store actions', () => {
Api.freezePeriods.mockResolvedValue({ data: freezePeriodsFixture });
Api.createFreezePeriod.mockResolvedValue();
Api.updateFreezePeriod.mockResolvedValue();
+ Api.deleteFreezePeriod.mockResolvedValue();
});
afterEach(() => {
@@ -195,4 +198,46 @@ describe('deploy freeze store actions', () => {
);
});
});
+
+ describe('deleteFreezePeriod', () => {
+ it('dispatch correct actions on deleting a freeze period', () => {
+ testAction(
+ actions.deleteFreezePeriod,
+ freezePeriodFixture,
+ state,
+ [
+ { type: 'REQUEST_DELETE_FREEZE_PERIOD', payload: freezePeriodFixture.id },
+ { type: 'RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS', payload: freezePeriodFixture.id },
+ ],
+ [],
+ () =>
+ expect(Api.deleteFreezePeriod).toHaveBeenCalledWith(
+ state.projectId,
+ freezePeriodFixture.id,
+ ),
+ );
+ });
+
+ it('should show flash error and set error in state on delete failure', () => {
+ jest.spyOn(logger, 'logError').mockImplementation();
+ const error = new Error();
+ Api.deleteFreezePeriod.mockRejectedValue(error);
+
+ testAction(
+ actions.deleteFreezePeriod,
+ freezePeriodFixture,
+ state,
+ [
+ { type: 'REQUEST_DELETE_FREEZE_PERIOD', payload: freezePeriodFixture.id },
+ { type: 'RECEIVE_DELETE_FREEZE_PERIOD_ERROR', payload: freezePeriodFixture.id },
+ ],
+ [],
+ () => {
+ expect(createFlash).toHaveBeenCalled();
+
+ expect(logger.logError).toHaveBeenCalledWith('Unable to delete deploy freeze', error);
+ },
+ );
+ });
+ });
});
diff --git a/spec/frontend/deploy_freeze/store/mutations_spec.js b/spec/frontend/deploy_freeze/store/mutations_spec.js
index f8683489340..878a755088c 100644
--- a/spec/frontend/deploy_freeze/store/mutations_spec.js
+++ b/spec/frontend/deploy_freeze/store/mutations_spec.js
@@ -28,9 +28,9 @@ describe('Deploy freeze mutations', () => {
describe('RECEIVE_FREEZE_PERIODS_SUCCESS', () => {
it('should set freeze periods and format timezones from identifiers to names', () => {
const timezoneNames = {
- 'Europe/Berlin': 'Berlin',
- 'Etc/UTC': 'UTC',
- 'America/New_York': 'Eastern Time (US & Canada)',
+ 'Europe/Berlin': '[UTC 2] Berlin',
+ 'Etc/UTC': '[UTC 0] UTC',
+ 'America/New_York': '[UTC -4] Eastern Time (US & Canada)',
};
mutations[types.RECEIVE_FREEZE_PERIODS_SUCCESS](stateCopy, freezePeriodsFixture);
diff --git a/spec/frontend/deprecated_jquery_dropdown_spec.js b/spec/frontend/deprecated_jquery_dropdown_spec.js
index 7858f88f8c3..4a6dee31cd5 100644
--- a/spec/frontend/deprecated_jquery_dropdown_spec.js
+++ b/spec/frontend/deprecated_jquery_dropdown_spec.js
@@ -323,7 +323,7 @@ describe('deprecatedJQueryDropdown', () => {
const li = dropdown.renderItem(item, null, 3);
const link = li.querySelector('a');
- expect(link).toHaveAttr('data-track-event', 'click_text');
+ expect(link).toHaveAttr('data-track-action', 'click_text');
expect(link).toHaveAttr('data-track-label', 'some_value_for_label');
expect(link).toHaveAttr('data-track-value', '3');
expect(link).toHaveAttr('data-track-property', 'suggestion-category');
diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap
index d9f5ba0bade..4dc8eaea174 100644
--- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap
+++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap
@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design reply form component renders button text as "Comment" when creating a comment 1`] = `
-"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\">
+"<button data-track-action=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\">
<!---->
<!----> <span class=\\"gl-button-text\\">
Comment
@@ -9,7 +9,7 @@ exports[`Design reply form component renders button text as "Comment" when creat
`;
exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = `
-"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\">
+"<button data-track-action=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\">
<!---->
<!----> <span class=\\"gl-button-text\\">
Save comment
diff --git a/spec/frontend/design_management/components/design_scaler_spec.js b/spec/frontend/design_management/components/design_scaler_spec.js
index 8a123b2d1e5..095c070e5e8 100644
--- a/spec/frontend/design_management/components/design_scaler_spec.js
+++ b/spec/frontend/design_management/components/design_scaler_spec.js
@@ -13,7 +13,11 @@ describe('Design management design scaler component', () => {
const setScale = (scale) => wrapper.vm.setScale(scale);
const createComponent = () => {
- wrapper = shallowMount(DesignScaler);
+ wrapper = shallowMount(DesignScaler, {
+ propsData: {
+ maxScale: 2,
+ },
+ });
};
beforeEach(() => {
@@ -61,6 +65,18 @@ describe('Design management design scaler component', () => {
expect(wrapper.emitted('scale')).toEqual([[1.2]]);
});
+ it('computes & increments correct stepSize based on maxScale', async () => {
+ wrapper.setProps({ maxScale: 11 });
+
+ await wrapper.vm.$nextTick();
+
+ getIncreaseScaleButton().vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted().scale[0][0]).toBe(3);
+ });
+
describe('when `scale` value is 1', () => {
it('disables the "reset" button', () => {
const resetButton = getResetScaleButton();
@@ -77,7 +93,7 @@ describe('Design management design scaler component', () => {
});
});
- describe('when `scale` value is 2 (maximum)', () => {
+ describe('when `scale` value is maximum', () => {
beforeEach(async () => {
setScale(2);
await wrapper.vm.$nextTick();
diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap
index 637f22457c4..67e4a82787c 100644
--- a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap
+++ b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap
@@ -3,10 +3,14 @@
exports[`Design management design version dropdown component renders design version dropdown button 1`] = `
<gl-dropdown-stub
category="primary"
+ clearalltext="Clear all"
headertext=""
hideheaderborder="true"
+ highlighteditemstitle="Selected"
+ highlighteditemstitleclass="gl-px-5"
issueiid=""
projectpath=""
+ showhighlighteditemstitle="true"
size="small"
text="Showing latest version"
variant="default"
@@ -80,10 +84,14 @@ exports[`Design management design version dropdown component renders design vers
exports[`Design management design version dropdown component renders design version list 1`] = `
<gl-dropdown-stub
category="primary"
+ clearalltext="Clear all"
headertext=""
hideheaderborder="true"
+ highlighteditemstitle="Selected"
+ highlighteditemstitleclass="gl-px-5"
issueiid=""
projectpath=""
+ showhighlighteditemstitle="true"
size="small"
text="Showing latest version"
variant="default"
diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
index 57023c55878..3d04840b1f8 100644
--- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
@@ -25,7 +25,9 @@ exports[`Design management design index page renders design index 1`] = `
<div
class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center"
>
- <design-scaler-stub />
+ <design-scaler-stub
+ maxscale="2"
+ />
</div>
</div>
@@ -186,7 +188,9 @@ exports[`Design management design index page with error GlAlert is rendered in c
<div
class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center"
>
- <design-scaler-stub />
+ <design-scaler-stub
+ maxscale="2"
+ />
</div>
</div>
diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js
index 1332e872246..6ce384b4869 100644
--- a/spec/frontend/design_management/pages/design/index_spec.js
+++ b/spec/frontend/design_management/pages/design/index_spec.js
@@ -390,28 +390,13 @@ describe('Design management design index page', () => {
);
});
- describe('with usage_data_design_action enabled', () => {
- it('tracks design view service ping', () => {
- createComponent(
- { loading: true },
- {
- provide: {
- glFeatures: { usageDataDesignAction: true },
- },
- },
- );
- expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1);
- expect(Api.trackRedisHllUserEvent).toHaveBeenCalledWith(
- DESIGN_SERVICE_PING_EVENT_TYPES.DESIGN_ACTION,
- );
- });
- });
+ it('tracks design view service ping', () => {
+ createComponent({ loading: true });
- describe('with usage_data_design_action disabled', () => {
- it("doesn't track design view service ping", () => {
- createComponent({ loading: true });
- expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(0);
- });
+ expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1);
+ expect(Api.trackRedisHllUserEvent).toHaveBeenCalledWith(
+ DESIGN_SERVICE_PING_EVENT_TYPES.DESIGN_ACTION,
+ );
});
});
});
diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js
index 95cb1ac943c..ce79feae2e7 100644
--- a/spec/frontend/design_management/pages/index_spec.js
+++ b/spec/frontend/design_management/pages/index_spec.js
@@ -338,6 +338,13 @@ describe('Design management index page', () => {
__typename: 'DesignVersion',
id: expect.anything(),
sha: expect.anything(),
+ createdAt: '',
+ author: {
+ __typename: 'UserCore',
+ id: expect.anything(),
+ name: '',
+ avatarUrl: '',
+ },
},
},
},
@@ -623,6 +630,16 @@ describe('Design management index page', () => {
expect(mockMutate).not.toHaveBeenCalled();
});
+ it('does not upload designs if designs wrapper is destroyed', () => {
+ findDesignsWrapper().trigger('mouseenter');
+
+ wrapper.destroy();
+
+ document.dispatchEvent(event);
+
+ expect(mockMutate).not.toHaveBeenCalled();
+ });
+
describe('when designs wrapper is hovered', () => {
let realDateNow;
const today = () => new Date('2020-12-25');
diff --git a/spec/frontend/design_management/utils/design_management_utils_spec.js b/spec/frontend/design_management/utils/design_management_utils_spec.js
index 5b7f99e9d96..dc6056badb9 100644
--- a/spec/frontend/design_management/utils/design_management_utils_spec.js
+++ b/spec/frontend/design_management/utils/design_management_utils_spec.js
@@ -101,7 +101,13 @@ describe('optimistic responses', () => {
discussions: { __typename: 'DesignDiscussion', nodes: [] },
versions: {
__typename: 'DesignVersionConnection',
- nodes: { __typename: 'DesignVersion', id: -1, sha: -1 },
+ nodes: {
+ __typename: 'DesignVersion',
+ id: expect.anything(),
+ sha: expect.anything(),
+ createdAt: '',
+ author: { __typename: 'UserCore', avatarUrl: '', name: '', id: expect.anything() },
+ },
},
},
],
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index 1464dd84666..9dc82bbdc93 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -183,7 +183,7 @@ describe('diffs/components/app', () => {
it('displays loading icon on batch loading', () => {
createComponent({}, ({ state }) => {
- state.diffs.isBatchLoading = true;
+ state.diffs.batchLoadingState = 'loading';
});
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
@@ -705,24 +705,4 @@ describe('diffs/components/app', () => {
);
});
});
-
- describe('diff file tree is aware of review bar', () => {
- it('it does not have review-bar-visible class when review bar is not visible', () => {
- createComponent({}, ({ state }) => {
- state.diffs.diffFiles = [{ file_hash: '111', file_path: '111.js' }];
- });
-
- expect(wrapper.find('.js-diff-tree-list').exists()).toBe(true);
- expect(wrapper.find('.js-diff-tree-list.review-bar-visible').exists()).toBe(false);
- });
-
- it('it does have review-bar-visible class when review bar is visible', () => {
- createComponent({}, ({ state }) => {
- state.diffs.diffFiles = [{ file_hash: '111', file_path: '111.js' }];
- state.batchComments.drafts = ['draft message'];
- });
-
- expect(wrapper.find('.js-diff-tree-list.review-bar-visible').exists()).toBe(true);
- });
- });
});
diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js
index 3dec56f2fe3..feb7118744b 100644
--- a/spec/frontend/diffs/components/diff_file_spec.js
+++ b/spec/frontend/diffs/components/diff_file_spec.js
@@ -242,32 +242,20 @@ describe('DiffFile', () => {
});
it.each`
- loggedIn | featureOn | bool
- ${true} | ${true} | ${true}
- ${false} | ${true} | ${false}
- ${true} | ${false} | ${false}
- ${false} | ${false} | ${false}
- `(
- 'should be $bool when { userIsLoggedIn: $loggedIn, featureEnabled: $featureOn }',
- ({ loggedIn, featureOn, bool }) => {
- setLoggedIn(loggedIn);
-
- ({ wrapper } = createComponent({
- options: {
- provide: {
- glFeatures: {
- localFileReviews: featureOn,
- },
- },
- },
- props: {
- file: store.state.diffs.diffFiles[0],
- },
- }));
+ loggedIn | bool
+ ${true} | ${true}
+ ${false} | ${false}
+ `('should be $bool when { userIsLoggedIn: $loggedIn }', ({ loggedIn, bool }) => {
+ setLoggedIn(loggedIn);
+
+ ({ wrapper } = createComponent({
+ props: {
+ file: store.state.diffs.diffFiles[0],
+ },
+ }));
- expect(wrapper.vm.showLocalFileReviews).toBe(bool);
- },
- );
+ expect(wrapper.vm.showLocalFileReviews).toBe(bool);
+ });
});
});
diff --git a/spec/frontend/diffs/create_diffs_store.js b/spec/frontend/diffs/create_diffs_store.js
index e6a8b7a72ae..307ebdaa4ac 100644
--- a/spec/frontend/diffs/create_diffs_store.js
+++ b/spec/frontend/diffs/create_diffs_store.js
@@ -9,6 +9,12 @@ Vue.use(Vuex);
export default function createDiffsStore() {
return new Vuex.Store({
modules: {
+ page: {
+ namespaced: true,
+ state: {
+ activeTab: 'notes',
+ },
+ },
diffs: diffsModule(),
notes: notesModule(),
batchComments: batchCommentsModule(),
diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js
index 6d005b868a9..b35abc9da02 100644
--- a/spec/frontend/diffs/store/actions_spec.js
+++ b/spec/frontend/diffs/store/actions_spec.js
@@ -186,15 +186,16 @@ describe('DiffsStoreActions', () => {
{},
{ endpointBatch, diffViewType: 'inline' },
[
- { type: types.SET_BATCH_LOADING, payload: true },
+ { type: types.SET_BATCH_LOADING_STATE, payload: 'loading' },
{ type: types.SET_RETRIEVING_BATCHES, payload: true },
{ type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res1.diff_files } },
- { type: types.SET_BATCH_LOADING, payload: false },
+ { type: types.SET_BATCH_LOADING_STATE, payload: 'loaded' },
{ type: types.VIEW_DIFF_FILE, payload: 'test' },
{ type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res2.diff_files } },
- { type: types.SET_BATCH_LOADING, payload: false },
+ { type: types.SET_BATCH_LOADING_STATE, payload: 'loaded' },
{ type: types.VIEW_DIFF_FILE, payload: 'test2' },
{ type: types.SET_RETRIEVING_BATCHES, payload: false },
+ { type: types.SET_BATCH_LOADING_STATE, payload: 'error' },
],
[{ type: 'startRenderDiffsQueue' }, { type: 'startRenderDiffsQueue' }],
done,
diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js
index b549ca42634..fc9ba223d5a 100644
--- a/spec/frontend/diffs/store/mutations_spec.js
+++ b/spec/frontend/diffs/store/mutations_spec.js
@@ -31,13 +31,13 @@ describe('DiffsStoreMutations', () => {
});
});
- describe('SET_BATCH_LOADING', () => {
+ describe('SET_BATCH_LOADING_STATE', () => {
it('should set loading state', () => {
const state = {};
- mutations[types.SET_BATCH_LOADING](state, false);
+ mutations[types.SET_BATCH_LOADING_STATE](state, false);
- expect(state.isBatchLoading).toEqual(false);
+ expect(state.batchLoadingState).toEqual(false);
});
});
diff --git a/spec/frontend/diffs/utils/preferences_spec.js b/spec/frontend/diffs/utils/preferences_spec.js
deleted file mode 100644
index 2dcc71dc188..00000000000
--- a/spec/frontend/diffs/utils/preferences_spec.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import Cookies from 'js-cookie';
-import {
- DIFF_FILE_BY_FILE_COOKIE_NAME,
- DIFF_VIEW_FILE_BY_FILE,
- DIFF_VIEW_ALL_FILES,
-} from '~/diffs/constants';
-import { fileByFile } from '~/diffs/utils/preferences';
-
-describe('diffs preferences', () => {
- describe('fileByFile', () => {
- afterEach(() => {
- Cookies.remove(DIFF_FILE_BY_FILE_COOKIE_NAME);
- });
-
- it.each`
- 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 }',
- ({ result, preference, cookie }) => {
- Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, cookie);
-
- expect(fileByFile(preference)).toBe(result);
- },
- );
- });
-});
diff --git a/spec/frontend/dropzone_input_spec.js b/spec/frontend/dropzone_input_spec.js
index 5e6ccbd7cda..acf7d0780cd 100644
--- a/spec/frontend/dropzone_input_spec.js
+++ b/spec/frontend/dropzone_input_spec.js
@@ -1,9 +1,12 @@
+import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
import mock from 'xhr-mock';
import waitForPromises from 'helpers/wait_for_promises';
import { TEST_HOST } from 'spec/test_constants';
import PasteMarkdownTable from '~/behaviors/markdown/paste_markdown_table';
import dropzoneInput from '~/dropzone_input';
+import axios from '~/lib/utils/axios_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
const TEST_FILE = new File([], 'somefile.jpg');
TEST_FILE.upload = {};
@@ -29,6 +32,16 @@ describe('dropzone_input', () => {
});
describe('handlePaste', () => {
+ const triggerPasteEvent = (clipboardData = {}) => {
+ const event = $.Event('paste');
+ const origEvent = new Event('paste');
+
+ origEvent.clipboardData = clipboardData;
+ event.originalEvent = origEvent;
+
+ $('.js-gfm-input').trigger(event);
+ };
+
beforeEach(() => {
loadFixtures('issues/new-issue.html');
@@ -38,24 +51,39 @@ describe('dropzone_input', () => {
});
it('pastes Markdown tables', () => {
- const event = $.Event('paste');
- const origEvent = new Event('paste');
+ jest.spyOn(PasteMarkdownTable.prototype, 'isTable');
+ jest.spyOn(PasteMarkdownTable.prototype, 'convertToTableMarkdown');
- origEvent.clipboardData = {
+ triggerPasteEvent({
types: ['text/plain', 'text/html'],
getData: () => '<table><tr><td>Hello World</td></tr></table>',
items: [],
- };
- event.originalEvent = origEvent;
-
- jest.spyOn(PasteMarkdownTable.prototype, 'isTable');
- jest.spyOn(PasteMarkdownTable.prototype, 'convertToTableMarkdown');
-
- $('.js-gfm-input').trigger(event);
+ });
expect(PasteMarkdownTable.prototype.isTable).toHaveBeenCalled();
expect(PasteMarkdownTable.prototype.convertToTableMarkdown).toHaveBeenCalled();
});
+
+ it('passes truncated long filename to post request', async () => {
+ const axiosMock = new MockAdapter(axios);
+ const longFileName = 'a'.repeat(300);
+
+ triggerPasteEvent({
+ types: ['text/plain', 'text/html', 'text/rtf', 'Files'],
+ getData: () => longFileName,
+ items: [
+ {
+ kind: 'file',
+ type: 'image/png',
+ getAsFile: () => new Blob(),
+ },
+ ],
+ });
+
+ axiosMock.onPost().reply(httpStatusCodes.OK, { link: { markdown: 'foo' } });
+ await waitForPromises();
+ expect(axiosMock.history.post[0].data.get('file').name).toHaveLength(246);
+ });
});
describe('shows error message', () => {
diff --git a/spec/frontend/emoji/index_spec.js b/spec/frontend/emoji/index_spec.js
index 1e6f5483160..9652c513671 100644
--- a/spec/frontend/emoji/index_spec.js
+++ b/spec/frontend/emoji/index_spec.js
@@ -9,6 +9,7 @@ import isEmojiUnicodeSupported, {
isHorceRacingSkinToneComboEmoji,
isPersonZwjEmoji,
} from '~/emoji/support/is_emoji_unicode_supported';
+import { sanitize } from '~/lib/dompurify';
const emptySupportMap = {
personZwj: false,
@@ -379,7 +380,7 @@ describe('emoji', () => {
describe('searchEmoji', () => {
const emojiFixture = Object.keys(mockEmojiData).reduce((acc, k) => {
const { name, e, u, d } = mockEmojiData[k];
- acc[k] = { name, e, u, d };
+ acc[k] = { name, e: sanitize(e), u, d };
return acc;
}, {});
@@ -397,6 +398,7 @@ describe('emoji', () => {
'heart',
'custard',
'star',
+ 'xss',
].map((name) => {
return {
emoji: emojiFixture[name],
@@ -620,4 +622,13 @@ describe('emoji', () => {
expect(sortEmoji(scoredItems)).toEqual(expected);
});
});
+
+ describe('sanitize emojis', () => {
+ it('should return sanitized emoji', () => {
+ expect(getEmojiInfo('xss')).toEqual({
+ ...mockEmojiData.xss,
+ e: '<img src="x">',
+ });
+ });
+ });
});
diff --git a/spec/frontend/emoji/support/unicode_support_map_spec.js b/spec/frontend/emoji/support/unicode_support_map_spec.js
index 945e804a9fa..37f74db30b5 100644
--- a/spec/frontend/emoji/support/unicode_support_map_spec.js
+++ b/spec/frontend/emoji/support/unicode_support_map_spec.js
@@ -8,14 +8,14 @@ describe('Unicode Support Map', () => {
const stringSupportMap = 'stringSupportMap';
beforeEach(() => {
- jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockImplementation(() => {});
+ jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockImplementation(() => {});
jest.spyOn(JSON, 'parse').mockImplementation(() => {});
jest.spyOn(JSON, 'stringify').mockReturnValue(stringSupportMap);
});
describe('if isLocalStorageAvailable is `true`', () => {
beforeEach(() => {
- jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(true);
+ jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true);
getUnicodeSupportMap();
});
@@ -38,7 +38,7 @@ describe('Unicode Support Map', () => {
describe('if isLocalStorageAvailable is `false`', () => {
beforeEach(() => {
- jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(false);
+ jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false);
getUnicodeSupportMap();
});
diff --git a/spec/frontend/environments/edit_environment_spec.js b/spec/frontend/environments/edit_environment_spec.js
index 3e7f5dd5ff4..2c8c054ccbd 100644
--- a/spec/frontend/environments/edit_environment_spec.js
+++ b/spec/frontend/environments/edit_environment_spec.js
@@ -15,15 +15,12 @@ const DEFAULT_OPTS = {
projectEnvironmentsPath: '/projects/environments',
updateEnvironmentPath: '/proejcts/environments/1',
},
- propsData: { environment: { name: 'foo', externalUrl: 'https://foo.example.com' } },
+ propsData: { environment: { id: '0', name: 'foo', external_url: 'https://foo.example.com' } },
};
describe('~/environments/components/edit.vue', () => {
let wrapper;
let mock;
- let name;
- let url;
- let form;
const createWrapper = (opts = {}) =>
mountExtended(EditEnvironment, {
@@ -34,9 +31,6 @@ describe('~/environments/components/edit.vue', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createWrapper();
- name = wrapper.findByLabelText('Name');
- url = wrapper.findByLabelText('External URL');
- form = wrapper.findByRole('form', { name: 'Edit environment' });
});
afterEach(() => {
@@ -44,19 +38,22 @@ describe('~/environments/components/edit.vue', () => {
wrapper.destroy();
});
+ const findNameInput = () => wrapper.findByLabelText('Name');
+ const findExternalUrlInput = () => wrapper.findByLabelText('External URL');
+ const findForm = () => wrapper.findByRole('form', { name: 'Edit environment' });
+
const showsLoading = () => wrapper.find(GlLoadingIcon).exists();
const submitForm = async (expected, response) => {
mock
.onPut(DEFAULT_OPTS.provide.updateEnvironmentPath, {
- name: expected.name,
external_url: expected.url,
+ id: '0',
})
.reply(...response);
- await name.setValue(expected.name);
- await url.setValue(expected.url);
+ await findExternalUrlInput().setValue(expected.url);
- await form.trigger('submit');
+ await findForm().trigger('submit');
await waitForPromises();
};
@@ -65,18 +62,8 @@ describe('~/environments/components/edit.vue', () => {
expect(header.exists()).toBe(true);
});
- it.each`
- input | value
- ${() => name} | ${'test'}
- ${() => url} | ${'https://example.org'}
- `('it changes the value of the input to $value', async ({ input, value }) => {
- await input().setValue(value);
-
- expect(input().element.value).toBe(value);
- });
-
it('shows loader after form is submitted', async () => {
- const expected = { name: 'test', url: 'https://google.ca' };
+ const expected = { url: 'https://google.ca' };
expect(showsLoading()).toBe(false);
@@ -86,7 +73,7 @@ describe('~/environments/components/edit.vue', () => {
});
it('submits the updated environment on submit', async () => {
- const expected = { name: 'test', url: 'https://google.ca' };
+ const expected = { url: 'https://google.ca' };
await submitForm(expected, [200, { path: '/test' }]);
@@ -94,11 +81,24 @@ describe('~/environments/components/edit.vue', () => {
});
it('shows errors on error', async () => {
- const expected = { name: 'test', url: 'https://google.ca' };
+ const expected = { url: 'https://google.ca' };
- await submitForm(expected, [400, { message: ['name taken'] }]);
+ await submitForm(expected, [400, { message: ['uh oh!'] }]);
- expect(createFlash).toHaveBeenCalledWith({ message: 'name taken' });
+ expect(createFlash).toHaveBeenCalledWith({ message: 'uh oh!' });
expect(showsLoading()).toBe(false);
});
+
+ it('renders a disabled "Name" field', () => {
+ const nameInput = findNameInput();
+
+ expect(nameInput.attributes().disabled).toBe('disabled');
+ expect(nameInput.element.value).toBe('foo');
+ });
+
+ it('renders an "External URL" field', () => {
+ const urlInput = findExternalUrlInput();
+
+ expect(urlInput.element.value).toBe('https://foo.example.com');
+ });
});
diff --git a/spec/frontend/environments/environment_form_spec.js b/spec/frontend/environments/environment_form_spec.js
index ed8fda71dab..f1af08bcf32 100644
--- a/spec/frontend/environments/environment_form_spec.js
+++ b/spec/frontend/environments/environment_form_spec.js
@@ -102,4 +102,52 @@ describe('~/environments/components/form.vue', () => {
wrapper = createWrapper({ loading: true });
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
+ describe('when a new environment is being created', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ environment: {
+ name: '',
+ externalUrl: '',
+ },
+ });
+ });
+
+ it('renders an enabled "Name" field', () => {
+ const nameInput = wrapper.findByLabelText('Name');
+
+ expect(nameInput.attributes().disabled).toBeUndefined();
+ expect(nameInput.element.value).toBe('');
+ });
+
+ it('renders an "External URL" field', () => {
+ const urlInput = wrapper.findByLabelText('External URL');
+
+ expect(urlInput.element.value).toBe('');
+ });
+ });
+
+ describe('when an existing environment is being edited', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({
+ environment: {
+ id: 1,
+ name: 'test',
+ externalUrl: 'https://example.com',
+ },
+ });
+ });
+
+ it('renders a disabled "Name" field', () => {
+ const nameInput = wrapper.findByLabelText('Name');
+
+ expect(nameInput.attributes().disabled).toBe('disabled');
+ expect(nameInput.element.value).toBe('test');
+ });
+
+ it('renders an "External URL" field', () => {
+ const urlInput = wrapper.findByLabelText('External URL');
+
+ expect(urlInput.element.value).toBe('https://example.com');
+ });
+ });
});
diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js
index a568a7d5396..b930259149f 100644
--- a/spec/frontend/environments/environment_item_spec.js
+++ b/spec/frontend/environments/environment_item_spec.js
@@ -31,7 +31,6 @@ describe('Environment item', () => {
factory({
propsData: {
model: environment,
- canReadEnvironment: true,
tableData,
},
});
@@ -135,7 +134,6 @@ describe('Environment item', () => {
factory({
propsData: {
model: environmentWithoutDeployable,
- canReadEnvironment: true,
tableData,
},
});
@@ -161,7 +159,6 @@ describe('Environment item', () => {
factory({
propsData: {
model: environmentWithoutUpcomingDeployment,
- canReadEnvironment: true,
tableData,
},
});
@@ -177,7 +174,6 @@ describe('Environment item', () => {
factory({
propsData: {
model: environment,
- canReadEnvironment: true,
tableData,
shouldShowAutoStopDate: true,
},
@@ -205,7 +201,6 @@ describe('Environment item', () => {
...environment,
auto_stop_at: futureDate,
},
- canReadEnvironment: true,
tableData,
shouldShowAutoStopDate: true,
},
@@ -241,7 +236,6 @@ describe('Environment item', () => {
...environment,
auto_stop_at: pastDate,
},
- canReadEnvironment: true,
tableData,
shouldShowAutoStopDate: true,
},
@@ -360,7 +354,6 @@ describe('Environment item', () => {
factory({
propsData: {
model: folder,
- canReadEnvironment: true,
tableData,
},
});
diff --git a/spec/frontend/environments/environment_table_spec.js b/spec/frontend/environments/environment_table_spec.js
index 71426ee5170..1851163ac68 100644
--- a/spec/frontend/environments/environment_table_spec.js
+++ b/spec/frontend/environments/environment_table_spec.js
@@ -28,7 +28,6 @@ describe('Environment table', () => {
factory({
propsData: {
environments: [folder],
- canReadEnvironment: true,
...eeOnlyProps,
},
});
@@ -50,7 +49,6 @@ describe('Environment table', () => {
await factory({
propsData: {
environments: [mockItem],
- canReadEnvironment: true,
userCalloutsPath: '/callouts',
lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',
helpCanaryDeploymentsPath: 'help/canary-deployments',
@@ -78,7 +76,6 @@ describe('Environment table', () => {
propsData: {
environments: [mockItem],
canCreateDeployment: false,
- canReadEnvironment: true,
userCalloutsPath: '/callouts',
lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',
helpCanaryDeploymentsPath: 'help/canary-deployments',
@@ -114,7 +111,6 @@ describe('Environment table', () => {
propsData: {
environments: [mockItem],
canCreateDeployment: false,
- canReadEnvironment: true,
userCalloutsPath: '/callouts',
lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',
helpCanaryDeploymentsPath: 'help/canary-deployments',
@@ -151,7 +147,6 @@ describe('Environment table', () => {
factory({
propsData: {
environments: [mockItem],
- canReadEnvironment: true,
userCalloutsPath: '/callouts',
lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',
helpCanaryDeploymentsPath: 'help/canary-deployments',
@@ -179,7 +174,6 @@ describe('Environment table', () => {
propsData: {
environments: [mockItem],
canCreateDeployment: false,
- canReadEnvironment: true,
userCalloutsPath: '/callouts',
lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',
helpCanaryDeploymentsPath: 'help/canary-deployments',
@@ -230,7 +224,6 @@ describe('Environment table', () => {
factory({
propsData: {
environments: mockItems,
- canReadEnvironment: true,
...eeOnlyProps,
},
});
@@ -296,7 +289,6 @@ describe('Environment table', () => {
factory({
propsData: {
environments: mockItems,
- canReadEnvironment: true,
...eeOnlyProps,
},
});
@@ -335,7 +327,6 @@ describe('Environment table', () => {
factory({
propsData: {
environments: mockItems,
- canReadEnvironment: true,
...eeOnlyProps,
},
});
@@ -364,7 +355,6 @@ describe('Environment table', () => {
factory({
propsData: {
environments: mockItems,
- canReadEnvironment: true,
...eeOnlyProps,
},
});
@@ -415,7 +405,6 @@ describe('Environment table', () => {
factory({
propsData: {
environments: mockItems,
- canReadEnvironment: true,
...eeOnlyProps,
},
});
diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js
index dc176001943..cd05ecbfb53 100644
--- a/spec/frontend/environments/environments_app_spec.js
+++ b/spec/frontend/environments/environments_app_spec.js
@@ -1,4 +1,4 @@
-import { GlTabs, GlAlert } from '@gitlab/ui';
+import { GlTabs } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
@@ -7,9 +7,7 @@ import DeployBoard from '~/environments/components/deploy_board.vue';
import EmptyState from '~/environments/components/empty_state.vue';
import EnableReviewAppModal from '~/environments/components/enable_review_app_modal.vue';
import EnvironmentsApp from '~/environments/components/environments_app.vue';
-import { ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME } from '~/environments/constants';
import axios from '~/lib/utils/axios_utils';
-import { setCookie, getCookie, removeCookie } from '~/lib/utils/common_utils';
import * as urlUtils from '~/lib/utils/url_utility';
import { environment, folder } from './mock_data';
@@ -20,7 +18,6 @@ describe('Environment', () => {
const mockData = {
endpoint: 'environments.json',
canCreateEnvironment: true,
- canReadEnvironment: true,
newEnvironmentPath: 'environments/new',
helpPagePath: 'help',
userCalloutsPath: '/callouts',
@@ -50,7 +47,6 @@ describe('Environment', () => {
const findNewEnvironmentButton = () => wrapper.findByTestId('new-environment');
const findEnvironmentsTabAvailable = () => wrapper.find('.js-environments-tab-available > a');
const findEnvironmentsTabStopped = () => wrapper.find('.js-environments-tab-stopped > a');
- const findSurveyAlert = () => wrapper.find(GlAlert);
beforeEach(() => {
mock = new MockAdapter(axios);
@@ -283,49 +279,4 @@ describe('Environment', () => {
expect(wrapper.findComponent(GlTabs).attributes('value')).toBe('1');
});
});
-
- describe('survey alert', () => {
- beforeEach(async () => {
- mockRequest(200, { environments: [] });
- await createWrapper(true);
- });
-
- afterEach(() => {
- removeCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME);
- });
-
- describe('when the user has not dismissed the alert', () => {
- it('shows the alert', () => {
- expect(findSurveyAlert().exists()).toBe(true);
- });
-
- describe('when the user dismisses the alert', () => {
- beforeEach(() => {
- findSurveyAlert().vm.$emit('dismiss');
- });
-
- it('hides the alert', () => {
- expect(findSurveyAlert().exists()).toBe(false);
- });
-
- it('persists the dismisal using a cookie', () => {
- const cookieValue = getCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME);
-
- expect(cookieValue).toBe('true');
- });
- });
- });
-
- describe('when the user has previously dismissed the alert', () => {
- beforeEach(async () => {
- setCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME, 'true');
-
- await createWrapper(true);
- });
-
- it('does not show the alert', () => {
- expect(findSurveyAlert().exists()).toBe(false);
- });
- });
- });
});
diff --git a/spec/frontend/environments/environments_detail_header_spec.js b/spec/frontend/environments/environments_detail_header_spec.js
index 6334060c736..305e7385b43 100644
--- a/spec/frontend/environments/environments_detail_header_spec.js
+++ b/spec/frontend/environments/environments_detail_header_spec.js
@@ -44,7 +44,6 @@ describe('Environments detail header component', () => {
TimeAgo,
},
propsData: {
- canReadEnvironment: false,
canAdminEnvironment: false,
canUpdateEnvironment: false,
canStopEnvironment: false,
@@ -60,7 +59,7 @@ describe('Environments detail header component', () => {
describe('default state with minimal access', () => {
beforeEach(() => {
- createWrapper({ props: { environment: createEnvironment() } });
+ createWrapper({ props: { environment: createEnvironment({ externalUrl: null }) } });
});
it('displays the environment name', () => {
@@ -164,7 +163,6 @@ describe('Environments detail header component', () => {
createWrapper({
props: {
environment: createEnvironment({ hasTerminals: true, externalUrl }),
- canReadEnvironment: true,
},
});
});
@@ -178,8 +176,7 @@ describe('Environments detail header component', () => {
beforeEach(() => {
createWrapper({
props: {
- environment: createEnvironment(),
- canReadEnvironment: true,
+ environment: createEnvironment({ metricsUrl: 'my metrics url' }),
metricsPath,
},
});
@@ -195,7 +192,6 @@ describe('Environments detail header component', () => {
createWrapper({
props: {
environment: createEnvironment(),
- canReadEnvironment: true,
canAdminEnvironment: true,
canStopEnvironment: true,
canUpdateEnvironment: true,
diff --git a/spec/frontend/environments/environments_folder_view_spec.js b/spec/frontend/environments/environments_folder_view_spec.js
index e4661d27872..72a7449f24e 100644
--- a/spec/frontend/environments/environments_folder_view_spec.js
+++ b/spec/frontend/environments/environments_folder_view_spec.js
@@ -11,7 +11,6 @@ describe('Environments Folder View', () => {
const mockData = {
endpoint: 'environments.json',
folderName: 'review',
- canReadEnvironment: true,
cssContainerClass: 'container',
userCalloutsPath: '/callouts',
lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',
diff --git a/spec/frontend/environments/folder/environments_folder_view_spec.js b/spec/frontend/environments/folder/environments_folder_view_spec.js
index d02ed8688c6..9eb57b2682f 100644
--- a/spec/frontend/environments/folder/environments_folder_view_spec.js
+++ b/spec/frontend/environments/folder/environments_folder_view_spec.js
@@ -14,7 +14,6 @@ describe('Environments Folder View', () => {
const mockData = {
endpoint: 'environments.json',
folderName: 'review',
- canReadEnvironment: true,
cssContainerClass: 'container',
userCalloutsPath: '/callouts',
lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',
diff --git a/spec/frontend/error_tracking_settings/components/app_spec.js b/spec/frontend/error_tracking_settings/components/app_spec.js
index e0be81b3899..30541ba68a5 100644
--- a/spec/frontend/error_tracking_settings/components/app_spec.js
+++ b/spec/frontend/error_tracking_settings/components/app_spec.js
@@ -1,6 +1,9 @@
+import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
import Vuex from 'vuex';
import { TEST_HOST } from 'helpers/test_constants';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import ErrorTrackingSettings from '~/error_tracking_settings/components/app.vue';
import ErrorTrackingForm from '~/error_tracking_settings/components/error_tracking_form.vue';
import ProjectDropdown from '~/error_tracking_settings/components/project_dropdown.vue';
@@ -14,20 +17,31 @@ describe('error tracking settings app', () => {
let wrapper;
function mountComponent() {
- wrapper = shallowMount(ErrorTrackingSettings, {
- localVue,
- store, // Override the imported store
- propsData: {
- initialEnabled: 'true',
- initialApiHost: TEST_HOST,
- initialToken: 'someToken',
- initialProject: null,
- listProjectsEndpoint: TEST_HOST,
- operationsSettingsEndpoint: TEST_HOST,
- },
- });
+ wrapper = extendedWrapper(
+ shallowMount(ErrorTrackingSettings, {
+ localVue,
+ store, // Override the imported store
+ propsData: {
+ initialEnabled: 'true',
+ initialIntegrated: 'false',
+ initialApiHost: TEST_HOST,
+ initialToken: 'someToken',
+ initialProject: null,
+ listProjectsEndpoint: TEST_HOST,
+ operationsSettingsEndpoint: TEST_HOST,
+ },
+ }),
+ );
}
+ const findBackendSettingsSection = () => wrapper.findByTestId('tracking-backend-settings');
+ const findBackendSettingsRadioGroup = () =>
+ findBackendSettingsSection().findComponent(GlFormRadioGroup);
+ const findBackendSettingsRadioButtons = () =>
+ findBackendSettingsRadioGroup().findAllComponents(GlFormRadio);
+ const findElementWithText = (wrappers, text) => wrappers.filter((item) => item.text() === text);
+ const findSentrySettings = () => wrapper.findByTestId('sentry-setting-form');
+
beforeEach(() => {
store = createStore();
@@ -62,4 +76,46 @@ describe('error tracking settings app', () => {
});
});
});
+
+ describe('tracking-backend settings', () => {
+ it('contains a form-group with the correct label', () => {
+ expect(findBackendSettingsSection().attributes('label')).toBe('Error tracking backend');
+ });
+
+ it('contains a radio group', () => {
+ expect(findBackendSettingsRadioGroup().exists()).toBe(true);
+ });
+
+ it('contains the correct radio buttons', () => {
+ expect(findBackendSettingsRadioButtons()).toHaveLength(2);
+
+ expect(findElementWithText(findBackendSettingsRadioButtons(), 'Sentry')).toHaveLength(1);
+ expect(findElementWithText(findBackendSettingsRadioButtons(), 'GitLab')).toHaveLength(1);
+ });
+
+ it('toggles the sentry-settings section when sentry is selected as a tracking-backend', async () => {
+ expect(findSentrySettings().exists()).toBe(true);
+
+ // set the "integrated" setting to "true"
+ findBackendSettingsRadioGroup().vm.$emit('change', true);
+
+ await nextTick();
+
+ expect(findSentrySettings().exists()).toBe(false);
+ });
+
+ it.each([true, false])(
+ 'calls the `updateIntegrated` action when the setting changes to `%s`',
+ (integrated) => {
+ jest.spyOn(store, 'dispatch').mockImplementation();
+
+ expect(store.dispatch).toHaveBeenCalledTimes(0);
+
+ findBackendSettingsRadioGroup().vm.$emit('change', integrated);
+
+ expect(store.dispatch).toHaveBeenCalledTimes(1);
+ expect(store.dispatch).toHaveBeenCalledWith('updateIntegrated', integrated);
+ },
+ );
+ });
});
diff --git a/spec/frontend/error_tracking_settings/mock.js b/spec/frontend/error_tracking_settings/mock.js
index e64a6d1fe14..b2d7a912518 100644
--- a/spec/frontend/error_tracking_settings/mock.js
+++ b/spec/frontend/error_tracking_settings/mock.js
@@ -42,6 +42,7 @@ export const sampleBackendProject = {
export const sampleFrontendSettings = {
apiHost: 'apiHost',
enabled: false,
+ integrated: false,
token: 'token',
selectedProject: {
slug: normalizedProject.slug,
@@ -54,6 +55,7 @@ export const sampleFrontendSettings = {
export const transformedSettings = {
api_host: 'apiHost',
enabled: false,
+ integrated: false,
token: 'token',
project: {
slug: normalizedProject.slug,
@@ -71,6 +73,7 @@ export const defaultProps = {
export const initialEmptyState = {
apiHost: '',
enabled: false,
+ integrated: false,
project: null,
token: '',
listProjectsEndpoint: TEST_HOST,
@@ -80,6 +83,7 @@ export const initialEmptyState = {
export const initialPopulatedState = {
apiHost: 'apiHost',
enabled: true,
+ integrated: true,
project: JSON.stringify(projectList[0]),
token: 'token',
listProjectsEndpoint: TEST_HOST,
diff --git a/spec/frontend/error_tracking_settings/store/actions_spec.js b/spec/frontend/error_tracking_settings/store/actions_spec.js
index 281db7d9686..1b9be042dd4 100644
--- a/spec/frontend/error_tracking_settings/store/actions_spec.js
+++ b/spec/frontend/error_tracking_settings/store/actions_spec.js
@@ -202,5 +202,11 @@ describe('error tracking settings actions', () => {
done,
);
});
+
+ it.each([true, false])('should set the `integrated` flag to `%s`', async (payload) => {
+ await testAction(actions.updateIntegrated, payload, state, [
+ { type: types.UPDATE_INTEGRATED, payload },
+ ]);
+ });
});
});
diff --git a/spec/frontend/error_tracking_settings/store/mutation_spec.js b/spec/frontend/error_tracking_settings/store/mutation_spec.js
index 78fd56904b3..ecf1c91c08a 100644
--- a/spec/frontend/error_tracking_settings/store/mutation_spec.js
+++ b/spec/frontend/error_tracking_settings/store/mutation_spec.js
@@ -25,6 +25,7 @@ describe('error tracking settings mutations', () => {
expect(state.apiHost).toEqual('');
expect(state.enabled).toEqual(false);
+ expect(state.integrated).toEqual(false);
expect(state.selectedProject).toEqual(null);
expect(state.token).toEqual('');
expect(state.listProjectsEndpoint).toEqual(TEST_HOST);
@@ -38,6 +39,7 @@ describe('error tracking settings mutations', () => {
expect(state.apiHost).toEqual('apiHost');
expect(state.enabled).toEqual(true);
+ expect(state.integrated).toEqual(true);
expect(state.selectedProject).toEqual(projectList[0]);
expect(state.token).toEqual('token');
expect(state.listProjectsEndpoint).toEqual(TEST_HOST);
@@ -78,5 +80,11 @@ describe('error tracking settings mutations', () => {
expect(state.connectSuccessful).toBe(false);
expect(state.connectError).toBe(false);
});
+
+ it.each([true, false])('should update `integrated` to `%s`', (integrated) => {
+ mutations[types.UPDATE_INTEGRATED](state, integrated);
+
+ expect(state.integrated).toBe(integrated);
+ });
});
});
diff --git a/spec/frontend/error_tracking_settings/utils_spec.js b/spec/frontend/error_tracking_settings/utils_spec.js
index 4b144f7daf1..61e75cdc45e 100644
--- a/spec/frontend/error_tracking_settings/utils_spec.js
+++ b/spec/frontend/error_tracking_settings/utils_spec.js
@@ -11,12 +11,14 @@ describe('error tracking settings utils', () => {
const emptyFrontendSettingsObject = {
apiHost: '',
enabled: false,
+ integrated: false,
token: '',
selectedProject: null,
};
const transformedEmptySettingsObject = {
api_host: null,
enabled: false,
+ integrated: false,
token: null,
project: null,
};
diff --git a/spec/frontend/experimentation/utils_spec.js b/spec/frontend/experimentation/utils_spec.js
index 2ba8c65a252..999bed1ffbd 100644
--- a/spec/frontend/experimentation/utils_spec.js
+++ b/spec/frontend/experimentation/utils_spec.js
@@ -37,6 +37,50 @@ describe('experiment Utilities', () => {
});
});
+ describe('getAllExperimentContexts', () => {
+ const schema = TRACKING_CONTEXT_SCHEMA;
+ let origGon;
+
+ beforeEach(() => {
+ origGon = window.gon;
+ });
+
+ afterEach(() => {
+ window.gon = origGon;
+ });
+
+ it('collects all of the experiment contexts into a single array', () => {
+ const experiments = [
+ { experiment: 'abc', variant: 'candidate' },
+ { experiment: 'def', variant: 'control' },
+ { experiment: 'ghi', variant: 'blue' },
+ ];
+ window.gon = {
+ experiment: experiments.reduce((collector, { experiment, variant }) => {
+ return { ...collector, [experiment]: { experiment, variant } };
+ }, {}),
+ };
+
+ expect(experimentUtils.getAllExperimentContexts()).toEqual(
+ experiments.map((data) => ({ schema, data })),
+ );
+ });
+
+ it('returns an empty array if there are no experiments', () => {
+ window.gon.experiment = {};
+
+ expect(experimentUtils.getAllExperimentContexts()).toEqual([]);
+ });
+
+ it('includes all additional experiment data', () => {
+ const experiment = 'experimentWithCustomData';
+ const data = { experiment, variant: 'control', color: 'blue', style: 'rounded' };
+ window.gon.experiment[experiment] = data;
+
+ expect(experimentUtils.getAllExperimentContexts()).toContainEqual({ schema, data });
+ });
+ });
+
describe('isExperimentVariant', () => {
describe.each`
gon | input | output
diff --git a/spec/frontend/filtered_search/services/recent_searches_service_spec.js b/spec/frontend/filtered_search/services/recent_searches_service_spec.js
index 6711ce03d40..dfa53652eb1 100644
--- a/spec/frontend/filtered_search/services/recent_searches_service_spec.js
+++ b/spec/frontend/filtered_search/services/recent_searches_service_spec.js
@@ -145,13 +145,13 @@ describe('RecentSearchesService', () => {
let isAvailable;
beforeEach(() => {
- jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe');
+ jest.spyOn(AccessorUtilities, 'canUseLocalStorage');
isAvailable = RecentSearchesService.isAvailable();
});
- it('should call .isLocalStorageAccessSafe', () => {
- expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
+ it('should call .canUseLocalStorage', () => {
+ expect(AccessorUtilities.canUseLocalStorage).toHaveBeenCalled();
});
it('should return a boolean', () => {
diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml
index b581aac6aee..1edb8cb3f41 100644
--- a/spec/frontend/fixtures/api_markdown.yml
+++ b/spec/frontend/fixtures/api_markdown.yml
@@ -12,14 +12,71 @@
markdown: |-
* {-deleted-}
* {+added+}
-- name: subscript
- markdown: H<sub>2</sub>O
-- name: superscript
- markdown: 2<sup>8</sup> = 256
- name: strike
markdown: '~~del~~'
- name: horizontal_rule
markdown: '---'
+- name: html_marks
+ markdown: |-
+ * Content editor is ~~great~~<ins>amazing</ins>.
+ * If the changes <abbr title="Looks good to merge">LGTM</abbr>, please <abbr title="Merge when pipeline succeeds">MWPS</abbr>.
+ * The English song <q>Oh I do like to be beside the seaside</q> looks like this in Hebrew: <span dir="rtl">אה, אני אוהב להיות ליד חוף הים</span>. In the computer's memory, this is stored as <bdo dir="ltr">אה, אני אוהב להיות ליד חוף הים</bdo>.
+ * <cite>The Scream</cite> by Edvard Munch. Painted in 1893.
+ * <dfn>HTML</dfn> is the standard markup language for creating web pages.
+ * Do not forget to buy <mark>milk</mark> today.
+ * This is a paragraph and <small>smaller text goes here</small>.
+ * The concert starts at <time datetime="20:00">20:00</time> and you'll be able to enjoy the band for at least <time datetime="PT2H30M">2h 30m</time>.
+ * Press <kbd>Ctrl</kbd> + <kbd>C</kbd> to copy text (Windows).
+ * WWF's goal is to: <q>Build a future where people live in harmony with nature.</q> We hope they succeed.
+ * The error occured was: <samp>Keyboard not found. Press F1 to continue.</samp>
+ * The area of a triangle is: 1/2 x <var>b</var> x <var>h</var>, where <var>b</var> is the base, and <var>h</var> is the vertical height.
+ * <ruby>漢<rt>ㄏㄢˋ</rt></ruby>
+ * C<sub>7</sub>H<sub>16</sub> + O<sub>2</sub> → CO<sub>2</sub> + H<sub>2</sub>O
+ * The **Pythagorean theorem** is often expressed as <var>a<sup>2</sup></var> + <var>b<sup>2</sup></var> = <var>c<sup>2</sup></var>
+- name: div
+ markdown: |-
+ <div>plain text</div>
+ <div>
+
+ just a plain ol' div, not much to _expect_!
+
+ </div>
+- name: figure
+ markdown: |-
+ <figure>
+
+ ![Elephant at sunset](elephant-sunset.jpg)
+
+ <figcaption>An elephant at sunset</figcaption>
+ </figure>
+ <figure>
+
+ ![A crocodile wearing crocs](croc-crocs.jpg)
+
+ <figcaption>
+
+ A crocodile wearing _crocs_!
+
+ </figcaption>
+ </figure>
+- name: description_list
+ markdown: |-
+ <dl>
+ <dt>Frog</dt>
+ <dd>Wet green thing</dd>
+ <dt>Rabbit</dt>
+ <dd>Warm fluffy thing</dd>
+ <dt>Punt</dt>
+ <dd>Kick a ball</dd>
+ <dd>Take a bet</dd>
+ <dt>Color</dt>
+ <dt>Colour</dt>
+ <dd>
+
+ Any hue except _white_ or **black**
+
+ </dd>
+ </dl>
- name: link
markdown: '[GitLab](https://gitlab.com)'
- name: attachment_link
@@ -66,16 +123,31 @@
- name: thematic_break
markdown: |-
---
-- name: bullet_list
+- name: bullet_list_style_1
markdown: |-
* list item 1
* list item 2
* embedded list item 3
+- name: bullet_list_style_2
+ markdown: |-
+ - list item 1
+ - list item 2
+ * embedded list item 3
+- name: bullet_list_style_3
+ markdown: |-
+ + list item 1
+ + list item 2
+ - embedded list item 3
- name: ordered_list
markdown: |-
1. list item 1
2. list item 2
3. list item 3
+- name: ordered_list_with_start_order
+ markdown: |-
+ 134. list item 1
+ 135. list item 2
+ 136. list item 3
- name: task_list
markdown: |-
* [x] hello
@@ -92,6 +164,11 @@
1. [ ] of nested
1. [x] task list
2. [ ] items
+- name: ordered_task_list_with_order
+ markdown: |-
+ 4893. [x] hello
+ 4894. [x] world
+ 4895. [ ] example
- name: image
markdown: '![alt text](https://gitlab.com/logo.png)'
- name: hard_break
@@ -102,17 +179,28 @@
markdown: |-
| header | header |
|--------|--------|
- | cell | cell |
- | cell | cell |
-- name: table_with_alignment
- markdown: |-
- | header | : header : | header : |
- |--------|------------|----------|
- | cell | cell | cell |
- | cell | cell | cell |
+ | `code` | cell with **bold** |
+ | ~~strike~~ | cell with _italic_ |
+
+ # content after table
- name: emoji
markdown: ':sparkles: :heart: :100:'
- name: reference
context: project_wiki
markdown: |-
Hi @gitlab - thank you for reporting this ~bug (#1) we hope to fix it in %1.1 as part of !1
+- name: audio
+ markdown: '![Sample Audio](https://gitlab.com/gitlab.mp3)'
+- name: video
+ markdown: '![Sample Video](https://gitlab.com/gitlab.mp4)'
+- name: audio_and_video_in_lists
+ markdown: |-
+ * ![Sample Audio](https://gitlab.com/1.mp3)
+ * ![Sample Video](https://gitlab.com/2.mp4)
+
+ 1. ![Sample Video](https://gitlab.com/1.mp4)
+ 2. ![Sample Audio](https://gitlab.com/2.mp3)
+
+ * [x] ![Sample Audio](https://gitlab.com/1.mp3)
+ * [x] ![Sample Audio](https://gitlab.com/2.mp3)
+ * [x] ![Sample Video](https://gitlab.com/3.mp4)
diff --git a/spec/frontend/fixtures/freeze_period.rb b/spec/frontend/fixtures/freeze_period.rb
index 09e4f969e1d..42762fa56f9 100644
--- a/spec/frontend/fixtures/freeze_period.rb
+++ b/spec/frontend/fixtures/freeze_period.rb
@@ -39,13 +39,4 @@ RSpec.describe 'Freeze Periods (JavaScript fixtures)' do
expect(response).to be_successful
end
end
-
- describe TimeZoneHelper, '(JavaScript fixtures)' do
- let(:response) { timezone_data.to_json }
-
- it 'api/freeze-periods/timezone_data.json' do
- # Looks empty but does things
- # More info: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38525/diffs#note_391048415
- end
- end
end
diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb
index e29a58f43b9..d5d6f534def 100644
--- a/spec/frontend/fixtures/runner.rb
+++ b/spec/frontend/fixtures/runner.rb
@@ -14,6 +14,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
let_it_be(:instance_runner) { create(:ci_runner, :instance, version: '1.0.0', revision: '123', description: 'Instance runner', ip_address: '127.0.0.1') }
let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], active: false, version: '2.0.0', revision: '456', description: 'Group runner', ip_address: '127.0.0.1') }
+ let_it_be(:group_runner_2) { create(:ci_runner, :group, groups: [group], active: false, version: '2.0.0', revision: '456', description: 'Group runner 2', ip_address: '127.0.0.1') }
let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project], active: false, version: '2.0.0', revision: '456', description: 'Project runner', ip_address: '127.0.0.1') }
query_path = 'runner/graphql/'
@@ -27,14 +28,14 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
remove_repository(project)
end
- before do
- sign_in(admin)
- enable_admin_mode!(admin)
- end
-
describe GraphQL::Query, type: :request do
get_runners_query_name = 'get_runners.query.graphql'
+ before do
+ sign_in(admin)
+ enable_admin_mode!(admin)
+ end
+
let_it_be(:query) do
get_graphql_query_as_string("#{query_path}#{get_runners_query_name}")
end
@@ -55,6 +56,11 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
describe GraphQL::Query, type: :request do
get_runner_query_name = 'get_runner.query.graphql'
+ before do
+ sign_in(admin)
+ enable_admin_mode!(admin)
+ end
+
let_it_be(:query) do
get_graphql_query_as_string("#{query_path}#{get_runner_query_name}")
end
@@ -67,4 +73,35 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
expect_graphql_errors_to_be_empty
end
end
+
+ describe GraphQL::Query, type: :request do
+ get_group_runners_query_name = 'get_group_runners.query.graphql'
+
+ let_it_be(:group_owner) { create(:user) }
+
+ before do
+ group.add_owner(group_owner)
+ end
+
+ let_it_be(:query) do
+ get_graphql_query_as_string("#{query_path}#{get_group_runners_query_name}")
+ end
+
+ it "#{fixtures_path}#{get_group_runners_query_name}.json" do
+ post_graphql(query, current_user: group_owner, variables: {
+ groupFullPath: group.full_path
+ })
+
+ expect_graphql_errors_to_be_empty
+ end
+
+ it "#{fixtures_path}#{get_group_runners_query_name}.paginated.json" do
+ post_graphql(query, current_user: group_owner, variables: {
+ groupFullPath: group.full_path,
+ first: 1
+ })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
end
diff --git a/spec/frontend/fixtures/startup_css.rb b/spec/frontend/fixtures/startup_css.rb
index be2ead756cf..1bd99f5cd7f 100644
--- a/spec/frontend/fixtures/startup_css.rb
+++ b/spec/frontend/fixtures/startup_css.rb
@@ -40,6 +40,21 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do
expect(response).to be_successful
end
+
+ # This Feature Flag is off by default
+ # This ensures that the correct css is generated
+ # When the feature flag is off, the general startup will capture it
+ # This will be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/339348
+ it "startup_css/project-#{type}-search-ff-on.html" do
+ stub_feature_flags(new_header_search: true)
+
+ get :show, params: {
+ namespace_id: project.namespace.to_param,
+ id: project
+ }
+
+ expect(response).to be_successful
+ end
end
describe ProjectsController, '(Startup CSS fixtures)', type: :controller do
diff --git a/spec/frontend/fixtures/static/pipeline_graph.html b/spec/frontend/fixtures/static/pipeline_graph.html
deleted file mode 100644
index d2c30ff9211..00000000000
--- a/spec/frontend/fixtures/static/pipeline_graph.html
+++ /dev/null
@@ -1,24 +0,0 @@
-<div class="pipeline-visualization js-pipeline-graph">
-<ul class="stage-column-list">
-<li class="stage-column">
-<div class="stage-name">
-<a href="/">
-Test
-<div class="builds-container">
-<ul>
-<li class="build">
-<div class="curve"></div>
-<a>
-<svg></svg>
-<div>
-stop_review
-</div>
-</a>
-</li>
-</ul>
-</div>
-</a>
-</div>
-</li>
-</ul>
-</div>
diff --git a/spec/frontend/fixtures/timezones.rb b/spec/frontend/fixtures/timezones.rb
new file mode 100644
index 00000000000..261dcf5e116
--- /dev/null
+++ b/spec/frontend/fixtures/timezones.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe TimeZoneHelper, '(JavaScript fixtures)' do
+ include JavaScriptFixturesHelpers
+ include TimeZoneHelper
+
+ let(:response) { @timezones.sort_by! { |tz| tz[:name] }.to_json }
+
+ before(:all) do
+ clean_frontend_fixtures('timezones/')
+ end
+
+ it 'timezones/short.json' do
+ @timezones = timezone_data(format: :short)
+ end
+
+ it 'timezones/full.json' do
+ @timezones = timezone_data(format: :full)
+ end
+end
diff --git a/spec/frontend/frequent_items/store/actions_spec.js b/spec/frontend/frequent_items/store/actions_spec.js
index dacfc7ce707..fb0321545c2 100644
--- a/spec/frontend/frequent_items/store/actions_spec.js
+++ b/spec/frontend/frequent_items/store/actions_spec.js
@@ -109,7 +109,7 @@ describe('Frequent Items Dropdown Store Actions', () => {
});
it('should dispatch `receiveFrequentItemsError`', (done) => {
- jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(false);
+ jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false);
mockedState.namespace = mockNamespace;
mockedState.storageKey = mockStorageKey;
diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js
index da0ff2a64ec..bc8c6460cf4 100644
--- a/spec/frontend/groups/components/app_spec.js
+++ b/spec/frontend/groups/components/app_spec.js
@@ -182,7 +182,12 @@ describe('AppComponent', () => {
jest.spyOn(window.history, 'replaceState').mockImplementation(() => {});
jest.spyOn(window, 'scrollTo').mockImplementation(() => {});
- const fetchPagePromise = vm.fetchPage(2, null, null, true);
+ const fetchPagePromise = vm.fetchPage({
+ page: 2,
+ filterGroupsBy: null,
+ sortBy: null,
+ archived: true,
+ });
expect(vm.isLoading).toBe(true);
expect(vm.fetchGroups).toHaveBeenCalledWith({
diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js
index dc1a10639fc..0ec1ef5a49e 100644
--- a/spec/frontend/groups/components/groups_spec.js
+++ b/spec/frontend/groups/components/groups_spec.js
@@ -41,13 +41,12 @@ describe('GroupsComponent', () => {
vm.change(2);
- expect(eventHub.$emit).toHaveBeenCalledWith(
- 'fetchPage',
- 2,
- expect.any(Object),
- expect.any(Object),
- expect.any(Object),
- );
+ expect(eventHub.$emit).toHaveBeenCalledWith('fetchPage', {
+ page: 2,
+ archived: null,
+ filterGroupsBy: null,
+ sortBy: null,
+ });
});
});
});
diff --git a/spec/frontend/groups/components/invite_members_banner_spec.js b/spec/frontend/groups/components/invite_members_banner_spec.js
index 0da2f84f2a1..c81edad499c 100644
--- a/spec/frontend/groups/components/invite_members_banner_spec.js
+++ b/spec/frontend/groups/components/invite_members_banner_spec.js
@@ -1,29 +1,29 @@
-import { GlBanner, GlButton } from '@gitlab/ui';
+import { GlBanner } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import InviteMembersBanner from '~/groups/components/invite_members_banner.vue';
import eventHub from '~/invite_members/event_hub';
-import { setCookie, parseBoolean } from '~/lib/utils/common_utils';
+import axios from '~/lib/utils/axios_utils';
jest.mock('~/lib/utils/common_utils');
-const isDismissedKey = 'invite_99_1';
const title = 'Collaborate with your team';
const body =
"We noticed that you haven't invited anyone to this group. Invite your colleagues so you can discuss issues, collaborate on merge requests, and share your knowledge";
-const svgPath = '/illustrations/background';
-const inviteMembersPath = 'groups/members';
const buttonText = 'Invite your colleagues';
-const trackLabel = 'invite_members_banner';
+const provide = {
+ svgPath: '/illustrations/background',
+ inviteMembersPath: 'groups/members',
+ trackLabel: 'invite_members_banner',
+ calloutsPath: 'call/out/path',
+ calloutsFeatureId: 'some-feature-id',
+ groupId: '1',
+};
const createComponent = (stubs = {}) => {
return shallowMount(InviteMembersBanner, {
- provide: {
- svgPath,
- inviteMembersPath,
- isDismissedKey,
- trackLabel,
- },
+ provide,
stubs,
});
};
@@ -31,8 +31,10 @@ const createComponent = (stubs = {}) => {
describe('InviteMembersBanner', () => {
let wrapper;
let trackingSpy;
+ let mockAxios;
beforeEach(() => {
+ mockAxios = new MockAdapter(axios);
document.body.dataset.page = 'any:page';
trackingSpy = mockTracking('_category_', undefined, jest.spyOn);
});
@@ -40,22 +42,28 @@ describe('InviteMembersBanner', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
+ mockAxios.restore();
unmockTracking();
});
describe('tracking', () => {
+ const mockTrackingOnWrapper = () => {
+ unmockTracking();
+ trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
+ };
+
beforeEach(() => {
wrapper = createComponent({ GlBanner });
});
const trackCategory = undefined;
- const displayEvent = 'invite_members_banner_displayed';
const buttonClickEvent = 'invite_members_banner_button_clicked';
- const dismissEvent = 'invite_members_banner_dismissed';
it('sends the displayEvent when the banner is displayed', () => {
+ const displayEvent = 'invite_members_banner_displayed';
+
expect(trackingSpy).toHaveBeenCalledWith(trackCategory, displayEvent, {
- label: trackLabel,
+ label: provide.trackLabel,
});
});
@@ -74,16 +82,20 @@ describe('InviteMembersBanner', () => {
it('sends the buttonClickEvent with correct trackCategory and trackLabel', () => {
expect(trackingSpy).toHaveBeenCalledWith(trackCategory, buttonClickEvent, {
- label: trackLabel,
+ label: provide.trackLabel,
});
});
});
it('sends the dismissEvent when the banner is dismissed', () => {
+ mockTrackingOnWrapper();
+ mockAxios.onPost(provide.calloutsPath).replyOnce(200);
+ const dismissEvent = 'invite_members_banner_dismissed';
+
wrapper.find(GlBanner).vm.$emit('close');
expect(trackingSpy).toHaveBeenCalledWith(trackCategory, dismissEvent, {
- label: trackLabel,
+ label: provide.trackLabel,
});
});
});
@@ -98,7 +110,7 @@ describe('InviteMembersBanner', () => {
});
it('uses the svgPath for the banner svgpath', () => {
- expect(findBanner().attributes('svgpath')).toBe(svgPath);
+ expect(findBanner().attributes('svgpath')).toBe(provide.svgPath);
});
it('uses the title from options for title', () => {
@@ -115,35 +127,20 @@ describe('InviteMembersBanner', () => {
});
describe('dismissing', () => {
- const findButton = () => wrapper.findAll(GlButton).at(1);
-
beforeEach(() => {
wrapper = createComponent({ GlBanner });
-
- findButton().vm.$emit('click');
});
- it('sets iDismissed to true', () => {
- expect(wrapper.vm.isDismissed).toBe(true);
+ it('should render the banner when not dismissed', () => {
+ expect(wrapper.find(GlBanner).exists()).toBe(true);
});
- it('sets the cookie with the isDismissedKey', () => {
- expect(setCookie).toHaveBeenCalledWith(isDismissedKey, true);
- });
- });
-
- describe('when a dismiss cookie exists', () => {
- beforeEach(() => {
- parseBoolean.mockReturnValue(true);
-
- wrapper = createComponent({ GlBanner });
- });
-
- it('sets isDismissed to true', () => {
- expect(wrapper.vm.isDismissed).toBe(true);
- });
+ it('should close the banner when dismiss is clicked', async () => {
+ mockAxios.onPost(provide.calloutsPath).replyOnce(200);
+ expect(wrapper.find(GlBanner).exists()).toBe(true);
+ wrapper.find(GlBanner).vm.$emit('close');
- it('does not render the banner', () => {
+ await wrapper.vm.$nextTick();
expect(wrapper.find(GlBanner).exists()).toBe(false);
});
});
diff --git a/spec/frontend/groups/components/item_stats_spec.js b/spec/frontend/groups/components/item_stats_spec.js
index f350012ebed..49f3f5da43c 100644
--- a/spec/frontend/groups/components/item_stats_spec.js
+++ b/spec/frontend/groups/components/item_stats_spec.js
@@ -1,4 +1,4 @@
-import { shallowMount } from '@vue/test-utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ItemStats from '~/groups/components/item_stats.vue';
import ItemStatsValue from '~/groups/components/item_stats_value.vue';
@@ -12,7 +12,7 @@ describe('ItemStats', () => {
};
const createComponent = (props = {}) => {
- wrapper = shallowMount(ItemStats, {
+ wrapper = shallowMountExtended(ItemStats, {
propsData: { ...defaultProps, ...props },
});
};
@@ -46,5 +46,31 @@ describe('ItemStats', () => {
expect(findItemStatsValue().props('cssClass')).toBe('project-stars');
expect(wrapper.find('.last-updated').exists()).toBe(true);
});
+
+ describe('group specific rendering', () => {
+ describe.each`
+ provided | state | data
+ ${true} | ${'displays'} | ${null}
+ ${false} | ${'does not display'} | ${{ subgroupCount: undefined, projectCount: undefined }}
+ `('when provided = $provided', ({ provided, state, data }) => {
+ beforeEach(() => {
+ const item = {
+ ...mockParentGroupItem,
+ ...data,
+ type: ITEM_TYPE.GROUP,
+ };
+
+ createComponent({ item });
+ });
+
+ it.each`
+ entity | testId
+ ${'subgroups'} | ${'subgroups-count'}
+ ${'projects'} | ${'projects-count'}
+ `(`${state} $entity count`, ({ testId }) => {
+ expect(wrapper.findByTestId(testId).exists()).toBe(provided);
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js
new file mode 100644
index 00000000000..2cbcb73ce5b
--- /dev/null
+++ b/spec/frontend/header_search/components/app_spec.js
@@ -0,0 +1,159 @@
+import { GlSearchBoxByType } from '@gitlab/ui';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import HeaderSearchApp from '~/header_search/components/app.vue';
+import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue';
+import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
+import { ENTER_KEY, ESC_KEY } from '~/lib/utils/keys';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { MOCK_SEARCH, MOCK_SEARCH_QUERY, MOCK_USERNAME } from '../mock_data';
+
+Vue.use(Vuex);
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ visitUrl: jest.fn(),
+}));
+
+describe('HeaderSearchApp', () => {
+ let wrapper;
+
+ const actionSpies = {
+ setSearch: jest.fn(),
+ };
+
+ const createComponent = (initialState) => {
+ const store = new Vuex.Store({
+ state: {
+ ...initialState,
+ },
+ actions: actionSpies,
+ getters: {
+ searchQuery: () => MOCK_SEARCH_QUERY,
+ },
+ });
+
+ wrapper = shallowMountExtended(HeaderSearchApp, {
+ store,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
+ const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu');
+ const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems);
+ const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems);
+
+ describe('template', () => {
+ it('always renders Header Search Input', () => {
+ createComponent();
+ expect(findHeaderSearchInput().exists()).toBe(true);
+ });
+
+ describe.each`
+ showDropdown | username | showSearchDropdown
+ ${false} | ${null} | ${false}
+ ${false} | ${MOCK_USERNAME} | ${false}
+ ${true} | ${null} | ${false}
+ ${true} | ${MOCK_USERNAME} | ${true}
+ `('Header Search Dropdown', ({ showDropdown, username, showSearchDropdown }) => {
+ describe(`when showDropdown is ${showDropdown} and current_username is ${username}`, () => {
+ beforeEach(() => {
+ createComponent();
+ window.gon.current_username = username;
+ wrapper.setData({ showDropdown });
+ });
+
+ it(`should${showSearchDropdown ? '' : ' not'} render`, () => {
+ expect(findHeaderSearchDropdown().exists()).toBe(showSearchDropdown);
+ });
+ });
+ });
+
+ describe.each`
+ search | showDefault | showScoped
+ ${null} | ${true} | ${false}
+ ${''} | ${true} | ${false}
+ ${MOCK_SEARCH} | ${false} | ${true}
+ `('Header Search Dropdown Items', ({ search, showDefault, showScoped }) => {
+ describe(`when search is ${search}`, () => {
+ beforeEach(() => {
+ createComponent({ search });
+ window.gon.current_username = MOCK_USERNAME;
+ wrapper.setData({ showDropdown: true });
+ });
+
+ it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => {
+ expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault);
+ });
+
+ it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => {
+ expect(findHeaderSearchScopedItems().exists()).toBe(showScoped);
+ });
+ });
+ });
+ });
+
+ describe('events', () => {
+ beforeEach(() => {
+ createComponent();
+ window.gon.current_username = MOCK_USERNAME;
+ });
+
+ describe('Header Search Input', () => {
+ describe('when dropdown is closed', () => {
+ it('onFocus opens dropdown', async () => {
+ expect(findHeaderSearchDropdown().exists()).toBe(false);
+ findHeaderSearchInput().vm.$emit('focus');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findHeaderSearchDropdown().exists()).toBe(true);
+ });
+
+ it('onClick opens dropdown', async () => {
+ expect(findHeaderSearchDropdown().exists()).toBe(false);
+ findHeaderSearchInput().vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findHeaderSearchDropdown().exists()).toBe(true);
+ });
+ });
+
+ describe('when dropdown is opened', () => {
+ beforeEach(() => {
+ wrapper.setData({ showDropdown: true });
+ });
+
+ it('onKey-Escape closes dropdown', async () => {
+ expect(findHeaderSearchDropdown().exists()).toBe(true);
+ findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ESC_KEY }));
+
+ await wrapper.vm.$nextTick();
+
+ expect(findHeaderSearchDropdown().exists()).toBe(false);
+ });
+ });
+
+ it('calls setSearch when search input event is fired', async () => {
+ findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH);
+
+ await wrapper.vm.$nextTick();
+
+ expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH);
+ });
+
+ it('submits a search onKey-Enter', async () => {
+ findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
+
+ await wrapper.vm.$nextTick();
+
+ expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/header_search/components/header_search_default_items_spec.js b/spec/frontend/header_search/components/header_search_default_items_spec.js
new file mode 100644
index 00000000000..ce083d0df72
--- /dev/null
+++ b/spec/frontend/header_search/components/header_search_default_items_spec.js
@@ -0,0 +1,81 @@
+import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue';
+import { MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS } from '../mock_data';
+
+Vue.use(Vuex);
+
+describe('HeaderSearchDefaultItems', () => {
+ let wrapper;
+
+ const createComponent = (initialState) => {
+ const store = new Vuex.Store({
+ state: {
+ searchContext: MOCK_SEARCH_CONTEXT,
+ ...initialState,
+ },
+ getters: {
+ defaultSearchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS,
+ },
+ });
+
+ wrapper = shallowMount(HeaderSearchDefaultItems, {
+ store,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findDropdownHeader = () => wrapper.findComponent(GlDropdownSectionHeader);
+ const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text());
+ const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
+
+ describe('template', () => {
+ describe('Dropdown items', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders item for each option in defaultSearchOptions', () => {
+ expect(findDropdownItems()).toHaveLength(MOCK_DEFAULT_SEARCH_OPTIONS.length);
+ });
+
+ it('renders titles correctly', () => {
+ const expectedTitles = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.title);
+ expect(findDropdownItemTitles()).toStrictEqual(expectedTitles);
+ });
+
+ it('renders links correctly', () => {
+ const expectedLinks = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.url);
+ expect(findDropdownItemLinks()).toStrictEqual(expectedLinks);
+ });
+ });
+
+ describe.each`
+ group | project | dropdownTitle
+ ${null} | ${null} | ${'All GitLab'}
+ ${{ name: 'Test Group' }} | ${null} | ${'Test Group'}
+ ${{ name: 'Test Group' }} | ${{ name: 'Test Project' }} | ${'Test Project'}
+ `('Dropdown Header', ({ group, project, dropdownTitle }) => {
+ describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
+ beforeEach(() => {
+ createComponent({
+ searchContext: {
+ group,
+ project,
+ },
+ });
+ });
+
+ it(`should render as ${dropdownTitle}`, () => {
+ expect(findDropdownHeader().text()).toBe(dropdownTitle);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/header_search/components/header_search_scoped_items_spec.js b/spec/frontend/header_search/components/header_search_scoped_items_spec.js
new file mode 100644
index 00000000000..f0e5e182ec4
--- /dev/null
+++ b/spec/frontend/header_search/components/header_search_scoped_items_spec.js
@@ -0,0 +1,61 @@
+import { GlDropdownItem } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import { trimText } from 'helpers/text_helper';
+import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
+import { MOCK_SEARCH, MOCK_SCOPED_SEARCH_OPTIONS } from '../mock_data';
+
+Vue.use(Vuex);
+
+describe('HeaderSearchScopedItems', () => {
+ let wrapper;
+
+ const createComponent = (initialState) => {
+ const store = new Vuex.Store({
+ state: {
+ search: MOCK_SEARCH,
+ ...initialState,
+ },
+ getters: {
+ scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS,
+ },
+ });
+
+ wrapper = shallowMount(HeaderSearchScopedItems, {
+ store,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text()));
+ const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
+
+ describe('template', () => {
+ describe('Dropdown items', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders item for each option in scopedSearchOptions', () => {
+ expect(findDropdownItems()).toHaveLength(MOCK_SCOPED_SEARCH_OPTIONS.length);
+ });
+
+ it('renders titles correctly', () => {
+ const expectedTitles = MOCK_SCOPED_SEARCH_OPTIONS.map((o) =>
+ trimText(`"${MOCK_SEARCH}" ${o.description} ${o.scope || ''}`),
+ );
+ expect(findDropdownItemTitles()).toStrictEqual(expectedTitles);
+ });
+
+ it('renders links correctly', () => {
+ const expectedLinks = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => o.url);
+ expect(findDropdownItemLinks()).toStrictEqual(expectedLinks);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/header_search/mock_data.js b/spec/frontend/header_search/mock_data.js
new file mode 100644
index 00000000000..5963ad9c279
--- /dev/null
+++ b/spec/frontend/header_search/mock_data.js
@@ -0,0 +1,83 @@
+import {
+ MSG_ISSUES_ASSIGNED_TO_ME,
+ MSG_ISSUES_IVE_CREATED,
+ MSG_MR_ASSIGNED_TO_ME,
+ MSG_MR_IM_REVIEWER,
+ MSG_MR_IVE_CREATED,
+ MSG_IN_PROJECT,
+ MSG_IN_GROUP,
+ MSG_IN_ALL_GITLAB,
+} from '~/header_search/constants';
+
+export const MOCK_USERNAME = 'anyone';
+
+export const MOCK_SEARCH_PATH = '/search';
+
+export const MOCK_ISSUE_PATH = '/dashboard/issues';
+
+export const MOCK_MR_PATH = '/dashboard/merge_requests';
+
+export const MOCK_ALL_PATH = '/';
+
+export const MOCK_PROJECT = {
+ id: 123,
+ name: 'MockProject',
+ path: '/mock-project',
+};
+
+export const MOCK_GROUP = {
+ id: 321,
+ name: 'MockGroup',
+ path: '/mock-group',
+};
+
+export const MOCK_SEARCH_QUERY = 'http://gitlab.com/search?search=test';
+
+export const MOCK_SEARCH = 'test';
+
+export const MOCK_SEARCH_CONTEXT = {
+ project: null,
+ project_metadata: {},
+ group: null,
+ group_metadata: {},
+};
+
+export const MOCK_DEFAULT_SEARCH_OPTIONS = [
+ {
+ title: MSG_ISSUES_ASSIGNED_TO_ME,
+ url: `${MOCK_ISSUE_PATH}/?assignee_username=${MOCK_USERNAME}`,
+ },
+ {
+ title: MSG_ISSUES_IVE_CREATED,
+ url: `${MOCK_ISSUE_PATH}/?author_username=${MOCK_USERNAME}`,
+ },
+ {
+ title: MSG_MR_ASSIGNED_TO_ME,
+ url: `${MOCK_MR_PATH}/?assignee_username=${MOCK_USERNAME}`,
+ },
+ {
+ title: MSG_MR_IM_REVIEWER,
+ url: `${MOCK_MR_PATH}/?reviewer_username=${MOCK_USERNAME}`,
+ },
+ {
+ title: MSG_MR_IVE_CREATED,
+ url: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`,
+ },
+];
+
+export const MOCK_SCOPED_SEARCH_OPTIONS = [
+ {
+ scope: MOCK_PROJECT.name,
+ description: MSG_IN_PROJECT,
+ url: MOCK_PROJECT.path,
+ },
+ {
+ scope: MOCK_GROUP.name,
+ description: MSG_IN_GROUP,
+ url: MOCK_GROUP.path,
+ },
+ {
+ description: MSG_IN_ALL_GITLAB,
+ url: MOCK_ALL_PATH,
+ },
+];
diff --git a/spec/frontend/header_search/store/actions_spec.js b/spec/frontend/header_search/store/actions_spec.js
new file mode 100644
index 00000000000..4530df0d91c
--- /dev/null
+++ b/spec/frontend/header_search/store/actions_spec.js
@@ -0,0 +1,28 @@
+import testAction from 'helpers/vuex_action_helper';
+import * as actions from '~/header_search/store/actions';
+import * as types from '~/header_search/store/mutation_types';
+import createState from '~/header_search/store/state';
+import { MOCK_SEARCH } from '../mock_data';
+
+describe('Header Search Store Actions', () => {
+ let state;
+
+ beforeEach(() => {
+ state = createState({});
+ });
+
+ afterEach(() => {
+ state = null;
+ });
+
+ describe('setSearch', () => {
+ it('calls the SET_SEARCH mutation', () => {
+ return testAction({
+ action: actions.setSearch,
+ payload: MOCK_SEARCH,
+ state,
+ expectedMutations: [{ type: types.SET_SEARCH, payload: MOCK_SEARCH }],
+ });
+ });
+ });
+});
diff --git a/spec/frontend/header_search/store/getters_spec.js b/spec/frontend/header_search/store/getters_spec.js
new file mode 100644
index 00000000000..2ad0a082f6a
--- /dev/null
+++ b/spec/frontend/header_search/store/getters_spec.js
@@ -0,0 +1,211 @@
+import * as getters from '~/header_search/store/getters';
+import initState from '~/header_search/store/state';
+import {
+ MOCK_USERNAME,
+ MOCK_SEARCH_PATH,
+ MOCK_ISSUE_PATH,
+ MOCK_MR_PATH,
+ MOCK_SEARCH_CONTEXT,
+ MOCK_DEFAULT_SEARCH_OPTIONS,
+ MOCK_SCOPED_SEARCH_OPTIONS,
+ MOCK_PROJECT,
+ MOCK_GROUP,
+ MOCK_ALL_PATH,
+ MOCK_SEARCH,
+} from '../mock_data';
+
+describe('Header Search Store Getters', () => {
+ let state;
+
+ const createState = (initialState) => {
+ state = initState({
+ searchPath: MOCK_SEARCH_PATH,
+ issuesPath: MOCK_ISSUE_PATH,
+ mrPath: MOCK_MR_PATH,
+ searchContext: MOCK_SEARCH_CONTEXT,
+ ...initialState,
+ });
+ };
+
+ afterEach(() => {
+ state = null;
+ });
+
+ describe.each`
+ group | project | expectedPath
+ ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=undefined&group_id=undefined&scope=issues`}
+ ${MOCK_GROUP} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=undefined&group_id=${MOCK_GROUP.id}&scope=issues`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`}
+ `('searchQuery', ({ group, project, expectedPath }) => {
+ describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
+ beforeEach(() => {
+ createState({
+ searchContext: {
+ group,
+ project,
+ scope: 'issues',
+ },
+ });
+ state.search = MOCK_SEARCH;
+ });
+
+ it(`should return ${expectedPath}`, () => {
+ expect(getters.searchQuery(state)).toBe(expectedPath);
+ });
+ });
+ });
+
+ describe.each`
+ group | group_metadata | project | project_metadata | expectedPath
+ ${null} | ${null} | ${null} | ${null} | ${MOCK_ISSUE_PATH}
+ ${{ name: 'Test Group' }} | ${{ issues_path: 'group/path' }} | ${null} | ${null} | ${'group/path'}
+ ${{ name: 'Test Group' }} | ${{ issues_path: 'group/path' }} | ${{ name: 'Test Project' }} | ${{ issues_path: 'project/path' }} | ${'project/path'}
+ `('scopedIssuesPath', ({ group, group_metadata, project, project_metadata, expectedPath }) => {
+ describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
+ beforeEach(() => {
+ createState({
+ searchContext: {
+ group,
+ group_metadata,
+ project,
+ project_metadata,
+ },
+ });
+ });
+
+ it(`should return ${expectedPath}`, () => {
+ expect(getters.scopedIssuesPath(state)).toBe(expectedPath);
+ });
+ });
+ });
+
+ describe.each`
+ group | group_metadata | project | project_metadata | expectedPath
+ ${null} | ${null} | ${null} | ${null} | ${MOCK_MR_PATH}
+ ${{ name: 'Test Group' }} | ${{ mr_path: 'group/path' }} | ${null} | ${null} | ${'group/path'}
+ ${{ name: 'Test Group' }} | ${{ mr_path: 'group/path' }} | ${{ name: 'Test Project' }} | ${{ mr_path: 'project/path' }} | ${'project/path'}
+ `('scopedMRPath', ({ group, group_metadata, project, project_metadata, expectedPath }) => {
+ describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
+ beforeEach(() => {
+ createState({
+ searchContext: {
+ group,
+ group_metadata,
+ project,
+ project_metadata,
+ },
+ });
+ });
+
+ it(`should return ${expectedPath}`, () => {
+ expect(getters.scopedMRPath(state)).toBe(expectedPath);
+ });
+ });
+ });
+
+ describe.each`
+ group | project | expectedPath
+ ${null} | ${null} | ${null}
+ ${MOCK_GROUP} | ${null} | ${null}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`}
+ `('projectUrl', ({ group, project, expectedPath }) => {
+ describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
+ beforeEach(() => {
+ createState({
+ searchContext: {
+ group,
+ project,
+ scope: 'issues',
+ },
+ });
+ state.search = MOCK_SEARCH;
+ });
+
+ it(`should return ${expectedPath}`, () => {
+ expect(getters.projectUrl(state)).toBe(expectedPath);
+ });
+ });
+ });
+
+ describe.each`
+ group | project | expectedPath
+ ${null} | ${null} | ${null}
+ ${MOCK_GROUP} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`}
+ `('groupUrl', ({ group, project, expectedPath }) => {
+ describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
+ beforeEach(() => {
+ createState({
+ searchContext: {
+ group,
+ project,
+ scope: 'issues',
+ },
+ });
+ state.search = MOCK_SEARCH;
+ });
+
+ it(`should return ${expectedPath}`, () => {
+ expect(getters.groupUrl(state)).toBe(expectedPath);
+ });
+ });
+ });
+
+ describe('allUrl', () => {
+ const expectedPath = `${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues`;
+
+ beforeEach(() => {
+ createState({
+ searchContext: {
+ scope: 'issues',
+ },
+ });
+ state.search = MOCK_SEARCH;
+ });
+
+ it(`should return ${expectedPath}`, () => {
+ expect(getters.allUrl(state)).toBe(expectedPath);
+ });
+ });
+
+ describe('defaultSearchOptions', () => {
+ const mockGetters = {
+ scopedIssuesPath: MOCK_ISSUE_PATH,
+ scopedMRPath: MOCK_MR_PATH,
+ };
+
+ beforeEach(() => {
+ createState();
+ window.gon.current_username = MOCK_USERNAME;
+ });
+
+ it('returns the correct array', () => {
+ expect(getters.defaultSearchOptions(state, mockGetters)).toStrictEqual(
+ MOCK_DEFAULT_SEARCH_OPTIONS,
+ );
+ });
+ });
+
+ describe('scopedSearchOptions', () => {
+ const mockGetters = {
+ projectUrl: MOCK_PROJECT.path,
+ groupUrl: MOCK_GROUP.path,
+ allUrl: MOCK_ALL_PATH,
+ };
+
+ beforeEach(() => {
+ createState({
+ searchContext: {
+ project: MOCK_PROJECT,
+ group: MOCK_GROUP,
+ },
+ });
+ });
+
+ it('returns the correct array', () => {
+ expect(getters.scopedSearchOptions(state, mockGetters)).toStrictEqual(
+ MOCK_SCOPED_SEARCH_OPTIONS,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/header_search/store/mutations_spec.js b/spec/frontend/header_search/store/mutations_spec.js
new file mode 100644
index 00000000000..8196c06099d
--- /dev/null
+++ b/spec/frontend/header_search/store/mutations_spec.js
@@ -0,0 +1,20 @@
+import * as types from '~/header_search/store/mutation_types';
+import mutations from '~/header_search/store/mutations';
+import createState from '~/header_search/store/state';
+import { MOCK_SEARCH } from '../mock_data';
+
+describe('Header Search Store Mutations', () => {
+ let state;
+
+ beforeEach(() => {
+ state = createState({});
+ });
+
+ describe('SET_SEARCH', () => {
+ it('sets search to value', () => {
+ mutations[types.SET_SEARCH](state, MOCK_SEARCH);
+
+ expect(state.search).toBe(MOCK_SEARCH);
+ });
+ });
+});
diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js
index 4ca6d7259bd..0d43accb7e5 100644
--- a/spec/frontend/header_spec.js
+++ b/spec/frontend/header_spec.js
@@ -59,8 +59,8 @@ describe('Header', () => {
beforeEach(() => {
setFixtures(`
<li class="js-nav-user-dropdown">
- <a class="js-buy-pipeline-minutes-link" data-track-event="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy Pipeline minutes</a>
- <a class="js-upgrade-plan-link" data-track-event="click_upgrade_link" data-track-label="free" data-track-property="user_dropdown">Upgrade</a>
+ <a class="js-buy-pipeline-minutes-link" data-track-action="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy Pipeline minutes</a>
+ <a class="js-upgrade-plan-link" data-track-action="click_upgrade_link" data-track-label="free" data-track-property="user_dropdown">Upgrade</a>
</li>`);
trackingSpy = mockTracking('_category_', $('.js-nav-user-dropdown').element, jest.spyOn);
diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js
index 47bcfb59a5f..c2212eea849 100644
--- a/spec/frontend/ide/components/repo_editor_spec.js
+++ b/spec/frontend/ide/components/repo_editor_spec.js
@@ -8,6 +8,7 @@ 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 { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext';
import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext';
import SourceEditor from '~/editor/source_editor';
import RepoEditor from '~/ide/components/repo_editor.vue';
@@ -25,6 +26,7 @@ import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer
import { file } from '../helpers';
const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown';
+const CURRENT_PROJECT_ID = 'gitlab-org/gitlab';
const defaultFileProps = {
...file('file.txt'),
@@ -63,7 +65,7 @@ const prepareStore = (state, activeFile) => {
const localState = {
openFiles: [activeFile],
projects: {
- 'gitlab-org/gitlab': {
+ [CURRENT_PROJECT_ID]: {
branches: {
main: {
name: 'main',
@@ -74,7 +76,7 @@ const prepareStore = (state, activeFile) => {
},
},
},
- currentProjectId: 'gitlab-org/gitlab',
+ currentProjectId: CURRENT_PROJECT_ID,
currentBranchId: 'main',
entries: {
[activeFile.path]: activeFile,
@@ -98,6 +100,7 @@ describe('RepoEditor', () => {
let createInstanceSpy;
let createDiffInstanceSpy;
let createModelSpy;
+ let applyExtensionSpy;
const waitForEditorSetup = () =>
new Promise((resolve) => {
@@ -124,11 +127,28 @@ describe('RepoEditor', () => {
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"]');
+ const expectEditorMarkdownExtension = (shouldHaveExtension) => {
+ if (shouldHaveExtension) {
+ expect(applyExtensionSpy).toHaveBeenCalledWith(
+ wrapper.vm.editor,
+ expect.any(EditorMarkdownExtension),
+ );
+ // TODO: spying on extensions causes Jest to blow up, so we have to assert on
+ // the public property the extension adds, as opposed to the args passed to the ctor
+ expect(wrapper.vm.editor.previewMarkdownPath).toBe(PREVIEW_MARKDOWN_PATH);
+ } else {
+ expect(applyExtensionSpy).not.toHaveBeenCalledWith(
+ wrapper.vm.editor,
+ expect.any(EditorMarkdownExtension),
+ );
+ }
+ };
beforeEach(() => {
createInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_CODE_INSTANCE_FN);
createDiffInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_DIFF_INSTANCE_FN);
createModelSpy = jest.spyOn(monacoEditor, 'createModel');
+ applyExtensionSpy = jest.spyOn(SourceEditor, 'instanceApplyExtension');
jest.spyOn(service, 'getFileData').mockResolvedValue();
jest.spyOn(service, 'getRawFileData').mockResolvedValue();
});
@@ -280,13 +300,8 @@ describe('RepoEditor', () => {
'$prefix install markdown extension for $activeFile.name in $viewer viewer',
async ({ activeFile, viewer, shouldHaveMarkdownExtension } = {}) => {
await createComponent({ state: { viewer }, activeFile });
- if (shouldHaveMarkdownExtension) {
- expect(vm.editor.previewMarkdownPath).toBe(PREVIEW_MARKDOWN_PATH);
- expect(vm.editor.togglePreview).toBeDefined();
- } else {
- expect(vm.editor.previewMarkdownPath).toBeUndefined();
- expect(vm.editor.togglePreview).toBeUndefined();
- }
+
+ expectEditorMarkdownExtension(shouldHaveMarkdownExtension);
},
);
});
diff --git a/spec/frontend/ide/services/terminals_spec.js b/spec/frontend/ide/services/terminals_spec.js
new file mode 100644
index 00000000000..788fdb6471c
--- /dev/null
+++ b/spec/frontend/ide/services/terminals_spec.js
@@ -0,0 +1,51 @@
+import MockAdapter from 'axios-mock-adapter';
+import * as terminalService from '~/ide/services/terminals';
+import axios from '~/lib/utils/axios_utils';
+
+const TEST_PROJECT_PATH = 'lorem/ipsum/dolar';
+const TEST_BRANCH = 'ref';
+
+describe('~/ide/services/terminals', () => {
+ let axiosSpy;
+ let mock;
+ const prevRelativeUrlRoot = gon.relative_url_root;
+
+ beforeEach(() => {
+ axiosSpy = jest.fn().mockReturnValue([200, {}]);
+
+ mock = new MockAdapter(axios);
+ mock.onPost(/.*/).reply((...args) => axiosSpy(...args));
+ });
+
+ afterEach(() => {
+ gon.relative_url_root = prevRelativeUrlRoot;
+ mock.restore();
+ });
+
+ it.each`
+ method | relativeUrlRoot | url
+ ${'checkConfig'} | ${''} | ${`/${TEST_PROJECT_PATH}/ide_terminals/check_config`}
+ ${'checkConfig'} | ${'/'} | ${`/${TEST_PROJECT_PATH}/ide_terminals/check_config`}
+ ${'checkConfig'} | ${'/gitlabbin'} | ${`/gitlabbin/${TEST_PROJECT_PATH}/ide_terminals/check_config`}
+ ${'create'} | ${''} | ${`/${TEST_PROJECT_PATH}/ide_terminals`}
+ ${'create'} | ${'/'} | ${`/${TEST_PROJECT_PATH}/ide_terminals`}
+ ${'create'} | ${'/gitlabbin'} | ${`/gitlabbin/${TEST_PROJECT_PATH}/ide_terminals`}
+ `(
+ 'when $method called, posts request to $url (relative_url_root=$relativeUrlRoot)',
+ async ({ method, url, relativeUrlRoot }) => {
+ gon.relative_url_root = relativeUrlRoot;
+
+ await terminalService[method](TEST_PROJECT_PATH, TEST_BRANCH);
+
+ expect(axiosSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: JSON.stringify({
+ branch: TEST_BRANCH,
+ format: 'json',
+ }),
+ url,
+ }),
+ );
+ },
+ );
+});
diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js
index 00733615f81..2f8447af518 100644
--- a/spec/frontend/ide/utils_spec.js
+++ b/spec/frontend/ide/utils_spec.js
@@ -86,6 +86,14 @@ describe('WebIDE utils', () => {
expect(isTextFile({ name: 'abc.dat', content: '' })).toBe(true);
expect(isTextFile({ name: 'abc.dat', content: ' ' })).toBe(true);
});
+
+ it('returns true if there is a `binary` property already set on the file object', () => {
+ expect(isTextFile({ name: 'abc.txt', content: '' })).toBe(true);
+ expect(isTextFile({ name: 'abc.txt', content: '', binary: true })).toBe(false);
+
+ expect(isTextFile({ name: 'abc.tex', content: 'éêė' })).toBe(false);
+ expect(isTextFile({ name: 'abc.tex', content: 'éêė', binary: false })).toBe(true);
+ });
});
describe('trimPathComponents', () => {
diff --git a/spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js b/spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js
new file mode 100644
index 00000000000..60f0780fdb3
--- /dev/null
+++ b/spec/frontend/import_entities/import_groups/components/import_actions_cell_spec.js
@@ -0,0 +1,90 @@
+import { GlButton, GlIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { STATUSES } from '~/import_entities/constants';
+import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue';
+import { generateFakeEntry } from '../graphql/fixtures';
+
+describe('import actions cell', () => {
+ let wrapper;
+
+ const createComponent = (props) => {
+ wrapper = shallowMount(ImportActionsCell, {
+ propsData: {
+ groupPathRegex: /^[a-zA-Z]+$/,
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when import status is NONE', () => {
+ beforeEach(() => {
+ const group = generateFakeEntry({ id: 1, status: STATUSES.NONE });
+ createComponent({ group });
+ });
+
+ it('renders import button', () => {
+ const button = wrapper.findComponent(GlButton);
+ expect(button.exists()).toBe(true);
+ expect(button.text()).toBe('Import');
+ });
+
+ it('does not render icon with a hint', () => {
+ expect(wrapper.findComponent(GlIcon).exists()).toBe(false);
+ });
+ });
+
+ describe('when import status is FINISHED', () => {
+ beforeEach(() => {
+ const group = generateFakeEntry({ id: 1, status: STATUSES.FINISHED });
+ createComponent({ group });
+ });
+
+ it('renders re-import button', () => {
+ const button = wrapper.findComponent(GlButton);
+ expect(button.exists()).toBe(true);
+ expect(button.text()).toBe('Re-import');
+ });
+
+ it('renders icon with a hint', () => {
+ const icon = wrapper.findComponent(GlIcon);
+ expect(icon.exists()).toBe(true);
+ expect(icon.attributes().title).toBe(
+ 'Re-import creates a new group. It does not sync with the existing group.',
+ );
+ });
+ });
+
+ it('does not render import button when group import is in progress', () => {
+ const group = generateFakeEntry({ id: 1, status: STATUSES.STARTED });
+ createComponent({ group });
+
+ const button = wrapper.findComponent(GlButton);
+ expect(button.exists()).toBe(false);
+ });
+
+ it('renders import button as disabled when there are validation errors', () => {
+ const group = generateFakeEntry({
+ id: 1,
+ status: STATUSES.NONE,
+ validation_errors: [{ field: 'new_name', message: 'something ' }],
+ });
+ createComponent({ group });
+
+ const button = wrapper.findComponent(GlButton);
+ expect(button.props().disabled).toBe(true);
+ });
+
+ it('emits import-group event when import button is clicked', () => {
+ const group = generateFakeEntry({ id: 1, status: STATUSES.NONE });
+ createComponent({ group });
+
+ const button = wrapper.findComponent(GlButton);
+ button.vm.$emit('click');
+
+ expect(wrapper.emitted('import-group')).toHaveLength(1);
+ });
+});
diff --git a/spec/frontend/import_entities/import_groups/components/import_source_cell_spec.js b/spec/frontend/import_entities/import_groups/components/import_source_cell_spec.js
new file mode 100644
index 00000000000..2a56efd1cbb
--- /dev/null
+++ b/spec/frontend/import_entities/import_groups/components/import_source_cell_spec.js
@@ -0,0 +1,59 @@
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { STATUSES } from '~/import_entities/constants';
+import ImportSourceCell from '~/import_entities/import_groups/components/import_source_cell.vue';
+import { generateFakeEntry } from '../graphql/fixtures';
+
+describe('import source cell', () => {
+ let wrapper;
+ let group;
+
+ const createComponent = (props) => {
+ wrapper = shallowMount(ImportSourceCell, {
+ propsData: {
+ ...props,
+ },
+ stubs: { GlSprintf },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when group status is NONE', () => {
+ beforeEach(() => {
+ group = generateFakeEntry({ id: 1, status: STATUSES.NONE });
+ createComponent({ group });
+ });
+
+ it('renders link to a group', () => {
+ const link = wrapper.findComponent(GlLink);
+ expect(link.attributes().href).toBe(group.web_url);
+ expect(link.text()).toContain(group.full_path);
+ });
+
+ it('does not render last imported line', () => {
+ expect(wrapper.text()).not.toContain('Last imported to');
+ });
+ });
+
+ describe('when group status is FINISHED', () => {
+ beforeEach(() => {
+ group = generateFakeEntry({ id: 1, status: STATUSES.FINISHED });
+ createComponent({ group });
+ });
+
+ it('renders link to a group', () => {
+ const link = wrapper.findComponent(GlLink);
+ expect(link.attributes().href).toBe(group.web_url);
+ expect(link.text()).toContain(group.full_path);
+ });
+
+ it('renders last imported line', () => {
+ expect(wrapper.text()).toMatchInterpolatedText(
+ 'fake_group_1 Last imported to root/last-group1',
+ );
+ });
+ });
+});
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 bbd8463e685..f43e545e049 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
@@ -15,6 +15,7 @@ import stubChildren from 'helpers/stub_children';
import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import { STATUSES } from '~/import_entities/constants';
+import ImportActionsCell from '~/import_entities/import_groups/components/import_actions_cell.vue';
import ImportTable from '~/import_entities/import_groups/components/import_table.vue';
import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue';
import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql';
@@ -163,11 +164,8 @@ describe('import table', () => {
it('invokes importGroups mutation when row button is clicked', async () => {
jest.spyOn(apolloProvider.defaultClient, 'mutate');
- const triggerImportButton = wrapper
- .findAllComponents(GlButton)
- .wrappers.find((w) => w.text() === 'Import');
- triggerImportButton.vm.$emit('click');
+ wrapper.findComponent(ImportActionsCell).vm.$emit('import-group');
await waitForPromises();
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
mutation: importGroupsMutation,
@@ -329,7 +327,7 @@ describe('import table', () => {
});
it('does not allow selecting already started groups', async () => {
- const NEW_GROUPS = [generateFakeEntry({ id: 1, status: STATUSES.FINISHED })];
+ const NEW_GROUPS = [generateFakeEntry({ id: 1, status: STATUSES.STARTED })];
createComponent({
bulkImportSourceGroups: () => ({
diff --git a/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js
index 8231297e594..be83a61841f 100644
--- a/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js
+++ b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js
@@ -1,14 +1,10 @@
-import { GlButton, GlDropdownItem, GlLink, GlFormInput } from '@gitlab/ui';
+import { GlDropdownItem, GlFormInput } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
-import VueApollo from 'vue-apollo';
import ImportGroupDropdown from '~/import_entities/components/group_dropdown.vue';
import { STATUSES } from '~/import_entities/constants';
import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue';
import { availableNamespacesFixture } from '../graphql/fixtures';
-Vue.use(VueApollo);
-
const getFakeGroup = (status) => ({
web_url: 'https://fake.host/',
full_path: 'fake_group_1',
@@ -26,9 +22,6 @@ describe('import target cell', () => {
let wrapper;
let group;
- const findByText = (cmp, text) => {
- return wrapper.findAll(cmp).wrappers.find((node) => node.text().indexOf(text) === 0);
- };
const findNameInput = () => wrapper.find(GlFormInput);
const findNamespaceDropdown = () => wrapper.find(ImportGroupDropdown);
@@ -117,10 +110,6 @@ describe('import target cell', () => {
createComponent({ group });
});
- it('does not render Import button', () => {
- expect(findByText(GlButton, 'Import')).toBe(undefined);
- });
-
it('renders namespace dropdown as disabled', () => {
expect(findNamespaceDropdown().attributes('disabled')).toBe('true');
});
@@ -132,17 +121,8 @@ describe('import target cell', () => {
createComponent({ group });
});
- it('does not render Import button', () => {
- expect(findByText(GlButton, 'Import')).toBe(undefined);
- });
-
- it('does not render namespace dropdown', () => {
- expect(findNamespaceDropdown().exists()).toBe(false);
- });
-
- it('renders target as link', () => {
- const TARGET_LINK = `${group.import_target.target_namespace}/${group.import_target.new_name}`;
- expect(findByText(GlLink, TARGET_LINK).exists()).toBe(true);
+ it('renders namespace dropdown as enabled', () => {
+ expect(findNamespaceDropdown().attributes('disabled')).toBe(undefined);
});
});
@@ -179,9 +159,6 @@ describe('import target cell', () => {
},
});
- jest.runOnlyPendingTimers();
- await nextTick();
-
expect(wrapper.text()).toContain(FAKE_ERROR_MESSAGE);
});
});
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 ec50dfd037f..e1d65095888 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
@@ -259,6 +259,10 @@ describe('Bulk import resolvers', () => {
target_namespace: 'root',
new_name: 'group1',
},
+ last_import_target: {
+ target_namespace: 'root',
+ new_name: 'group1',
+ },
validation_errors: [],
},
],
@@ -414,19 +418,32 @@ describe('Bulk import resolvers', () => {
});
});
- it('setImportProgress updates group progress', async () => {
+ it('setImportProgress updates group progress and sets import target', async () => {
const NEW_STATUS = 'dummy';
const FAKE_JOB_ID = 5;
+ const IMPORT_TARGET = {
+ __typename: 'ClientBulkImportTarget',
+ new_name: 'fake_name',
+ target_namespace: 'fake_target',
+ };
const {
data: {
- setImportProgress: { progress },
+ setImportProgress: { progress, last_import_target: lastImportTarget },
},
} = await client.mutate({
mutation: setImportProgressMutation,
- variables: { sourceGroupId: GROUP_ID, status: NEW_STATUS, jobId: FAKE_JOB_ID },
+ variables: {
+ sourceGroupId: GROUP_ID,
+ status: NEW_STATUS,
+ jobId: FAKE_JOB_ID,
+ importTarget: IMPORT_TARGET,
+ },
});
- expect(progress).toMatchObject({
+ expect(lastImportTarget).toStrictEqual(IMPORT_TARGET);
+
+ expect(progress).toStrictEqual({
+ __typename: clientTypenames.BulkImportProgress,
id: FAKE_JOB_ID,
status: NEW_STATUS,
});
@@ -442,7 +459,8 @@ describe('Bulk import resolvers', () => {
variables: { id: FAKE_JOB_ID, status: NEW_STATUS },
});
- expect(statusInResponse).toMatchObject({
+ expect(statusInResponse).toStrictEqual({
+ __typename: clientTypenames.BulkImportProgress,
id: FAKE_JOB_ID,
status: NEW_STATUS,
});
@@ -460,7 +478,13 @@ describe('Bulk import resolvers', () => {
variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD, message: FAKE_MESSAGE },
});
- expect(validationErrors).toMatchObject([{ field: FAKE_FIELD, message: FAKE_MESSAGE }]);
+ expect(validationErrors).toStrictEqual([
+ {
+ __typename: clientTypenames.BulkImportValidationError,
+ field: FAKE_FIELD,
+ message: FAKE_MESSAGE,
+ },
+ ]);
});
it('removeValidationError removes error from group', async () => {
@@ -481,7 +505,7 @@ describe('Bulk import resolvers', () => {
variables: { sourceGroupId: GROUP_ID, field: FAKE_FIELD },
});
- expect(validationErrors).toMatchObject([]);
+ expect(validationErrors).toStrictEqual([]);
});
});
});
diff --git a/spec/frontend/import_entities/import_groups/graphql/fixtures.js b/spec/frontend/import_entities/import_groups/graphql/fixtures.js
index 6f66066b312..d1bd52693b6 100644
--- a/spec/frontend/import_entities/import_groups/graphql/fixtures.js
+++ b/spec/frontend/import_entities/import_groups/graphql/fixtures.js
@@ -9,6 +9,10 @@ export const generateFakeEntry = ({ id, status, ...rest }) => ({
target_namespace: 'root',
new_name: `group${id}`,
},
+ last_import_target: {
+ target_namespace: 'root',
+ new_name: `last-group${id}`,
+ },
id,
progress: {
id: `test-${id}`,
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 bae715edac0..f06babcb149 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
@@ -20,7 +20,7 @@ describe('SourceGroupsManager', () => {
describe('storage management', () => {
const IMPORT_ID = 1;
- const IMPORT_TARGET = { destination_name: 'demo', destination_namespace: 'foo' };
+ const IMPORT_TARGET = { new_name: 'demo', target_namespace: 'foo' };
const STATUS = 'FAKE_STATUS';
const FAKE_GROUP = { id: 1, import_target: IMPORT_TARGET, status: STATUS };
diff --git a/spec/frontend/import_entities/import_projects/store/actions_spec.js b/spec/frontend/import_entities/import_projects/store/actions_spec.js
index f2bfc61381c..0ebe8525b5a 100644
--- a/spec/frontend/import_entities/import_projects/store/actions_spec.js
+++ b/spec/frontend/import_entities/import_projects/store/actions_spec.js
@@ -85,7 +85,7 @@ describe('import_projects store actions', () => {
afterEach(() => mock.restore());
- it('commits SET_PAGE, REQUEST_REPOS, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => {
+ it('commits REQUEST_REPOS, SET_PAGE, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => {
mock.onGet(MOCK_ENDPOINT).reply(200, payload);
return testAction(
@@ -93,8 +93,8 @@ describe('import_projects store actions', () => {
null,
localState,
[
- { type: SET_PAGE, payload: 1 },
{ type: REQUEST_REPOS },
+ { type: SET_PAGE, payload: 1 },
{
type: RECEIVE_REPOS_SUCCESS,
payload: convertObjectPropsToCamelCase(payload, { deep: true }),
@@ -104,19 +104,14 @@ describe('import_projects store actions', () => {
);
});
- it('commits SET_PAGE, REQUEST_REPOS, RECEIVE_REPOS_ERROR and SET_PAGE again mutations on an unsuccessful request', () => {
+ it('commits REQUEST_REPOS, RECEIVE_REPOS_ERROR mutations on an unsuccessful request', () => {
mock.onGet(MOCK_ENDPOINT).reply(500);
return testAction(
fetchRepos,
null,
localState,
- [
- { type: SET_PAGE, payload: 1 },
- { type: REQUEST_REPOS },
- { type: SET_PAGE, payload: 0 },
- { type: RECEIVE_REPOS_ERROR },
- ],
+ [{ type: REQUEST_REPOS }, { type: RECEIVE_REPOS_ERROR }],
[],
);
});
@@ -135,7 +130,7 @@ describe('import_projects store actions', () => {
expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localStateWithPage.pageInfo.page + 1}`);
});
- it('correctly updates current page on an unsuccessful request', () => {
+ it('correctly keeps current page on an unsuccessful request', () => {
mock.onGet(MOCK_ENDPOINT).reply(500);
const CURRENT_PAGE = 5;
@@ -143,10 +138,7 @@ describe('import_projects store actions', () => {
fetchRepos,
null,
{ ...localState, pageInfo: { page: CURRENT_PAGE } },
- expect.arrayContaining([
- { type: SET_PAGE, payload: CURRENT_PAGE + 1 },
- { type: SET_PAGE, payload: CURRENT_PAGE },
- ]),
+ expect.arrayContaining([]),
[],
);
});
@@ -159,12 +151,7 @@ describe('import_projects store actions', () => {
fetchRepos,
null,
{ ...localState, filter: 'filter' },
- [
- { type: SET_PAGE, payload: 1 },
- { type: REQUEST_REPOS },
- { type: SET_PAGE, payload: 0 },
- { type: RECEIVE_REPOS_ERROR },
- ],
+ [{ type: REQUEST_REPOS }, { type: RECEIVE_REPOS_ERROR }],
[],
);
@@ -183,8 +170,8 @@ describe('import_projects store actions', () => {
null,
{ ...localState, filter: 'filter' },
[
- { type: SET_PAGE, payload: 1 },
{ type: REQUEST_REPOS },
+ { type: SET_PAGE, payload: 1 },
{
type: RECEIVE_REPOS_SUCCESS,
payload: convertObjectPropsToCamelCase(payload, { deep: true }),
diff --git a/spec/frontend/invite_members/components/import_a_project_modal_spec.js b/spec/frontend/invite_members/components/import_a_project_modal_spec.js
new file mode 100644
index 00000000000..fecbf84fb57
--- /dev/null
+++ b/spec/frontend/invite_members/components/import_a_project_modal_spec.js
@@ -0,0 +1,167 @@
+import { GlFormGroup, GlSprintf, GlModal } from '@gitlab/ui';
+import MockAdapter from 'axios-mock-adapter';
+import { stubComponent } from 'helpers/stub_component';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import * as ProjectsApi from '~/api/projects_api';
+import ImportAProjectModal from '~/invite_members/components/import_a_project_modal.vue';
+import ProjectSelect from '~/invite_members/components/project_select.vue';
+import axios from '~/lib/utils/axios_utils';
+
+let wrapper;
+let mock;
+
+const projectId = '1';
+const projectName = 'test name';
+const projectToBeImported = { id: '2' };
+const $toast = {
+ show: jest.fn(),
+};
+
+const createComponent = () => {
+ wrapper = shallowMountExtended(ImportAProjectModal, {
+ propsData: {
+ projectId,
+ projectName,
+ },
+ stubs: {
+ GlModal: stubComponent(GlModal, {
+ template:
+ '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>',
+ }),
+ GlSprintf,
+ GlFormGroup: stubComponent(GlFormGroup, {
+ props: ['state', 'invalidFeedback'],
+ }),
+ },
+ mocks: {
+ $toast,
+ },
+ });
+};
+
+beforeEach(() => {
+ gon.api_version = 'v4';
+ mock = new MockAdapter(axios);
+});
+
+afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+});
+
+describe('ImportAProjectModal', () => {
+ const findIntroText = () => wrapper.find({ ref: 'modalIntro' }).text();
+ const findCancelButton = () => wrapper.findByTestId('cancel-button');
+ const findImportButton = () => wrapper.findByTestId('import-button');
+ const clickImportButton = () => findImportButton().vm.$emit('click');
+ const clickCancelButton = () => findCancelButton().vm.$emit('click');
+ const findFormGroup = () => wrapper.findByTestId('form-group');
+ const formGroupInvalidFeedback = () => findFormGroup().props('invalidFeedback');
+ const formGroupErrorState = () => findFormGroup().props('state');
+ const findProjectSelect = () => wrapper.findComponent(ProjectSelect);
+
+ describe('rendering the modal', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders the modal with the correct title', () => {
+ expect(wrapper.findComponent(GlModal).props('title')).toBe(
+ 'Import members from another project',
+ );
+ });
+
+ it('renders the Cancel button text correctly', () => {
+ expect(findCancelButton().text()).toBe('Cancel');
+ });
+
+ it('renders the Import button text correctly', () => {
+ expect(findImportButton().text()).toBe('Import project members');
+ });
+
+ it('renders the modal intro text correctly', () => {
+ expect(findIntroText()).toBe("You're importing members to the test name project.");
+ });
+
+ it('renders the Import button modal without isLoading', () => {
+ expect(findImportButton().props('loading')).toBe(false);
+ });
+
+ it('sets isLoading to true when the Invite button is clicked', async () => {
+ clickImportButton();
+
+ await wrapper.vm.$nextTick();
+
+ expect(findImportButton().props('loading')).toBe(true);
+ });
+ });
+
+ describe('submitting the import form', () => {
+ describe('when the import is successful', () => {
+ beforeEach(() => {
+ createComponent();
+
+ findProjectSelect().vm.$emit('input', projectToBeImported);
+
+ jest.spyOn(ProjectsApi, 'importProjectMembers').mockResolvedValue();
+
+ clickImportButton();
+ });
+
+ it('calls Api importProjectMembers', () => {
+ expect(ProjectsApi.importProjectMembers).toHaveBeenCalledWith(
+ projectId,
+ projectToBeImported.id,
+ );
+ });
+
+ it('displays the successful toastMessage', () => {
+ expect($toast.show).toHaveBeenCalledWith(
+ 'Successfully imported',
+ wrapper.vm.$options.toastOptions,
+ );
+ });
+
+ it('sets isLoading to false after success', () => {
+ expect(findImportButton().props('loading')).toBe(false);
+ });
+ });
+
+ describe('when the import fails', () => {
+ beforeEach(async () => {
+ createComponent();
+
+ findProjectSelect().vm.$emit('input', projectToBeImported);
+
+ jest
+ .spyOn(ProjectsApi, 'importProjectMembers')
+ .mockRejectedValue({ response: { data: { success: false } } });
+
+ clickImportButton();
+ await waitForPromises();
+ });
+
+ it('displays the generic error message', () => {
+ expect(formGroupInvalidFeedback()).toBe('Unable to import project members');
+ expect(formGroupErrorState()).toBe(false);
+ });
+
+ it('sets isLoading to false after error', () => {
+ expect(findImportButton().props('loading')).toBe(false);
+ });
+
+ it('clears the error when the modal is closed with an error', async () => {
+ expect(formGroupInvalidFeedback()).toBe('Unable to import project members');
+ expect(formGroupErrorState()).toBe(false);
+
+ clickCancelButton();
+
+ await wrapper.vm.$nextTick();
+
+ expect(formGroupInvalidFeedback()).toBe('');
+ expect(formGroupErrorState()).not.toBe(false);
+ });
+ });
+ });
+});
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 f57af61ad5b..b2ebb9e4a47 100644
--- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js
@@ -79,14 +79,14 @@ describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement
it('does not add tracking attributes', () => {
createComponent();
- expect(findButton().attributes('data-track-event')).toBeUndefined();
+ expect(findButton().attributes('data-track-action')).toBeUndefined();
expect(findButton().attributes('data-track-label')).toBeUndefined();
});
it('adds tracking attributes', () => {
createComponent({ label: '_label_', event: '_event_' });
- expect(findButton().attributes('data-track-event')).toBe('_event_');
+ expect(findButton().attributes('data-track-action')).toBe('_event_');
expect(findButton().attributes('data-track-label')).toBe('_label_');
});
});
diff --git a/spec/frontend/invite_members/components/project_select_spec.js b/spec/frontend/invite_members/components/project_select_spec.js
new file mode 100644
index 00000000000..acc062b5fff
--- /dev/null
+++ b/spec/frontend/invite_members/components/project_select_spec.js
@@ -0,0 +1,105 @@
+import { GlSearchBoxByType, GlAvatarLabeled, GlDropdownItem } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import * as projectsApi from '~/api/projects_api';
+import ProjectSelect from '~/invite_members/components/project_select.vue';
+import { allProjects, project1 } from '../mock_data/api_response_data';
+
+describe('ProjectSelect', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(ProjectSelect, {});
+ };
+
+ beforeEach(() => {
+ jest.spyOn(projectsApi, 'getProjects').mockResolvedValue(allProjects);
+
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType);
+ const findDropdownItem = (index) => wrapper.findAllComponents(GlDropdownItem).at(index);
+ const findAvatarLabeled = (index) => findDropdownItem(index).findComponent(GlAvatarLabeled);
+ const findEmptyResultMessage = () => wrapper.findByTestId('empty-result-message');
+ const findErrorMessage = () => wrapper.findByTestId('error-message');
+
+ it('renders GlSearchBoxByType with default attributes', () => {
+ expect(findSearchBoxByType().exists()).toBe(true);
+ expect(findSearchBoxByType().vm.$attrs).toMatchObject({
+ placeholder: 'Search projects',
+ });
+ });
+
+ describe('when user types in the search input', () => {
+ let resolveApiRequest;
+ let rejectApiRequest;
+
+ beforeEach(() => {
+ jest.spyOn(projectsApi, 'getProjects').mockImplementation(
+ () =>
+ new Promise((resolve, reject) => {
+ resolveApiRequest = resolve;
+ rejectApiRequest = reject;
+ }),
+ );
+
+ findSearchBoxByType().vm.$emit('input', project1.name);
+ });
+
+ it('calls the API', () => {
+ resolveApiRequest({ data: allProjects });
+
+ expect(projectsApi.getProjects).toHaveBeenCalledWith(project1.name, {
+ active: true,
+ exclude_internal: true,
+ });
+ });
+
+ it('displays loading icon while waiting for API call to resolve and then sets loading false', async () => {
+ expect(findSearchBoxByType().props('isLoading')).toBe(true);
+
+ resolveApiRequest({ data: allProjects });
+ await waitForPromises();
+
+ expect(findSearchBoxByType().props('isLoading')).toBe(false);
+ expect(findEmptyResultMessage().exists()).toBe(false);
+ expect(findErrorMessage().exists()).toBe(false);
+ });
+
+ it('displays a dropdown item and avatar for each project fetched', async () => {
+ resolveApiRequest({ data: allProjects });
+ await waitForPromises();
+
+ allProjects.forEach((project, index) => {
+ expect(findDropdownItem(index).attributes('name')).toBe(project.name_with_namespace);
+ expect(findAvatarLabeled(index).attributes()).toMatchObject({
+ src: project.avatar_url,
+ 'entity-id': String(project.id),
+ 'entity-name': project.name_with_namespace,
+ });
+ expect(findAvatarLabeled(index).props('label')).toBe(project.name_with_namespace);
+ });
+ });
+
+ it('displays the empty message when the API results are empty', async () => {
+ resolveApiRequest({ data: [] });
+ await waitForPromises();
+
+ expect(findEmptyResultMessage().text()).toBe('No matching results');
+ });
+
+ it('displays the error message when the fetch fails', async () => {
+ rejectApiRequest();
+ await waitForPromises();
+
+ expect(findErrorMessage().text()).toBe(
+ 'There was an error fetching the projects. Please try again.',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/invite_members/mock_data/api_response_data.js b/spec/frontend/invite_members/mock_data/api_response_data.js
new file mode 100644
index 00000000000..9509422b603
--- /dev/null
+++ b/spec/frontend/invite_members/mock_data/api_response_data.js
@@ -0,0 +1,13 @@
+export const project1 = {
+ id: 1,
+ name: 'Project One',
+ name_with_namespace: 'Project One',
+ avatar_url: 'test1',
+};
+export const project2 = {
+ id: 2,
+ name: 'Project One',
+ name_with_namespace: 'Project Two',
+ avatar_url: 'test2',
+};
+export const allProjects = [project1, project2];
diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js
index babe3a66578..bd05cb1ac5a 100644
--- a/spec/frontend/issue_show/components/app_spec.js
+++ b/spec/frontend/issue_show/components/app_spec.js
@@ -1,7 +1,8 @@
import { GlIntersectionObserver } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import '~/behaviors/markdown/render_gfm';
import IssuableApp from '~/issue_show/components/app.vue';
import DescriptionComponent from '~/issue_show/components/description.vue';
@@ -33,13 +34,17 @@ describe('Issuable output', () => {
let realtimeRequestCount = 0;
let wrapper;
- const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]');
- const findLockedBadge = () => wrapper.find('[data-testid="locked"]');
- const findConfidentialBadge = () => wrapper.find('[data-testid="confidential"]');
+ const findStickyHeader = () => wrapper.findByTestId('issue-sticky-header');
+ const findLockedBadge = () => wrapper.findByTestId('locked');
+ const findConfidentialBadge = () => wrapper.findByTestId('confidential');
+ const findHiddenBadge = () => wrapper.findByTestId('hidden');
const findAlert = () => wrapper.find('.alert');
const mountComponent = (props = {}, options = {}, data = {}) => {
- wrapper = mount(IssuableApp, {
+ wrapper = mountExtended(IssuableApp, {
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
propsData: { ...appProps, ...props },
provide: {
fullPath: 'gitlab-org/incidents',
@@ -539,8 +544,8 @@ describe('Issuable output', () => {
it.each`
title | isConfidential
- ${'does not show confidential badge when issue is not confidential'} | ${true}
- ${'shows confidential badge when issue is confidential'} | ${false}
+ ${'does not show confidential badge when issue is not confidential'} | ${false}
+ ${'shows confidential badge when issue is confidential'} | ${true}
`('$title', async ({ isConfidential }) => {
wrapper.setProps({ isConfidential });
@@ -551,8 +556,8 @@ describe('Issuable output', () => {
it.each`
title | isLocked
- ${'does not show locked badge when issue is not locked'} | ${true}
- ${'shows locked badge when issue is locked'} | ${false}
+ ${'does not show locked badge when issue is not locked'} | ${false}
+ ${'shows locked badge when issue is locked'} | ${true}
`('$title', async ({ isLocked }) => {
wrapper.setProps({ isLocked });
@@ -560,6 +565,27 @@ describe('Issuable output', () => {
expect(findLockedBadge().exists()).toBe(isLocked);
});
+
+ it.each`
+ title | isHidden
+ ${'does not show hidden badge when issue is not hidden'} | ${false}
+ ${'shows hidden badge when issue is hidden'} | ${true}
+ `('$title', async ({ isHidden }) => {
+ wrapper.setProps({ isHidden });
+
+ await nextTick();
+
+ const hiddenBadge = findHiddenBadge();
+
+ expect(hiddenBadge.exists()).toBe(isHidden);
+
+ if (isHidden) {
+ expect(hiddenBadge.attributes('title')).toBe(
+ 'This issue is hidden because its author has been banned',
+ );
+ expect(getBinding(hiddenBadge.element, 'gl-tooltip')).not.toBeUndefined();
+ }
+ });
});
});
diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js
index 0cb1092135f..8d79a5eed35 100644
--- a/spec/frontend/issues_list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues_list/components/issues_list_app_spec.js
@@ -5,17 +5,17 @@ import { cloneDeep } from 'lodash';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
-import getIssuesCountQuery from 'ee_else_ce/issues_list/queries/get_issues_count.query.graphql';
+import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import {
+ getIssuesCountsQueryResponse,
getIssuesQueryResponse,
filteredTokens,
locationSearch,
urlParams,
- getIssuesCountQueryResponse,
} from 'jest/issues_list/mock_data';
import createFlash from '~/flash';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -63,15 +63,15 @@ describe('IssuesListApp component', () => {
canBulkUpdate: false,
emptyStateSvgPath: 'empty-state.svg',
exportCsvPath: 'export/csv/path',
+ fullPath: 'path/to/project',
+ hasAnyIssues: true,
hasBlockedIssuesFeature: true,
hasIssueWeightsFeature: true,
hasIterationsFeature: true,
- hasProjectIssues: true,
+ isProject: true,
isSignedIn: true,
- issuesPath: 'path/to/issues',
jiraIntegrationPath: 'jira/integration/path',
newIssuePath: 'new/issue/path',
- projectPath: 'path/to/project',
rssPath: 'rss/path',
showNewIssueLink: true,
signInPath: 'sign/in/path',
@@ -97,12 +97,12 @@ describe('IssuesListApp component', () => {
const mountComponent = ({
provide = {},
issuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse),
- issuesQueryCountResponse = jest.fn().mockResolvedValue(getIssuesCountQueryResponse),
+ issuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse),
mountFn = shallowMount,
} = {}) => {
const requestHandlers = [
[getIssuesQuery, issuesQueryResponse],
- [getIssuesCountQuery, issuesQueryCountResponse],
+ [getIssuesCountsQuery, issuesCountsQueryResponse],
];
const apolloProvider = createMockApollo(requestHandlers);
@@ -134,7 +134,7 @@ describe('IssuesListApp component', () => {
it('renders', () => {
expect(findIssuableList().props()).toMatchObject({
- namespace: defaultProvide.projectPath,
+ namespace: defaultProvide.fullPath,
recentSearchesStorageKey: 'issues',
searchInputPlaceholder: IssuesListApp.i18n.searchPlaceholder,
sortOptions: getSortOptions(true, true),
@@ -191,7 +191,7 @@ describe('IssuesListApp component', () => {
setWindowLocation(search);
wrapper = mountComponent({
- provide: { ...defaultProvide, isSignedIn: true },
+ provide: { isSignedIn: true },
mountFn: mount,
});
@@ -208,7 +208,15 @@ describe('IssuesListApp component', () => {
describe('when user is not signed in', () => {
it('does not render', () => {
- wrapper = mountComponent({ provide: { ...defaultProvide, isSignedIn: false } });
+ wrapper = mountComponent({ provide: { isSignedIn: false } });
+
+ expect(findCsvImportExportButtons().exists()).toBe(false);
+ });
+ });
+
+ describe('when in a group context', () => {
+ it('does not render', () => {
+ wrapper = mountComponent({ provide: { isProject: false } });
expect(findCsvImportExportButtons().exists()).toBe(false);
});
@@ -349,7 +357,7 @@ describe('IssuesListApp component', () => {
beforeEach(() => {
setWindowLocation(`?search=no+results`);
- wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
+ wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount });
});
it('shows empty state', () => {
@@ -363,7 +371,7 @@ describe('IssuesListApp component', () => {
describe('when "Open" tab has no issues', () => {
beforeEach(() => {
- wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
+ wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount });
});
it('shows empty state', () => {
@@ -379,7 +387,7 @@ describe('IssuesListApp component', () => {
beforeEach(() => {
setWindowLocation(`?state=${IssuableStates.Closed}`);
- wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
+ wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount });
});
it('shows empty state', () => {
@@ -395,7 +403,7 @@ describe('IssuesListApp component', () => {
describe('when user is logged in', () => {
beforeEach(() => {
wrapper = mountComponent({
- provide: { hasProjectIssues: false, isSignedIn: true },
+ provide: { hasAnyIssues: false, isSignedIn: true },
mountFn: mount,
});
});
@@ -434,7 +442,7 @@ describe('IssuesListApp component', () => {
describe('when user is logged out', () => {
beforeEach(() => {
wrapper = mountComponent({
- provide: { hasProjectIssues: false, isSignedIn: false },
+ provide: { hasAnyIssues: false, isSignedIn: false },
});
});
@@ -571,9 +579,9 @@ describe('IssuesListApp component', () => {
describe('errors', () => {
describe.each`
- error | mountOption | message
- ${'fetching issues'} | ${'issuesQueryResponse'} | ${IssuesListApp.i18n.errorFetchingIssues}
- ${'fetching issue counts'} | ${'issuesQueryCountResponse'} | ${IssuesListApp.i18n.errorFetchingCounts}
+ error | mountOption | message
+ ${'fetching issues'} | ${'issuesQueryResponse'} | ${IssuesListApp.i18n.errorFetchingIssues}
+ ${'fetching issue counts'} | ${'issuesCountsQueryResponse'} | ${IssuesListApp.i18n.errorFetchingCounts}
`('when there is an error $error', ({ mountOption, message }) => {
beforeEach(() => {
wrapper = mountComponent({
@@ -625,78 +633,99 @@ describe('IssuesListApp component', () => {
...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/1',
iid: '101',
- title: 'Issue one',
+ reference: 'group/project#1',
+ webPath: '/group/project/-/issues/1',
};
const issueTwo = {
...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/2',
iid: '102',
- title: 'Issue two',
+ reference: 'group/project#2',
+ webPath: '/group/project/-/issues/2',
};
const issueThree = {
...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/3',
iid: '103',
- title: 'Issue three',
+ reference: 'group/project#3',
+ webPath: '/group/project/-/issues/3',
};
const issueFour = {
...defaultQueryResponse.data.project.issues.nodes[0],
id: 'gid://gitlab/Issue/4',
iid: '104',
- title: 'Issue four',
+ reference: 'group/project#4',
+ webPath: '/group/project/-/issues/4',
};
- const response = {
+ const response = (isProject = true) => ({
data: {
- project: {
+ [isProject ? 'project' : 'group']: {
issues: {
...defaultQueryResponse.data.project.issues,
nodes: [issueOne, issueTwo, issueThree, issueFour],
},
},
},
- };
-
- beforeEach(() => {
- wrapper = mountComponent({ issuesQueryResponse: jest.fn().mockResolvedValue(response) });
- jest.runOnlyPendingTimers();
});
describe('when successful', () => {
- describe.each`
- description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId
- ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id}
- ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id}
- ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id}
- ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null}
- `(
- 'when moving issue $description',
- ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => {
- it('makes API call to reorder the issue', async () => {
- findIssuableList().vm.$emit('reorder', { oldIndex, newIndex });
-
- await waitForPromises();
-
- expect(axiosMock.history.put[0]).toMatchObject({
- url: joinPaths(defaultProvide.issuesPath, issueToMove.iid, 'reorder'),
- data: JSON.stringify({
- move_before_id: getIdFromGraphQLId(moveBeforeId),
- move_after_id: getIdFromGraphQLId(moveAfterId),
- }),
+ describe.each([true, false])('when isProject=%s', (isProject) => {
+ describe.each`
+ description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId
+ ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id}
+ ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id}
+ ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id}
+ ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null}
+ `(
+ 'when moving issue $description',
+ ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ provide: { isProject },
+ issuesQueryResponse: jest.fn().mockResolvedValue(response(isProject)),
+ });
+ jest.runOnlyPendingTimers();
});
- });
- },
- );
+
+ it('makes API call to reorder the issue', async () => {
+ findIssuableList().vm.$emit('reorder', { oldIndex, newIndex });
+
+ await waitForPromises();
+
+ expect(axiosMock.history.put[0]).toMatchObject({
+ url: joinPaths(issueToMove.webPath, 'reorder'),
+ data: JSON.stringify({
+ move_before_id: getIdFromGraphQLId(moveBeforeId),
+ move_after_id: getIdFromGraphQLId(moveAfterId),
+ group_full_path: isProject ? undefined : defaultProvide.fullPath,
+ }),
+ });
+ });
+ },
+ );
+ });
});
describe('when unsuccessful', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ issuesQueryResponse: jest.fn().mockResolvedValue(response()),
+ });
+ jest.runOnlyPendingTimers();
+ });
+
it('displays an error message', async () => {
- axiosMock.onPut(joinPaths(defaultProvide.issuesPath, issueOne.iid, 'reorder')).reply(500);
+ axiosMock.onPut(joinPaths(issueOne.webPath, 'reorder')).reply(500);
findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 });
await waitForPromises();
- expect(createFlash).toHaveBeenCalledWith({ message: IssuesListApp.i18n.reorderError });
+ expect(createFlash).toHaveBeenCalledWith({
+ message: IssuesListApp.i18n.reorderError,
+ captureError: true,
+ error: new Error('Request failed with status code 500'),
+ });
});
});
});
diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js
index d3f3f2f9f23..720f9cac986 100644
--- a/spec/frontend/issues_list/mock_data.js
+++ b/spec/frontend/issues_list/mock_data.js
@@ -29,6 +29,7 @@ export const getIssuesQueryResponse = {
updatedAt: '2021-05-22T04:08:01Z',
upvotes: 3,
userDiscussionsCount: 4,
+ webPath: 'project/-/issues/789',
webUrl: 'project/-/issues/789',
assignees: {
nodes: [
@@ -70,10 +71,16 @@ export const getIssuesQueryResponse = {
},
};
-export const getIssuesCountQueryResponse = {
+export const getIssuesCountsQueryResponse = {
data: {
project: {
- issues: {
+ openedIssues: {
+ count: 1,
+ },
+ closedIssues: {
+ count: 1,
+ },
+ allIssues: {
count: 1,
},
},
diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap
index f2142ce1fcf..891ba9c223c 100644
--- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap
+++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap
@@ -128,8 +128,26 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
<!---->
<div
+ class="gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5"
+ >
+ <div
+ class="gl-display-flex"
+ >
+ <!---->
+ </div>
+
+ <div
+ class="gl-display-flex"
+ >
+ <!---->
+ </div>
+ </div>
+
+ <div
class="gl-new-dropdown-contents"
>
+ <!---->
+
<div
class="gl-search-box-by-type"
>
@@ -255,8 +273,26 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
<!---->
<div
+ class="gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5"
+ >
+ <div
+ class="gl-display-flex"
+ >
+ <!---->
+ </div>
+
+ <div
+ class="gl-display-flex"
+ >
+ <!---->
+ </div>
+ </div>
+
+ <div
class="gl-new-dropdown-contents"
>
+ <!---->
+
<div
class="gl-search-box-by-type"
>
diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js
index 1f4dd7d6216..f8a0059bf21 100644
--- a/spec/frontend/jobs/components/job_app_spec.js
+++ b/spec/frontend/jobs/components/job_app_spec.js
@@ -140,7 +140,7 @@ describe('Job App', () => {
it('should render provided job information', () => {
expect(wrapper.find('.header-main-content').text().replace(/\s+/g, ' ').trim()).toContain(
- 'passed Job #4757 triggered 1 year ago by Root',
+ 'passed Job test triggered 1 year ago by Root',
);
});
@@ -154,7 +154,7 @@ describe('Job App', () => {
setupAndMount().then(() => {
expect(
wrapper.find('.header-main-content').text().replace(/\s+/g, ' ').trim(),
- ).toContain('passed Job #4757 created 3 weeks ago by Root');
+ ).toContain('passed Job test created 3 weeks ago by Root');
}));
});
});
diff --git a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js
new file mode 100644
index 00000000000..1b1e2d4df8f
--- /dev/null
+++ b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js
@@ -0,0 +1,126 @@
+import { GlModal } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ActionsCell from '~/jobs/components/table/cells/actions_cell.vue';
+import JobPlayMutation from '~/jobs/components/table/graphql/mutations/job_play.mutation.graphql';
+import JobRetryMutation from '~/jobs/components/table/graphql/mutations/job_retry.mutation.graphql';
+import JobUnscheduleMutation from '~/jobs/components/table/graphql/mutations/job_unschedule.mutation.graphql';
+import { playableJob, retryableJob, scheduledJob } from '../../../mock_data';
+
+describe('Job actions cell', () => {
+ let wrapper;
+ let mutate;
+
+ const findRetryButton = () => wrapper.findByTestId('retry');
+ const findPlayButton = () => wrapper.findByTestId('play');
+ const findDownloadArtifactsButton = () => wrapper.findByTestId('download-artifacts');
+ const findCountdownButton = () => wrapper.findByTestId('countdown');
+ const findPlayScheduledJobButton = () => wrapper.findByTestId('play-scheduled');
+ const findUnscheduleButton = () => wrapper.findByTestId('unschedule');
+
+ const findModal = () => wrapper.findComponent(GlModal);
+
+ const MUTATION_SUCCESS = { data: { JobRetryMutation: { jobId: retryableJob.id } } };
+ const MUTATION_SUCCESS_UNSCHEDULE = {
+ data: { JobUnscheduleMutation: { jobId: scheduledJob.id } },
+ };
+ const MUTATION_SUCCESS_PLAY = { data: { JobPlayMutation: { jobId: playableJob.id } } };
+
+ const $toast = {
+ show: jest.fn(),
+ };
+
+ const createComponent = (jobType, mutationType = MUTATION_SUCCESS, props = {}) => {
+ mutate = jest.fn().mockResolvedValue(mutationType);
+
+ wrapper = shallowMountExtended(ActionsCell, {
+ propsData: {
+ job: jobType,
+ ...props,
+ },
+ mocks: {
+ $apollo: {
+ mutate,
+ },
+ $toast,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('does not display an artifacts download button', () => {
+ createComponent(retryableJob);
+
+ expect(findDownloadArtifactsButton().exists()).toBe(false);
+ });
+
+ it.each`
+ button | action | jobType
+ ${findPlayButton} | ${'play'} | ${playableJob}
+ ${findRetryButton} | ${'retry'} | ${retryableJob}
+ ${findDownloadArtifactsButton} | ${'download artifacts'} | ${playableJob}
+ `('displays the $action button', ({ button, jobType }) => {
+ createComponent(jobType);
+
+ expect(button().exists()).toBe(true);
+ });
+
+ it.each`
+ button | mutationResult | action | jobType | mutationFile
+ ${findPlayButton} | ${MUTATION_SUCCESS_PLAY} | ${'play'} | ${playableJob} | ${JobPlayMutation}
+ ${findRetryButton} | ${MUTATION_SUCCESS} | ${'retry'} | ${retryableJob} | ${JobRetryMutation}
+ `('performs the $action mutation', ({ button, mutationResult, jobType, mutationFile }) => {
+ createComponent(jobType, mutationResult);
+
+ button().vm.$emit('click');
+
+ expect(mutate).toHaveBeenCalledWith({
+ mutation: mutationFile,
+ variables: {
+ id: jobType.id,
+ },
+ });
+ });
+
+ describe('Scheduled Jobs', () => {
+ const today = () => new Date('2021-08-31');
+
+ beforeEach(() => {
+ jest.spyOn(Date, 'now').mockImplementation(today);
+ });
+
+ it('displays the countdown, play and unschedule buttons', () => {
+ createComponent(scheduledJob);
+
+ expect(findCountdownButton().exists()).toBe(true);
+ expect(findPlayScheduledJobButton().exists()).toBe(true);
+ expect(findUnscheduleButton().exists()).toBe(true);
+ });
+
+ it('unschedules a job', () => {
+ createComponent(scheduledJob, MUTATION_SUCCESS_UNSCHEDULE);
+
+ findUnscheduleButton().vm.$emit('click');
+
+ expect(mutate).toHaveBeenCalledWith({
+ mutation: JobUnscheduleMutation,
+ variables: {
+ id: scheduledJob.id,
+ },
+ });
+ });
+
+ it('shows the play job confirmation modal', async () => {
+ createComponent(scheduledJob, MUTATION_SUCCESS);
+
+ findPlayScheduledJobButton().vm.$emit('click');
+
+ await nextTick();
+
+ expect(findModal().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/table/cells.vue/duration_cell_spec.js b/spec/frontend/jobs/components/table/cells/duration_cell_spec.js
index 763a4b0eaa2..763a4b0eaa2 100644
--- a/spec/frontend/jobs/components/table/cells.vue/duration_cell_spec.js
+++ b/spec/frontend/jobs/components/table/cells/duration_cell_spec.js
diff --git a/spec/frontend/jobs/components/table/cells.vue/job_cell_spec.js b/spec/frontend/jobs/components/table/cells/job_cell_spec.js
index fc4e5586349..fc4e5586349 100644
--- a/spec/frontend/jobs/components/table/cells.vue/job_cell_spec.js
+++ b/spec/frontend/jobs/components/table/cells/job_cell_spec.js
diff --git a/spec/frontend/jobs/components/table/cells.vue/pipeline_cell_spec.js b/spec/frontend/jobs/components/table/cells/pipeline_cell_spec.js
index 1f5e0a7aa21..1f5e0a7aa21 100644
--- a/spec/frontend/jobs/components/table/cells.vue/pipeline_cell_spec.js
+++ b/spec/frontend/jobs/components/table/cells/pipeline_cell_spec.js
diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js
index 57f0b852ff8..43755b46bc9 100644
--- a/spec/frontend/jobs/mock_data.js
+++ b/spec/frontend/jobs/mock_data.js
@@ -1555,7 +1555,11 @@ export const mockJobsQueryResponse = {
cancelable: false,
active: false,
stuck: false,
- userPermissions: { readBuild: true, __typename: 'JobPermissions' },
+ userPermissions: {
+ readBuild: true,
+ readJobArtifacts: true,
+ __typename: 'JobPermissions',
+ },
__typename: 'CiJob',
},
],
@@ -1573,3 +1577,179 @@ export const mockJobsQueryEmptyResponse = {
},
},
};
+
+export const retryableJob = {
+ artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' },
+ allowFailure: false,
+ status: 'SUCCESS',
+ scheduledAt: null,
+ manualJob: false,
+ triggered: null,
+ createdByTag: false,
+ detailedStatus: {
+ detailsPath: '/root/test-job-artifacts/-/jobs/1981',
+ group: 'success',
+ icon: 'status_success',
+ label: 'passed',
+ text: 'passed',
+ tooltip: 'passed',
+ action: {
+ buttonTitle: 'Retry this job',
+ icon: 'retry',
+ method: 'post',
+ path: '/root/test-job-artifacts/-/jobs/1981/retry',
+ title: 'Retry',
+ __typename: 'StatusAction',
+ },
+ __typename: 'DetailedStatus',
+ },
+ id: 'gid://gitlab/Ci::Build/1981',
+ refName: 'main',
+ refPath: '/root/test-job-artifacts/-/commits/main',
+ tags: [],
+ shortSha: '75daf01b',
+ commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055',
+ pipeline: {
+ id: 'gid://gitlab/Ci::Pipeline/288',
+ path: '/root/test-job-artifacts/-/pipelines/288',
+ user: {
+ webPath: '/root',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ __typename: 'UserCore',
+ },
+ __typename: 'Pipeline',
+ },
+ stage: { name: 'test', __typename: 'CiStage' },
+ name: 'hello_world',
+ duration: 7,
+ finishedAt: '2021-08-30T20:33:56Z',
+ coverage: null,
+ retryable: true,
+ playable: false,
+ cancelable: false,
+ active: false,
+ stuck: false,
+ userPermissions: { readBuild: true, __typename: 'JobPermissions' },
+ __typename: 'CiJob',
+};
+
+export const playableJob = {
+ artifacts: {
+ nodes: [
+ {
+ downloadPath: '/root/test-job-artifacts/-/jobs/1982/artifacts/download?file_type=trace',
+ __typename: 'CiJobArtifact',
+ },
+ ],
+ __typename: 'CiJobArtifactConnection',
+ },
+ allowFailure: false,
+ status: 'SUCCESS',
+ scheduledAt: null,
+ manualJob: true,
+ triggered: null,
+ createdByTag: false,
+ detailedStatus: {
+ detailsPath: '/root/test-job-artifacts/-/jobs/1982',
+ group: 'success',
+ icon: 'status_success',
+ label: 'manual play action',
+ text: 'passed',
+ tooltip: 'passed',
+ action: {
+ buttonTitle: 'Trigger this manual action',
+ icon: 'play',
+ method: 'post',
+ path: '/root/test-job-artifacts/-/jobs/1982/play',
+ title: 'Play',
+ __typename: 'StatusAction',
+ },
+ __typename: 'DetailedStatus',
+ },
+ id: 'gid://gitlab/Ci::Build/1982',
+ refName: 'main',
+ refPath: '/root/test-job-artifacts/-/commits/main',
+ tags: [],
+ shortSha: '75daf01b',
+ commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055',
+ pipeline: {
+ id: 'gid://gitlab/Ci::Pipeline/288',
+ path: '/root/test-job-artifacts/-/pipelines/288',
+ user: {
+ webPath: '/root',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ __typename: 'UserCore',
+ },
+ __typename: 'Pipeline',
+ },
+ stage: { name: 'test', __typename: 'CiStage' },
+ name: 'hello_world_delayed',
+ duration: 6,
+ finishedAt: '2021-08-30T20:36:12Z',
+ coverage: null,
+ retryable: true,
+ playable: true,
+ cancelable: false,
+ active: false,
+ stuck: false,
+ userPermissions: { readBuild: true, readJobArtifacts: true, __typename: 'JobPermissions' },
+ __typename: 'CiJob',
+};
+
+export const scheduledJob = {
+ artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' },
+ allowFailure: false,
+ status: 'SCHEDULED',
+ scheduledAt: '2021-08-31T22:36:05Z',
+ manualJob: true,
+ triggered: null,
+ createdByTag: false,
+ detailedStatus: {
+ detailsPath: '/root/test-job-artifacts/-/jobs/1986',
+ group: 'scheduled',
+ icon: 'status_scheduled',
+ label: 'unschedule action',
+ text: 'delayed',
+ tooltip: 'delayed manual action (%{remainingTime})',
+ action: {
+ buttonTitle: 'Unschedule job',
+ icon: 'time-out',
+ method: 'post',
+ path: '/root/test-job-artifacts/-/jobs/1986/unschedule',
+ title: 'Unschedule',
+ __typename: 'StatusAction',
+ },
+ __typename: 'DetailedStatus',
+ },
+ id: 'gid://gitlab/Ci::Build/1986',
+ refName: 'main',
+ refPath: '/root/test-job-artifacts/-/commits/main',
+ tags: [],
+ shortSha: '75daf01b',
+ commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055',
+ pipeline: {
+ id: 'gid://gitlab/Ci::Pipeline/290',
+ path: '/root/test-job-artifacts/-/pipelines/290',
+ user: {
+ webPath: '/root',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ __typename: 'UserCore',
+ },
+ __typename: 'Pipeline',
+ },
+ stage: { name: 'test', __typename: 'CiStage' },
+ name: 'hello_world_delayed',
+ duration: null,
+ finishedAt: null,
+ coverage: null,
+ retryable: false,
+ playable: true,
+ cancelable: false,
+ active: false,
+ stuck: false,
+ userPermissions: { readBuild: true, __typename: 'JobPermissions' },
+ __typename: 'CiJob',
+};
diff --git a/spec/frontend/learn_gitlab/track_learn_gitlab_spec.js b/spec/frontend/learn_gitlab/track_learn_gitlab_spec.js
deleted file mode 100644
index 3fb38a74c70..00000000000
--- a/spec/frontend/learn_gitlab/track_learn_gitlab_spec.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { mockTracking } from 'helpers/tracking_helper';
-import trackLearnGitlab from '~/learn_gitlab/track_learn_gitlab';
-
-describe('trackTrialUserErrors', () => {
- let spy;
-
- describe('when an error is present', () => {
- beforeEach(() => {
- spy = mockTracking('projects:learn_gitlab_index', document.body, jest.spyOn);
- });
-
- it('tracks the error message', () => {
- trackLearnGitlab();
-
- expect(spy).toHaveBeenCalledWith('projects:learn_gitlab:index', 'page_init', {
- label: 'learn_gitlab',
- property: 'Growth::Activation::Experiment::LearnGitLabB',
- });
- });
- });
-});
diff --git a/spec/frontend/lib/apollo/instrumentation_link_spec.js b/spec/frontend/lib/apollo/instrumentation_link_spec.js
new file mode 100644
index 00000000000..ef686129257
--- /dev/null
+++ b/spec/frontend/lib/apollo/instrumentation_link_spec.js
@@ -0,0 +1,54 @@
+import { testApolloLink } from 'helpers/test_apollo_link';
+import { getInstrumentationLink, FEATURE_CATEGORY_HEADER } from '~/lib/apollo/instrumentation_link';
+
+const TEST_FEATURE_CATEGORY = 'foo_feature';
+
+describe('~/lib/apollo/instrumentation_link', () => {
+ const setFeatureCategory = (val) => {
+ window.gon.feature_category = val;
+ };
+
+ afterEach(() => {
+ getInstrumentationLink.cache.clear();
+ });
+
+ describe('getInstrumentationLink', () => {
+ describe('with no gon.feature_category', () => {
+ beforeEach(() => {
+ setFeatureCategory(null);
+ });
+
+ it('returns null', () => {
+ expect(getInstrumentationLink()).toBe(null);
+ });
+ });
+
+ describe('with gon.feature_category', () => {
+ beforeEach(() => {
+ setFeatureCategory(TEST_FEATURE_CATEGORY);
+ });
+
+ it('returns memoized apollo link', () => {
+ const result = getInstrumentationLink();
+
+ // expect.any(ApolloLink) doesn't work for some reason...
+ expect(result).toHaveProp('request');
+ expect(result).toBe(getInstrumentationLink());
+ });
+
+ it('adds a feature category header from the returned apollo link', async () => {
+ const defaultHeaders = { Authorization: 'foo' };
+ const operation = await testApolloLink(getInstrumentationLink(), {
+ context: { headers: defaultHeaders },
+ });
+
+ const { headers } = operation.getContext();
+
+ expect(headers).toEqual({
+ ...defaultHeaders,
+ [FEATURE_CATEGORY_HEADER]: TEST_FEATURE_CATEGORY,
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/lib/dompurify_spec.js b/spec/frontend/lib/dompurify_spec.js
index fa8dbb12a08..324441fa2c9 100644
--- a/spec/frontend/lib/dompurify_spec.js
+++ b/spec/frontend/lib/dompurify_spec.js
@@ -44,6 +44,31 @@ describe('~/lib/dompurify', () => {
expect(sanitize('<strong></strong>', { ALLOWED_TAGS: [] })).toBe('');
});
+ describe('includes default configuration', () => {
+ it('with empty config', () => {
+ const svgIcon = '<svg width="100"><use></use></svg>';
+ expect(sanitize(svgIcon, {})).toBe(svgIcon);
+ });
+
+ it('with valid config', () => {
+ expect(sanitize('<a href="#" data-remote="true"></a>', { ALLOWED_TAGS: ['a'] })).toBe(
+ '<a href="#"></a>',
+ );
+ });
+ });
+
+ it("doesn't sanitize local references", () => {
+ const htmlHref = `<svg><use href="#some-element"></use></svg>`;
+ const htmlXlink = `<svg><use xlink:href="#some-element"></use></svg>`;
+
+ expect(sanitize(htmlHref)).toBe(htmlHref);
+ expect(sanitize(htmlXlink)).toBe(htmlXlink);
+ });
+
+ it("doesn't sanitize gl-emoji", () => {
+ expect(sanitize('<p><gl-emoji>💯</gl-emoji></p>')).toBe('<p><gl-emoji>💯</gl-emoji></p>');
+ });
+
describe.each`
type | gon
${'root'} | ${rootGon}
diff --git a/spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap b/spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap
new file mode 100644
index 00000000000..791ec05befd
--- /dev/null
+++ b/spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap
@@ -0,0 +1,16 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`~/lib/logger/hello logHello console logs a friendly hello message 1`] = `
+Array [
+ Array [
+ "%cWelcome to GitLab!%c
+
+Does this page need fixes or improvements? Open an issue or contribute a merge request to help make GitLab more lovable. At GitLab, everyone can contribute!
+
+🤝 Contribute to GitLab: https://about.gitlab.com/community/contribute/
+🔎 Create a new GitLab issue: https://gitlab.com/gitlab-org/gitlab/-/issues/new",
+ "padding-top: 0.5em; font-size: 2em;",
+ "padding-bottom: 0.5em;",
+ ],
+]
+`;
diff --git a/spec/frontend/lib/logger/hello_deferred_spec.js b/spec/frontend/lib/logger/hello_deferred_spec.js
new file mode 100644
index 00000000000..3233cbff0dc
--- /dev/null
+++ b/spec/frontend/lib/logger/hello_deferred_spec.js
@@ -0,0 +1,17 @@
+import waitForPromises from 'helpers/wait_for_promises';
+import { logHello } from '~/lib/logger/hello';
+import { logHelloDeferred } from '~/lib/logger/hello_deferred';
+
+jest.mock('~/lib/logger/hello');
+
+describe('~/lib/logger/hello_deferred', () => {
+ it('dynamically imports and calls logHello', async () => {
+ logHelloDeferred();
+
+ expect(logHello).not.toHaveBeenCalled();
+
+ await waitForPromises();
+
+ expect(logHello).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/lib/logger/hello_spec.js b/spec/frontend/lib/logger/hello_spec.js
new file mode 100644
index 00000000000..39abe0e0dd0
--- /dev/null
+++ b/spec/frontend/lib/logger/hello_spec.js
@@ -0,0 +1,20 @@
+import { logHello } from '~/lib/logger/hello';
+
+describe('~/lib/logger/hello', () => {
+ let consoleLogSpy;
+
+ beforeEach(() => {
+ // We don't `mockImplementation` so we can validate there's no errors thrown
+ consoleLogSpy = jest.spyOn(console, 'log');
+ });
+
+ describe('logHello', () => {
+ it('console logs a friendly hello message', () => {
+ expect(consoleLogSpy).not.toHaveBeenCalled();
+
+ logHello();
+
+ expect(consoleLogSpy.mock.calls).toMatchSnapshot();
+ });
+ });
+});
diff --git a/spec/frontend/lib/logger/index_spec.js b/spec/frontend/lib/logger/index_spec.js
new file mode 100644
index 00000000000..9382fafe4de
--- /dev/null
+++ b/spec/frontend/lib/logger/index_spec.js
@@ -0,0 +1,23 @@
+import { logError, LOG_PREFIX } from '~/lib/logger';
+
+describe('~/lib/logger', () => {
+ let consoleErrorSpy;
+
+ beforeEach(() => {
+ consoleErrorSpy = jest.spyOn(console, 'error');
+ consoleErrorSpy.mockImplementation();
+ });
+
+ describe('logError', () => {
+ it('sends given message to console.error', () => {
+ const message = 'Lorem ipsum dolar sit amit';
+ const error = new Error('lorem ipsum');
+
+ expect(consoleErrorSpy).not.toHaveBeenCalled();
+
+ logError(message, error);
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(LOG_PREFIX, `${message}\n`, error);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/accessor_spec.js b/spec/frontend/lib/utils/accessor_spec.js
index 752a88296e6..63497d795ce 100644
--- a/spec/frontend/lib/utils/accessor_spec.js
+++ b/spec/frontend/lib/utils/accessor_spec.js
@@ -6,60 +6,9 @@ describe('AccessorUtilities', () => {
const testError = new Error('test error');
- describe('isPropertyAccessSafe', () => {
- let base;
-
- it('should return `true` if access is safe', () => {
- base = {
- testProp: 'testProp',
- };
- expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(true);
- });
-
- it('should return `false` if access throws an error', () => {
- base = {
- get testProp() {
- throw testError;
- },
- };
-
- expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(false);
- });
-
- it('should return `false` if property is undefined', () => {
- base = {};
-
- expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(false);
- });
- });
-
- describe('isFunctionCallSafe', () => {
- const base = {};
-
- it('should return `true` if calling is safe', () => {
- base.func = () => {};
-
- expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(true);
- });
-
- it('should return `false` if calling throws an error', () => {
- base.func = () => {
- throw new Error('test error');
- };
-
- expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(false);
- });
-
- it('should return `false` if function is undefined', () => {
- base.func = undefined;
-
- expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(false);
- });
- });
-
- describe('isLocalStorageAccessSafe', () => {
+ describe('canUseLocalStorage', () => {
it('should return `true` if access is safe', () => {
- expect(AccessorUtilities.isLocalStorageAccessSafe()).toBe(true);
+ expect(AccessorUtilities.canUseLocalStorage()).toBe(true);
});
it('should return `false` if access to .setItem isnt safe', () => {
@@ -67,19 +16,19 @@ describe('AccessorUtilities', () => {
throw testError;
});
- expect(AccessorUtilities.isLocalStorageAccessSafe()).toBe(false);
+ expect(AccessorUtilities.canUseLocalStorage()).toBe(false);
});
it('should set a test item if access is safe', () => {
- AccessorUtilities.isLocalStorageAccessSafe();
+ AccessorUtilities.canUseLocalStorage();
- expect(window.localStorage.setItem).toHaveBeenCalledWith('isLocalStorageAccessSafe', 'true');
+ expect(window.localStorage.setItem).toHaveBeenCalledWith('canUseLocalStorage', 'true');
});
it('should remove the test item if access is safe', () => {
- AccessorUtilities.isLocalStorageAccessSafe();
+ AccessorUtilities.canUseLocalStorage();
- expect(window.localStorage.removeItem).toHaveBeenCalledWith('isLocalStorageAccessSafe');
+ expect(window.localStorage.removeItem).toHaveBeenCalledWith('canUseLocalStorage');
});
});
});
diff --git a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js
new file mode 100644
index 00000000000..942ba56196e
--- /dev/null
+++ b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js
@@ -0,0 +1,120 @@
+import * as utils from '~/lib/utils/datetime/date_format_utility';
+
+describe('date_format_utility.js', () => {
+ describe('padWithZeros', () => {
+ it.each`
+ input | output
+ ${0} | ${'00'}
+ ${'1'} | ${'01'}
+ ${'10'} | ${'10'}
+ ${'100'} | ${'100'}
+ ${100} | ${'100'}
+ ${'a'} | ${'0a'}
+ ${'foo'} | ${'foo'}
+ `('properly pads $input to match $output', ({ input, output }) => {
+ expect(utils.padWithZeros(input)).toEqual([output]);
+ });
+
+ it('accepts multiple arguments', () => {
+ expect(utils.padWithZeros(1, '2', 3)).toEqual(['01', '02', '03']);
+ });
+
+ it('returns an empty array provided no argument', () => {
+ expect(utils.padWithZeros()).toEqual([]);
+ });
+ });
+
+ describe('stripTimezoneFromISODate', () => {
+ it.each`
+ input | expectedOutput
+ ${'2021-08-16T00:00:00Z'} | ${'2021-08-16T00:00:00'}
+ ${'2021-08-16T10:30:00+02:00'} | ${'2021-08-16T10:30:00'}
+ ${'2021-08-16T10:30:00-05:30'} | ${'2021-08-16T10:30:00'}
+ `('returns $expectedOutput when given $input', ({ input, expectedOutput }) => {
+ expect(utils.stripTimezoneFromISODate(input)).toBe(expectedOutput);
+ });
+
+ it('returns null if date is invalid', () => {
+ expect(utils.stripTimezoneFromISODate('Invalid date')).toBe(null);
+ });
+ });
+
+ describe('dateToYearMonthDate', () => {
+ it.each`
+ date | expectedOutput
+ ${new Date('2021-08-05')} | ${{ year: '2021', month: '08', day: '05' }}
+ ${new Date('2021-12-24')} | ${{ year: '2021', month: '12', day: '24' }}
+ `('returns $expectedOutput provided $date', ({ date, expectedOutput }) => {
+ expect(utils.dateToYearMonthDate(date)).toEqual(expectedOutput);
+ });
+
+ it('throws provided an invalid date', () => {
+ expect(() => utils.dateToYearMonthDate('Invalid date')).toThrow(
+ 'Argument should be a Date instance',
+ );
+ });
+ });
+
+ describe('timeToHoursMinutes', () => {
+ it.each`
+ time | expectedOutput
+ ${'23:12'} | ${{ hours: '23', minutes: '12' }}
+ ${'23:12'} | ${{ hours: '23', minutes: '12' }}
+ `('returns $expectedOutput provided $time', ({ time, expectedOutput }) => {
+ expect(utils.timeToHoursMinutes(time)).toEqual(expectedOutput);
+ });
+
+ it('throws provided an invalid time', () => {
+ expect(() => utils.timeToHoursMinutes('Invalid time')).toThrow('Invalid time provided');
+ });
+ });
+
+ describe('dateAndTimeToISOString', () => {
+ it('computes the date properly', () => {
+ expect(utils.dateAndTimeToISOString(new Date('2021-08-16'), '10:00')).toBe(
+ '2021-08-16T10:00:00.000Z',
+ );
+ });
+
+ it('computes the date properly with an offset', () => {
+ expect(utils.dateAndTimeToISOString(new Date('2021-08-16'), '10:00', '-04:00')).toBe(
+ '2021-08-16T10:00:00.000-04:00',
+ );
+ });
+
+ it('throws if date in invalid', () => {
+ expect(() => utils.dateAndTimeToISOString('Invalid date', '10:00')).toThrow(
+ 'Argument should be a Date instance',
+ );
+ });
+
+ it('throws if time in invalid', () => {
+ expect(() => utils.dateAndTimeToISOString(new Date('2021-08-16'), '')).toThrow(
+ 'Invalid time provided',
+ );
+ });
+
+ it('throws if offset is invalid', () => {
+ expect(() =>
+ utils.dateAndTimeToISOString(new Date('2021-08-16'), '10:00', 'not an offset'),
+ ).toThrow('Could not initialize date');
+ });
+ });
+
+ describe('dateToTimeInputValue', () => {
+ it.each`
+ input | expectedOutput
+ ${new Date('2021-08-16T10:00:00.000Z')} | ${'10:00'}
+ ${new Date('2021-08-16T22:30:00.000Z')} | ${'22:30'}
+ ${new Date('2021-08-16T22:30:00.000-03:00')} | ${'01:30'}
+ `('extracts $expectedOutput out of $input', ({ input, expectedOutput }) => {
+ expect(utils.dateToTimeInputValue(input)).toBe(expectedOutput);
+ });
+
+ it('throws if date is invalid', () => {
+ expect(() => utils.dateToTimeInputValue('Invalid date')).toThrow(
+ 'Argument should be a Date instance',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/dom_utils_spec.js b/spec/frontend/lib/utils/dom_utils_spec.js
index 7c4c20e651f..cb8b1c7ca9a 100644
--- a/spec/frontend/lib/utils/dom_utils_spec.js
+++ b/spec/frontend/lib/utils/dom_utils_spec.js
@@ -5,6 +5,7 @@ import {
parseBooleanDataAttributes,
isElementVisible,
isElementHidden,
+ getParents,
} from '~/lib/utils/dom_utils';
const TEST_MARGIN = 5;
@@ -193,4 +194,18 @@ describe('DOM Utils', () => {
});
},
);
+
+ describe('getParents', () => {
+ it('gets all parents of an element', () => {
+ const el = document.createElement('div');
+ el.innerHTML = '<p><span><strong><mark>hello world';
+
+ expect(getParents(el.querySelector('mark'))).toEqual([
+ el.querySelector('strong'),
+ el.querySelector('span'),
+ el.querySelector('p'),
+ el,
+ ]);
+ });
+ });
});
diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js
index beedb9b2eba..acbf1a975b8 100644
--- a/spec/frontend/lib/utils/text_markdown_spec.js
+++ b/spec/frontend/lib/utils/text_markdown_spec.js
@@ -88,6 +88,25 @@ describe('init markdown', () => {
expect(textArea.value).toEqual(`${initialValue}\n- `);
});
+ it('unescapes new line characters', () => {
+ const initialValue = '';
+
+ textArea.value = initialValue;
+ textArea.selectionStart = 0;
+ textArea.selectionEnd = 0;
+
+ insertMarkdownText({
+ textArea,
+ text: textArea.value,
+ tag: '```suggestion:-0+0\n{text}\n```',
+ blockTag: true,
+ selected: '# Does not parse the %br currently.',
+ wrap: false,
+ });
+
+ expect(textArea.value).toContain('# Does not parse the \\n currently.');
+ });
+
it('inserts the tag on the same line if the current line only contains spaces', () => {
const initialValue = ' ';
diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js
index c8ac7ffc9d9..6f186ba3227 100644
--- a/spec/frontend/lib/utils/url_utility_spec.js
+++ b/spec/frontend/lib/utils/url_utility_spec.js
@@ -645,29 +645,6 @@ describe('URL utility', () => {
});
});
- describe('urlParamsToObject', () => {
- it('parses path for label with trailing +', () => {
- // eslint-disable-next-line import/no-deprecated
- expect(urlUtils.urlParamsToObject('label_name[]=label%2B', {})).toEqual({
- label_name: ['label+'],
- });
- });
-
- it('parses path for milestone with trailing +', () => {
- // eslint-disable-next-line import/no-deprecated
- expect(urlUtils.urlParamsToObject('milestone_title=A%2B', {})).toEqual({
- milestone_title: 'A+',
- });
- });
-
- it('parses path for search terms with spaces', () => {
- // eslint-disable-next-line import/no-deprecated
- expect(urlUtils.urlParamsToObject('search=two+words', {})).toEqual({
- search: 'two words',
- });
- });
- });
-
describe('queryToObject', () => {
it.each`
case | query | options | result
diff --git a/spec/frontend/members/components/modals/leave_modal_spec.js b/spec/frontend/members/components/modals/leave_modal_spec.js
index ea9eb7bf923..1dc913e5c78 100644
--- a/spec/frontend/members/components/modals/leave_modal_spec.js
+++ b/spec/frontend/members/components/modals/leave_modal_spec.js
@@ -99,10 +99,14 @@ describe('LeaveModal', () => {
});
});
- it("does NOT display oncall schedules list when member's user is NOT a part of on-call schedules ", () => {
+ it("does NOT display oncall schedules list when member's user is NOT a part of on-call schedules ", async () => {
+ wrapper.destroy();
+
const memberWithoutOncallSchedules = cloneDeep(member);
- delete (memberWithoutOncallSchedules, 'user.oncallSchedules');
+ delete memberWithoutOncallSchedules.user.oncallSchedules;
createComponent({ member: memberWithoutOncallSchedules });
+ await nextTick();
+
expect(findOncallSchedulesList().exists()).toBe(false);
});
});
diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js
index 23e9bf8b447..ced9b71125b 100644
--- a/spec/frontend/merge_request_tabs_spec.js
+++ b/spec/frontend/merge_request_tabs_spec.js
@@ -34,6 +34,44 @@ describe('MergeRequestTabs', () => {
gl.mrWidget = {};
});
+ describe('clickTab', () => {
+ let params;
+
+ beforeEach(() => {
+ document.documentElement.scrollTop = 100;
+
+ params = {
+ metaKey: false,
+ ctrlKey: false,
+ which: 1,
+ stopImmediatePropagation() {},
+ preventDefault() {},
+ currentTarget: {
+ getAttribute(attr) {
+ return attr === 'href' ? 'a/tab/url' : null;
+ },
+ },
+ };
+ });
+
+ it("stores the current scroll position if there's an active tab", () => {
+ testContext.class.currentTab = 'someTab';
+
+ testContext.class.clickTab(params);
+
+ expect(testContext.class.scrollPositions.someTab).toBe(100);
+ });
+
+ it("doesn't store a scroll position if there's no active tab", () => {
+ // this happens on first load, and we just don't want to store empty values in the `null` property
+ testContext.class.currentTab = null;
+
+ testContext.class.clickTab(params);
+
+ expect(testContext.class.scrollPositions).toEqual({});
+ });
+ });
+
describe('opensInNewTab', () => {
const windowTarget = '_blank';
let clickTabParams;
@@ -258,6 +296,7 @@ describe('MergeRequestTabs', () => {
beforeEach(() => {
jest.spyOn(mainContent, 'getBoundingClientRect').mockReturnValue({ top: 10 });
jest.spyOn(tabContent, 'getBoundingClientRect').mockReturnValue({ top: 100 });
+ jest.spyOn(window, 'scrollTo').mockImplementation(() => {});
jest.spyOn(document, 'querySelector').mockImplementation((selector) => {
return selector === '.content-wrapper' ? mainContent : tabContent;
});
@@ -267,8 +306,6 @@ describe('MergeRequestTabs', () => {
it('calls window scrollTo with options if document has scrollBehavior', () => {
document.documentElement.style.scrollBehavior = '';
- jest.spyOn(window, 'scrollTo').mockImplementation(() => {});
-
testContext.class.tabShown('commits', 'foobar');
expect(window.scrollTo.mock.calls[0][0]).toEqual({ top: 39, behavior: 'smooth' });
@@ -276,11 +313,50 @@ describe('MergeRequestTabs', () => {
it('calls window scrollTo with two args if document does not have scrollBehavior', () => {
jest.spyOn(document.documentElement, 'style', 'get').mockReturnValue({});
- jest.spyOn(window, 'scrollTo').mockImplementation(() => {});
testContext.class.tabShown('commits', 'foobar');
expect(window.scrollTo.mock.calls[0]).toEqual([0, 39]);
});
+
+ describe('when switching tabs', () => {
+ const SCROLL_TOP = 100;
+
+ beforeAll(() => {
+ jest.useFakeTimers();
+ });
+
+ beforeEach(() => {
+ jest.spyOn(window, 'scrollTo').mockImplementation(() => {});
+ testContext.class.mergeRequestTabs = document.createElement('div');
+ testContext.class.mergeRequestTabPanes = document.createElement('div');
+ testContext.class.currentTab = 'tab';
+ testContext.class.scrollPositions = { newTab: SCROLL_TOP };
+ });
+
+ afterAll(() => {
+ jest.useRealTimers();
+ });
+
+ it('scrolls to the stored position, if one is stored', () => {
+ testContext.class.tabShown('newTab');
+
+ jest.advanceTimersByTime(250);
+
+ expect(window.scrollTo.mock.calls[0][0]).toEqual({
+ top: SCROLL_TOP,
+ left: 0,
+ behavior: 'auto',
+ });
+ });
+
+ it('scrolls to 0, if no position is stored', () => {
+ testContext.class.tabShown('unknownTab');
+
+ jest.advanceTimersByTime(250);
+
+ expect(window.scrollTo.mock.calls[0][0]).toEqual({ top: 0, left: 0, behavior: 'auto' });
+ });
+ });
});
});
diff --git a/spec/frontend/milestones/stores/mutations_spec.js b/spec/frontend/milestones/stores/mutations_spec.js
index 91b2acf23c5..a53d6ca5de1 100644
--- a/spec/frontend/milestones/stores/mutations_spec.js
+++ b/spec/frontend/milestones/stores/mutations_spec.js
@@ -174,6 +174,35 @@ describe('Milestones combobox Vuex store mutations', () => {
});
});
+ it('falls back to the length of list if pagination headers are missing', () => {
+ const response = {
+ data: [
+ {
+ title: 'v0.1',
+ },
+ {
+ title: 'v0.2',
+ },
+ ],
+ headers: {},
+ };
+
+ mutations[types.RECEIVE_PROJECT_MILESTONES_SUCCESS](state, response);
+
+ expect(state.matches.projectMilestones).toEqual({
+ list: [
+ {
+ title: 'v0.1',
+ },
+ {
+ title: 'v0.2',
+ },
+ ],
+ error: null,
+ totalCount: 2,
+ });
+ });
+
describe(`${types.RECEIVE_PROJECT_MILESTONES_ERROR}`, () => {
it('updates state.matches.projectMilestones to an empty state with the error object', () => {
const error = new Error('Something went wrong!');
@@ -227,6 +256,35 @@ describe('Milestones combobox Vuex store mutations', () => {
});
});
+ it('falls back to the length of data received if pagination headers are missing', () => {
+ const response = {
+ data: [
+ {
+ title: 'group-0.1',
+ },
+ {
+ title: 'group-0.2',
+ },
+ ],
+ headers: {},
+ };
+
+ mutations[types.RECEIVE_GROUP_MILESTONES_SUCCESS](state, response);
+
+ expect(state.matches.groupMilestones).toEqual({
+ list: [
+ {
+ title: 'group-0.1',
+ },
+ {
+ title: 'group-0.2',
+ },
+ ],
+ error: null,
+ totalCount: 2,
+ });
+ });
+
describe(`${types.RECEIVE_GROUP_MILESTONES_ERROR}`, () => {
it('updates state.matches.groupMilestones to an empty state with the error object', () => {
const error = new Error('Something went wrong!');
diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
index 08f9e07244f..05538dbaeee 100644
--- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
+++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
@@ -36,11 +36,15 @@ exports[`Dashboard template matches the default snapshot 1`] = `
<gl-dropdown-stub
category="primary"
class="flex-grow-1"
+ clearalltext="Clear all"
data-qa-selector="environments_dropdown"
headertext=""
hideheaderborder="true"
+ highlighteditemstitle="Selected"
+ highlighteditemstitleclass="gl-px-5"
id="monitor-environments-dropdown"
menu-class="monitor-environment-dropdown-menu"
+ showhighlighteditemstitle="true"
size="medium"
text="production"
toggleclass="dropdown-menu-toggle"
diff --git a/spec/frontend/notebook/cells/markdown_spec.js b/spec/frontend/notebook/cells/markdown_spec.js
index deeee5d6589..707efa21528 100644
--- a/spec/frontend/notebook/cells/markdown_spec.js
+++ b/spec/frontend/notebook/cells/markdown_spec.js
@@ -1,3 +1,4 @@
+import { mount } from '@vue/test-utils';
import katex from 'katex';
import Vue from 'vue';
import MarkdownComponent from '~/notebook/cells/markdown.vue';
@@ -6,6 +7,28 @@ const Component = Vue.extend(MarkdownComponent);
window.katex = katex;
+function buildCellComponent(cell, relativePath = '') {
+ return mount(Component, {
+ propsData: {
+ cell,
+ },
+ provide: {
+ relativeRawPath: relativePath,
+ },
+ }).vm;
+}
+
+function buildMarkdownComponent(markdownContent, relativePath = '') {
+ return buildCellComponent(
+ {
+ cell_type: 'markdown',
+ metadata: {},
+ source: markdownContent,
+ },
+ relativePath,
+ );
+}
+
describe('Markdown component', () => {
let vm;
let cell;
@@ -17,12 +40,7 @@ describe('Markdown component', () => {
// eslint-disable-next-line prefer-destructuring
cell = json.cells[1];
- vm = new Component({
- propsData: {
- cell,
- },
- });
- vm.$mount();
+ vm = buildCellComponent(cell);
return vm.$nextTick();
});
@@ -61,17 +79,36 @@ describe('Markdown component', () => {
expect(findLink().getAttribute('data-type')).toBe(null);
});
+ describe('When parsing images', () => {
+ it.each([
+ [
+ 'for relative images in root folder, it does',
+ '![](local_image.png)\n',
+ 'src="/raw/local_image',
+ ],
+ [
+ 'for relative images in child folders, it does',
+ '![](data/local_image.png)\n',
+ 'src="/raw/data',
+ ],
+ ["for embedded images, it doesn't", '![](data:image/jpeg;base64)\n', 'src="data:'],
+ ["for images urls, it doesn't", '![](http://image.png)\n', 'src="http:'],
+ ])('%s', async ([testMd, mustContain]) => {
+ vm = buildMarkdownComponent([testMd], '/raw/');
+
+ await vm.$nextTick();
+
+ expect(vm.$el.innerHTML).toContain(mustContain);
+ });
+ });
+
describe('tables', () => {
beforeEach(() => {
json = getJSONFixture('blob/notebook/markdown-table.json');
});
it('renders images and text', () => {
- vm = new Component({
- propsData: {
- cell: json.cells[0],
- },
- }).$mount();
+ vm = buildCellComponent(json.cells[0]);
return vm.$nextTick().then(() => {
const images = vm.$el.querySelectorAll('img');
@@ -102,48 +139,28 @@ describe('Markdown component', () => {
});
it('renders multi-line katex', async () => {
- vm = new Component({
- propsData: {
- cell: json.cells[0],
- },
- }).$mount();
+ vm = buildCellComponent(json.cells[0]);
await vm.$nextTick();
expect(vm.$el.querySelector('.katex')).not.toBeNull();
});
it('renders inline katex', async () => {
- vm = new Component({
- propsData: {
- cell: json.cells[1],
- },
- }).$mount();
+ vm = buildCellComponent(json.cells[1]);
await vm.$nextTick();
expect(vm.$el.querySelector('p:first-child .katex')).not.toBeNull();
});
it('renders multiple inline katex', async () => {
- vm = new Component({
- propsData: {
- cell: json.cells[1],
- },
- }).$mount();
+ vm = buildCellComponent(json.cells[1]);
await vm.$nextTick();
expect(vm.$el.querySelectorAll('p:nth-child(2) .katex')).toHaveLength(4);
});
it('output cell in case of katex error', async () => {
- vm = new Component({
- propsData: {
- cell: {
- cell_type: 'markdown',
- metadata: {},
- source: ['Some invalid $a & b$ inline formula $b & c$\n', '\n'],
- },
- },
- }).$mount();
+ vm = buildMarkdownComponent(['Some invalid $a & b$ inline formula $b & c$\n', '\n']);
await vm.$nextTick();
// expect one paragraph with no katex formula in it
@@ -152,15 +169,10 @@ describe('Markdown component', () => {
});
it('output cell and render remaining formula in case of katex error', async () => {
- vm = new Component({
- propsData: {
- cell: {
- cell_type: 'markdown',
- metadata: {},
- source: ['An invalid $a & b$ inline formula and a vaild one $b = c$\n', '\n'],
- },
- },
- }).$mount();
+ vm = buildMarkdownComponent([
+ 'An invalid $a & b$ inline formula and a vaild one $b = c$\n',
+ '\n',
+ ]);
await vm.$nextTick();
// expect one paragraph with no katex formula in it
@@ -169,15 +181,7 @@ describe('Markdown component', () => {
});
it('renders math formula in list object', async () => {
- vm = new Component({
- propsData: {
- cell: {
- cell_type: 'markdown',
- metadata: {},
- source: ["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n'],
- },
- },
- }).$mount();
+ vm = buildMarkdownComponent(["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n']);
await vm.$nextTick();
// expect one list with a katex formula in it
@@ -186,15 +190,7 @@ describe('Markdown component', () => {
});
it("renders math formula with tick ' in it", async () => {
- vm = new Component({
- propsData: {
- cell: {
- cell_type: 'markdown',
- metadata: {},
- source: ["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n'],
- },
- },
- }).$mount();
+ vm = buildMarkdownComponent(["- list with inline $a=2$ inline formula $a' + b = c$\n", '\n']);
await vm.$nextTick();
// expect one list with a katex formula in it
@@ -203,15 +199,7 @@ describe('Markdown component', () => {
});
it('renders math formula with less-than-operator < in it', async () => {
- vm = new Component({
- propsData: {
- cell: {
- cell_type: 'markdown',
- metadata: {},
- source: ['- list with inline $a=2$ inline formula $a + b < c$\n', '\n'],
- },
- },
- }).$mount();
+ vm = buildMarkdownComponent(['- list with inline $a=2$ inline formula $a + b < c$\n', '\n']);
await vm.$nextTick();
// expect one list with a katex formula in it
@@ -220,15 +208,7 @@ describe('Markdown component', () => {
});
it('renders math formula with greater-than-operator > in it', async () => {
- vm = new Component({
- propsData: {
- cell: {
- cell_type: 'markdown',
- metadata: {},
- source: ['- list with inline $a=2$ inline formula $a + b > c$\n', '\n'],
- },
- },
- }).$mount();
+ vm = buildMarkdownComponent(['- list with inline $a=2$ inline formula $a + b > c$\n', '\n']);
await vm.$nextTick();
// expect one list with a katex formula in it
diff --git a/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js b/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js
index 0b585ab860b..803ac4a219d 100644
--- a/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js
+++ b/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js
@@ -90,7 +90,8 @@ export default [
' </g>\n',
'</svg>',
].join(),
- output: '<svg height="115.02pt" id="svg2"',
+ output:
+ '<svg xmlns="http://www.w3.org/2000/svg" width="388.84pt" version="1.0" id="svg2" height="115.02pt">',
},
],
];
diff --git a/spec/frontend/notebook/index_spec.js b/spec/frontend/notebook/index_spec.js
index 945af08e4d5..4d0dacaf37e 100644
--- a/spec/frontend/notebook/index_spec.js
+++ b/spec/frontend/notebook/index_spec.js
@@ -1,3 +1,4 @@
+import { mount } from '@vue/test-utils';
import Vue from 'vue';
import Notebook from '~/notebook/index.vue';
@@ -13,14 +14,16 @@ describe('Notebook component', () => {
jsonWithWorksheet = getJSONFixture('blob/notebook/worksheets.json');
});
+ function buildComponent(notebook) {
+ return mount(Component, {
+ propsData: { notebook, codeCssClass: 'js-code-class' },
+ provide: { relativeRawPath: '' },
+ }).vm;
+ }
+
describe('without JSON', () => {
beforeEach((done) => {
- vm = new Component({
- propsData: {
- notebook: {},
- },
- });
- vm.$mount();
+ vm = buildComponent({});
setImmediate(() => {
done();
@@ -34,13 +37,7 @@ describe('Notebook component', () => {
describe('with JSON', () => {
beforeEach((done) => {
- vm = new Component({
- propsData: {
- notebook: json,
- codeCssClass: 'js-code-class',
- },
- });
- vm.$mount();
+ vm = buildComponent(json);
setImmediate(() => {
done();
@@ -66,13 +63,7 @@ describe('Notebook component', () => {
describe('with worksheets', () => {
beforeEach((done) => {
- vm = new Component({
- propsData: {
- notebook: jsonWithWorksheet,
- codeCssClass: 'js-code-class',
- },
- });
- vm.$mount();
+ vm = buildComponent(jsonWithWorksheet);
setImmediate(() => {
done();
diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js
index bb79b43205b..c3a51c51de0 100644
--- a/spec/frontend/notes/components/comment_form_spec.js
+++ b/spec/frontend/notes/components/comment_form_spec.js
@@ -10,6 +10,7 @@ import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import CommentForm from '~/notes/components/comment_form.vue';
+import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue';
import * as constants from '~/notes/constants';
import eventHub from '~/notes/event_hub';
import { COMMENT_FORM } from '~/notes/i18n';
@@ -33,8 +34,8 @@ describe('issue_comment_form component', () => {
const findAddToReviewButton = () => wrapper.findByTestId('add-to-review-button');
const findAddCommentNowButton = () => wrapper.findByTestId('add-comment-now-button');
const findConfidentialNoteCheckbox = () => wrapper.findByTestId('confidential-note-checkbox');
- const findCommentGlDropdown = () => wrapper.findByTestId('comment-button');
- const findCommentButton = () => findCommentGlDropdown().find('button');
+ const findCommentTypeDropdown = () => wrapper.findComponent(CommentTypeDropdown);
+ const findCommentButton = () => findCommentTypeDropdown().find('button');
const findErrorAlerts = () => wrapper.findAllComponents(GlAlert).wrappers;
async function clickCommentButton({ waitForComponent = true, waitForNetwork = true } = {}) {
@@ -381,7 +382,7 @@ describe('issue_comment_form component', () => {
it('should render comment button as disabled', () => {
mountComponent();
- expect(findCommentGlDropdown().props('disabled')).toBe(true);
+ expect(findCommentTypeDropdown().props('disabled')).toBe(true);
});
it('should enable comment button if it has note', async () => {
@@ -389,7 +390,7 @@ describe('issue_comment_form component', () => {
await wrapper.setData({ note: 'Foo' });
- expect(findCommentGlDropdown().props('disabled')).toBe(false);
+ expect(findCommentTypeDropdown().props('disabled')).toBe(false);
});
it('should update buttons texts when it has note', () => {
@@ -624,7 +625,7 @@ describe('issue_comment_form component', () => {
it('when no drafts exist, should not render', () => {
mountComponent();
- expect(findCommentGlDropdown().exists()).toBe(true);
+ expect(findCommentTypeDropdown().exists()).toBe(true);
expect(findAddToReviewButton().exists()).toBe(false);
expect(findAddCommentNowButton().exists()).toBe(false);
});
@@ -637,7 +638,7 @@ describe('issue_comment_form component', () => {
it('should render', () => {
mountComponent();
- expect(findCommentGlDropdown().exists()).toBe(false);
+ expect(findCommentTypeDropdown().exists()).toBe(false);
expect(findAddToReviewButton().exists()).toBe(true);
expect(findAddCommentNowButton().exists()).toBe(true);
});
diff --git a/spec/frontend/notes/components/comment_type_dropdown_spec.js b/spec/frontend/notes/components/comment_type_dropdown_spec.js
new file mode 100644
index 00000000000..5e1cb813369
--- /dev/null
+++ b/spec/frontend/notes/components/comment_type_dropdown_spec.js
@@ -0,0 +1,64 @@
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue';
+import * as constants from '~/notes/constants';
+import { COMMENT_FORM } from '~/notes/i18n';
+
+describe('CommentTypeDropdown component', () => {
+ let wrapper;
+
+ const findCommentGlDropdown = () => wrapper.findComponent(GlDropdown);
+ const findCommentDropdownOption = () => wrapper.findAllComponents(GlDropdownItem).at(0);
+ const findDiscussionDropdownOption = () => wrapper.findAllComponents(GlDropdownItem).at(1);
+
+ const mountComponent = ({ props = {} } = {}) => {
+ wrapper = extendedWrapper(
+ mount(CommentTypeDropdown, {
+ propsData: {
+ noteableDisplayName: 'issue',
+ noteType: constants.COMMENT,
+ ...props,
+ },
+ }),
+ );
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('Should label action button "Comment" and correct dropdown item checked when selected', () => {
+ mountComponent({ props: { noteType: constants.COMMENT } });
+
+ expect(findCommentGlDropdown().props()).toMatchObject({ text: COMMENT_FORM.comment });
+ expect(findCommentDropdownOption().props()).toMatchObject({ isChecked: true });
+ expect(findDiscussionDropdownOption().props()).toMatchObject({ isChecked: false });
+ });
+
+ it('Should label action button "Start Thread" and correct dropdown item option checked when selected', () => {
+ mountComponent({ props: { noteType: constants.DISCUSSION } });
+
+ expect(findCommentGlDropdown().props()).toMatchObject({ text: COMMENT_FORM.startThread });
+ expect(findCommentDropdownOption().props()).toMatchObject({ isChecked: false });
+ expect(findDiscussionDropdownOption().props()).toMatchObject({ isChecked: true });
+ });
+
+ it('Should emit `change` event when clicking on an alternate dropdown option', () => {
+ mountComponent({ props: { noteType: constants.DISCUSSION } });
+
+ findCommentDropdownOption().vm.$emit('click');
+ findDiscussionDropdownOption().vm.$emit('click');
+
+ expect(wrapper.emitted('change')[0]).toEqual([constants.COMMENT]);
+ expect(wrapper.emitted('change').length).toEqual(1);
+ });
+
+ it('Should emit `click` event when clicking on the action button', () => {
+ mountComponent({ props: { noteType: constants.DISCUSSION } });
+
+ findCommentGlDropdown().vm.$emit('click');
+
+ expect(wrapper.emitted('click').length > 0).toBe(true);
+ });
+});
diff --git a/spec/frontend/notes/old_notes_spec.js b/spec/frontend/notes/deprecated_notes_spec.js
index 0cf43b8fd97..34623f8aa13 100644
--- a/spec/frontend/notes/old_notes_spec.js
+++ b/spec/frontend/notes/deprecated_notes_spec.js
@@ -14,9 +14,8 @@ import * as urlUtility from '~/lib/utils/url_utility';
window.jQuery = $;
require('autosize');
require('~/commons');
-require('~/notes');
+const Notes = require('~/deprecated_notes').default;
-const { Notes } = window;
const FLASH_TYPE_ALERT = 'alert';
const NOTES_POST_PATH = /(.*)\/notes\?html=true$/;
const fixture = 'snippets/show.html';
@@ -31,7 +30,7 @@ gl.utils.disableButtonIfEmptyField = () => {};
// the following test is unreliable and failing in main 2-3 times a day
// see https://gitlab.com/gitlab-org/gitlab/issues/206906#note_290602581
// eslint-disable-next-line jest/no-disabled-tests
-describe.skip('Old Notes (~/notes.js)', () => {
+describe.skip('Old Notes (~/deprecated_notes.js)', () => {
beforeEach(() => {
loadFixtures(fixture);
@@ -67,7 +66,7 @@ describe.skip('Old Notes (~/notes.js)', () => {
it('calls postComment when comment button is clicked', () => {
jest.spyOn(Notes.prototype, 'postComment');
- new window.Notes('', []);
+ new Notes('', []);
$('.js-comment-button').click();
expect(Notes.prototype.postComment).toHaveBeenCalled();
});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap
index 45d261625b4..451cf743e35 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/package_title_spec.js.snap
@@ -177,15 +177,6 @@ exports[`PackageTitle renders without tags 1`] = `
texttooltip=""
/>
</div>
- <div
- class="gl-display-flex gl-align-items-center gl-mr-5"
- >
- <package-tags-stub
- hidelabel="true"
- tagdisplaylimit="2"
- tags="[object Object],[object Object],[object Object]"
- />
- </div>
</div>
</div>
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js
index 0504a42dfcf..7a71a1cea0f 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/additional_metadata_spec.js
@@ -1,10 +1,11 @@
-import { GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import {
conanMetadata,
mavenMetadata,
nugetMetadata,
packageData,
+ composerMetadata,
+ pypiMetadata,
} from 'jest/packages_and_registries/package_registry/mock_data';
import component from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue';
import {
@@ -12,12 +13,15 @@ import {
PACKAGE_TYPE_CONAN,
PACKAGE_TYPE_MAVEN,
PACKAGE_TYPE_NPM,
+ PACKAGE_TYPE_COMPOSER,
+ PACKAGE_TYPE_PYPI,
} from '~/packages_and_registries/package_registry/constants';
-import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
const mavenPackage = { packageType: PACKAGE_TYPE_MAVEN, metadata: mavenMetadata() };
const conanPackage = { packageType: PACKAGE_TYPE_CONAN, metadata: conanMetadata() };
const nugetPackage = { packageType: PACKAGE_TYPE_NUGET, metadata: nugetMetadata() };
+const composerPackage = { packageType: PACKAGE_TYPE_COMPOSER, metadata: composerMetadata() };
+const pypiPackage = { packageType: PACKAGE_TYPE_PYPI, metadata: pypiMetadata() };
const npmPackage = { packageType: PACKAGE_TYPE_NPM, metadata: {} };
describe('Package Additional Metadata', () => {
@@ -32,8 +36,7 @@ describe('Package Additional Metadata', () => {
wrapper = shallowMountExtended(component, {
propsData: { ...defaultProps, ...props },
stubs: {
- DetailsRow,
- GlSprintf,
+ component: { template: '<div data-testid="component-is"></div>' },
},
});
};
@@ -45,12 +48,7 @@ describe('Package Additional Metadata', () => {
const findTitle = () => wrapper.findByTestId('title');
const findMainArea = () => wrapper.findByTestId('main');
- const findNugetSource = () => wrapper.findByTestId('nuget-source');
- const findNugetLicense = () => wrapper.findByTestId('nuget-license');
- const findConanRecipe = () => wrapper.findByTestId('conan-recipe');
- const findMavenApp = () => wrapper.findByTestId('maven-app');
- const findMavenGroup = () => wrapper.findByTestId('maven-group');
- const findElementLink = (container) => container.findComponent(GlLink);
+ const findComponentIs = () => wrapper.findByTestId('component-is');
it('has the correct title', () => {
mountComponent();
@@ -62,11 +60,13 @@ describe('Package Additional Metadata', () => {
});
it.each`
- packageEntity | visible | packageType
- ${mavenPackage} | ${true} | ${PACKAGE_TYPE_MAVEN}
- ${conanPackage} | ${true} | ${PACKAGE_TYPE_CONAN}
- ${nugetPackage} | ${true} | ${PACKAGE_TYPE_NUGET}
- ${npmPackage} | ${false} | ${PACKAGE_TYPE_NPM}
+ packageEntity | visible | packageType
+ ${mavenPackage} | ${true} | ${PACKAGE_TYPE_MAVEN}
+ ${conanPackage} | ${true} | ${PACKAGE_TYPE_CONAN}
+ ${nugetPackage} | ${true} | ${PACKAGE_TYPE_NUGET}
+ ${composerPackage} | ${true} | ${PACKAGE_TYPE_COMPOSER}
+ ${pypiPackage} | ${true} | ${PACKAGE_TYPE_PYPI}
+ ${npmPackage} | ${false} | ${PACKAGE_TYPE_NPM}
`(
`It is $visible that the component is visible when the package is $packageType`,
({ packageEntity, visible }) => {
@@ -74,57 +74,11 @@ describe('Package Additional Metadata', () => {
expect(findTitle().exists()).toBe(visible);
expect(findMainArea().exists()).toBe(visible);
+ expect(findComponentIs().exists()).toBe(visible);
+
+ if (visible) {
+ expect(findComponentIs().props('packageEntity')).toEqual(packageEntity);
+ }
},
);
-
- describe('nuget metadata', () => {
- beforeEach(() => {
- mountComponent({ packageEntity: nugetPackage });
- });
-
- it.each`
- name | finderFunction | text | link | icon
- ${'source'} | ${findNugetSource} | ${'Source project located at projectUrl'} | ${'projectUrl'} | ${'project'}
- ${'license'} | ${findNugetLicense} | ${'License information located at licenseUrl'} | ${'licenseUrl'} | ${'license'}
- `('$name element', ({ finderFunction, text, link, icon }) => {
- const element = finderFunction();
- expect(element.exists()).toBe(true);
- expect(element.text()).toBe(text);
- expect(element.props('icon')).toBe(icon);
- expect(findElementLink(element).attributes('href')).toBe(nugetPackage.metadata[link]);
- });
- });
-
- describe('conan metadata', () => {
- beforeEach(() => {
- mountComponent({ packageEntity: conanPackage });
- });
-
- it.each`
- name | finderFunction | text | icon
- ${'recipe'} | ${findConanRecipe} | ${'Recipe: package-8/1.0.0@gitlab-org+gitlab-test/stable'} | ${'information-o'}
- `('$name element', ({ finderFunction, text, icon }) => {
- const element = finderFunction();
- expect(element.exists()).toBe(true);
- expect(element.text()).toBe(text);
- expect(element.props('icon')).toBe(icon);
- });
- });
-
- describe('maven metadata', () => {
- beforeEach(() => {
- mountComponent();
- });
-
- it.each`
- name | finderFunction | text | icon
- ${'app'} | ${findMavenApp} | ${'App name: appName'} | ${'information-o'}
- ${'group'} | ${findMavenGroup} | ${'App group: appGroup'} | ${'information-o'}
- `('$name element', ({ finderFunction, text, icon }) => {
- const element = finderFunction();
- expect(element.exists()).toBe(true);
- expect(element.text()).toBe(text);
- expect(element.props('icon')).toBe(icon);
- });
- });
});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/composer_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/composer_spec.js
new file mode 100644
index 00000000000..e744680cb9a
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/composer_spec.js
@@ -0,0 +1,58 @@
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import {
+ packageData,
+ composerMetadata,
+} from 'jest/packages_and_registries/package_registry/mock_data';
+import component from '~/packages_and_registries/package_registry/components/details/metadata/composer.vue';
+import { PACKAGE_TYPE_COMPOSER } from '~/packages_and_registries/package_registry/constants';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
+
+const composerPackage = { packageType: PACKAGE_TYPE_COMPOSER, metadata: composerMetadata() };
+
+describe('Composer Metadata', () => {
+ let wrapper;
+
+ const mountComponent = () => {
+ wrapper = shallowMountExtended(component, {
+ propsData: { packageEntity: packageData(composerPackage) },
+ stubs: {
+ DetailsRow,
+ GlSprintf,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findComposerTargetSha = () => wrapper.findByTestId('composer-target-sha');
+ const findComposerTargetShaCopyButton = () => wrapper.findComponent(ClipboardButton);
+ const findComposerJson = () => wrapper.findByTestId('composer-json');
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it.each`
+ name | finderFunction | text | icon
+ ${'target-sha'} | ${findComposerTargetSha} | ${'Target SHA: b83d6e391c22777fca1ed3012fce84f633d7fed0'} | ${'information-o'}
+ ${'composer-json'} | ${findComposerJson} | ${'Composer.json with license: MIT and version: 1.0.0'} | ${'information-o'}
+ `('$name element', ({ finderFunction, text, icon }) => {
+ const element = finderFunction();
+ expect(element.exists()).toBe(true);
+ expect(element.text()).toBe(text);
+ expect(element.props('icon')).toBe(icon);
+ });
+
+ it('target-sha has a copy button', () => {
+ expect(findComposerTargetShaCopyButton().exists()).toBe(true);
+ expect(findComposerTargetShaCopyButton().props()).toMatchObject({
+ text: 'b83d6e391c22777fca1ed3012fce84f633d7fed0',
+ title: 'Copy target SHA',
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/conan_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/conan_spec.js
new file mode 100644
index 00000000000..46593047f1f
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/conan_spec.js
@@ -0,0 +1,48 @@
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import {
+ conanMetadata,
+ packageData,
+} from 'jest/packages_and_registries/package_registry/mock_data';
+import component from '~/packages_and_registries/package_registry/components/details/metadata/conan.vue';
+import { PACKAGE_TYPE_CONAN } from '~/packages_and_registries/package_registry/constants';
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
+
+const conanPackage = { packageType: PACKAGE_TYPE_CONAN, metadata: conanMetadata() };
+
+describe('Conan Metadata', () => {
+ let wrapper;
+
+ const mountComponent = () => {
+ wrapper = shallowMountExtended(component, {
+ propsData: {
+ packageEntity: packageData(conanPackage),
+ },
+ stubs: {
+ DetailsRow,
+ GlSprintf,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findConanRecipe = () => wrapper.findByTestId('conan-recipe');
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it.each`
+ name | finderFunction | text | icon
+ ${'recipe'} | ${findConanRecipe} | ${'Recipe: package-8/1.0.0@gitlab-org+gitlab-test/stable'} | ${'information-o'}
+ `('$name element', ({ finderFunction, text, icon }) => {
+ const element = finderFunction();
+ expect(element.exists()).toBe(true);
+ expect(element.text()).toBe(text);
+ expect(element.props('icon')).toBe(icon);
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/maven_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/maven_spec.js
new file mode 100644
index 00000000000..bc54cf1cb98
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/maven_spec.js
@@ -0,0 +1,52 @@
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import {
+ mavenMetadata,
+ packageData,
+} from 'jest/packages_and_registries/package_registry/mock_data';
+import component from '~/packages_and_registries/package_registry/components/details/metadata/maven.vue';
+import { PACKAGE_TYPE_MAVEN } from '~/packages_and_registries/package_registry/constants';
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
+
+const mavenPackage = { packageType: PACKAGE_TYPE_MAVEN, metadata: mavenMetadata() };
+
+describe('Maven Metadata', () => {
+ let wrapper;
+
+ const mountComponent = () => {
+ wrapper = shallowMountExtended(component, {
+ propsData: {
+ packageEntity: {
+ ...packageData(mavenPackage),
+ },
+ },
+ stubs: {
+ DetailsRow,
+ GlSprintf,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findMavenApp = () => wrapper.findByTestId('maven-app');
+ const findMavenGroup = () => wrapper.findByTestId('maven-group');
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it.each`
+ name | finderFunction | text | icon
+ ${'app'} | ${findMavenApp} | ${'App name: appName'} | ${'information-o'}
+ ${'group'} | ${findMavenGroup} | ${'App group: appGroup'} | ${'information-o'}
+ `('$name element', ({ finderFunction, text, icon }) => {
+ const element = finderFunction();
+ expect(element.exists()).toBe(true);
+ expect(element.text()).toBe(text);
+ expect(element.props('icon')).toBe(icon);
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js
new file mode 100644
index 00000000000..279900edff2
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js
@@ -0,0 +1,55 @@
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import {
+ nugetMetadata,
+ packageData,
+} from 'jest/packages_and_registries/package_registry/mock_data';
+import component from '~/packages_and_registries/package_registry/components/details/metadata/nuget.vue';
+import { PACKAGE_TYPE_NUGET } from '~/packages_and_registries/package_registry/constants';
+
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
+
+const nugetPackage = { packageType: PACKAGE_TYPE_NUGET, metadata: nugetMetadata() };
+
+describe('Nuget Metadata', () => {
+ let wrapper;
+
+ const mountComponent = () => {
+ wrapper = shallowMountExtended(component, {
+ propsData: {
+ packageEntity: {
+ ...packageData(nugetPackage),
+ },
+ },
+ stubs: {
+ DetailsRow,
+ GlSprintf,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findNugetSource = () => wrapper.findByTestId('nuget-source');
+ const findNugetLicense = () => wrapper.findByTestId('nuget-license');
+ const findElementLink = (container) => container.findComponent(GlLink);
+
+ beforeEach(() => {
+ mountComponent({ packageEntity: nugetPackage });
+ });
+
+ it.each`
+ name | finderFunction | text | link | icon
+ ${'source'} | ${findNugetSource} | ${'Source project located at projectUrl'} | ${'projectUrl'} | ${'project'}
+ ${'license'} | ${findNugetLicense} | ${'License information located at licenseUrl'} | ${'licenseUrl'} | ${'license'}
+ `('$name element', ({ finderFunction, text, link, icon }) => {
+ const element = finderFunction();
+ expect(element.exists()).toBe(true);
+ expect(element.text()).toBe(text);
+ expect(element.props('icon')).toBe(icon);
+ expect(findElementLink(element).attributes('href')).toBe(nugetPackage.metadata[link]);
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js
new file mode 100644
index 00000000000..c4481c3f20b
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/pypi_spec.js
@@ -0,0 +1,48 @@
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { packageData, pypiMetadata } from 'jest/packages_and_registries/package_registry/mock_data';
+import component from '~/packages_and_registries/package_registry/components/details/metadata/pypi.vue';
+import { PACKAGE_TYPE_PYPI } from '~/packages_and_registries/package_registry/constants';
+
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
+
+const pypiPackage = { packageType: PACKAGE_TYPE_PYPI, metadata: pypiMetadata() };
+
+describe('Package Additional Metadata', () => {
+ let wrapper;
+
+ const mountComponent = () => {
+ wrapper = shallowMountExtended(component, {
+ propsData: {
+ packageEntity: {
+ ...packageData(pypiPackage),
+ },
+ },
+ stubs: {
+ DetailsRow,
+ GlSprintf,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findPypiRequiredPython = () => wrapper.findByTestId('pypi-required-python');
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it.each`
+ name | finderFunction | text | icon
+ ${'pypi-required-python'} | ${findPypiRequiredPython} | ${'Required Python: 1.0.0'} | ${'information-o'}
+ `('$name element', ({ finderFunction, text, icon }) => {
+ const element = finderFunction();
+ expect(element.exists()).toBe(true);
+ expect(element.text()).toBe(text);
+ expect(element.props('icon')).toBe(icon);
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js
index 327f6d81905..d59c3184e4e 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js
@@ -1,5 +1,6 @@
import { GlIcon, GlSprintf } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import PackageTags from '~/packages/shared/components/package_tags.vue';
import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue';
@@ -30,6 +31,9 @@ describe('PackageTitle', () => {
TitleArea,
GlSprintf,
},
+ directives: {
+ GlResizeObserver: createMockDirective(),
+ },
});
return wrapper.vm.$nextTick();
}
@@ -51,7 +55,7 @@ describe('PackageTitle', () => {
describe('renders', () => {
it('without tags', async () => {
- await createComponent();
+ await createComponent({ ...packageData(), packageFiles: { nodes: packageFiles() } });
expect(wrapper.element).toMatchSnapshot();
});
@@ -64,12 +68,26 @@ describe('PackageTitle', () => {
it('with tags on mobile', async () => {
jest.spyOn(GlBreakpointInstance, 'isDesktop').mockReturnValue(false);
+
await createComponent();
await wrapper.vm.$nextTick();
expect(findPackageBadges()).toHaveLength(packageTags().length);
});
+
+ it('when the page is resized', async () => {
+ await createComponent();
+
+ expect(findPackageBadges()).toHaveLength(0);
+
+ jest.spyOn(GlBreakpointInstance, 'isDesktop').mockReturnValue(false);
+ const { value } = getBinding(wrapper.element, 'gl-resize-observer');
+ value();
+
+ await wrapper.vm.$nextTick();
+ expect(findPackageBadges()).toHaveLength(packageTags().length);
+ });
});
describe('package title', () => {
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/packages_list_app_spec.js.snap
new file mode 100644
index 00000000000..dbebdeeb452
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/packages_list_app_spec.js.snap
@@ -0,0 +1,68 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`packages_list_app renders 1`] = `
+<div>
+ <div
+ help-url="foo"
+ />
+
+ <div />
+
+ <div>
+ <section
+ class="row empty-state text-center"
+ >
+ <div
+ class="col-12"
+ >
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt=""
+ class="gl-max-w-full"
+ role="img"
+ src="helpSvg"
+ />
+ </div>
+ </div>
+
+ <div
+ class="col-12"
+ >
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ >
+ <h1
+ class="h4"
+ >
+ There are no packages yet
+ </h1>
+
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
+
+ <div
+ class="gl-display-flex gl-flex-wrap gl-justify-content-center"
+ >
+ <!---->
+
+ <!---->
+ </div>
+ </div>
+ </div>
+ </section>
+ </div>
+</div>
+`;
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_app_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_app_spec.js
new file mode 100644
index 00000000000..6c871a34d50
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_app_spec.js
@@ -0,0 +1,273 @@
+import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import createFlash from '~/flash';
+import * as commonUtils from '~/lib/utils/common_utils';
+import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
+import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
+import PackageListApp from '~/packages_and_registries/package_registry/components/list/packages_list_app.vue';
+import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
+import * as packageUtils from '~/packages_and_registries/shared/utils';
+
+jest.mock('~/lib/utils/common_utils');
+jest.mock('~/flash');
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('packages_list_app', () => {
+ let wrapper;
+ let store;
+
+ const PackageList = {
+ name: 'package-list',
+ template: '<div><slot name="empty-state"></slot></div>',
+ };
+ const GlLoadingIcon = { name: 'gl-loading-icon', template: '<div>loading</div>' };
+
+ // we need to manually stub dynamic imported components because shallowMount is not able to stub them automatically. See: https://github.com/vuejs/vue-test-utils/issues/1279
+ const PackageSearch = { name: 'PackageSearch', template: '<div></div>' };
+ const PackageTitle = { name: 'PackageTitle', template: '<div></div>' };
+ const InfrastructureTitle = { name: 'InfrastructureTitle', template: '<div></div>' };
+ const InfrastructureSearch = { name: 'InfrastructureSearch', template: '<div></div>' };
+
+ const emptyListHelpUrl = 'helpUrl';
+ const findEmptyState = () => wrapper.find(GlEmptyState);
+ const findListComponent = () => wrapper.find(PackageList);
+ const findPackageSearch = () => wrapper.find(PackageSearch);
+ const findPackageTitle = () => wrapper.find(PackageTitle);
+ const findInfrastructureTitle = () => wrapper.find(InfrastructureTitle);
+ const findInfrastructureSearch = () => wrapper.find(InfrastructureSearch);
+
+ const createStore = (filter = []) => {
+ store = new Vuex.Store({
+ state: {
+ isLoading: false,
+ config: {
+ resourceId: 'project_id',
+ emptyListIllustration: 'helpSvg',
+ emptyListHelpUrl,
+ packageHelpUrl: 'foo',
+ },
+ filter,
+ },
+ });
+ store.dispatch = jest.fn();
+ };
+
+ const mountComponent = (provide) => {
+ wrapper = shallowMount(PackageListApp, {
+ localVue,
+ store,
+ stubs: {
+ GlEmptyState,
+ GlLoadingIcon,
+ PackageList,
+ GlSprintf,
+ GlLink,
+ PackageSearch,
+ PackageTitle,
+ InfrastructureTitle,
+ InfrastructureSearch,
+ },
+ provide,
+ });
+ };
+
+ beforeEach(() => {
+ createStore();
+ jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue({});
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders', () => {
+ mountComponent();
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('call requestPackagesList on page:changed', () => {
+ mountComponent();
+ store.dispatch.mockClear();
+
+ const list = findListComponent();
+ list.vm.$emit('page:changed', 1);
+ expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList', { page: 1 });
+ });
+
+ it('call requestDeletePackage on package:delete', () => {
+ mountComponent();
+
+ const list = findListComponent();
+ list.vm.$emit('package:delete', 'foo');
+ expect(store.dispatch).toHaveBeenCalledWith('requestDeletePackage', 'foo');
+ });
+
+ it('does call requestPackagesList only one time on render', () => {
+ mountComponent();
+
+ expect(store.dispatch).toHaveBeenCalledTimes(3);
+ expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', expect.any(Object));
+ expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', expect.any(Array));
+ expect(store.dispatch).toHaveBeenNthCalledWith(3, 'requestPackagesList');
+ });
+
+ describe('url query string handling', () => {
+ const defaultQueryParamsMock = {
+ search: [1, 2],
+ type: 'npm',
+ sort: 'asc',
+ orderBy: 'created',
+ };
+
+ it('calls setSorting with the query string based sorting', () => {
+ jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue(defaultQueryParamsMock);
+
+ mountComponent();
+
+ expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', {
+ orderBy: defaultQueryParamsMock.orderBy,
+ sort: defaultQueryParamsMock.sort,
+ });
+ });
+
+ it('calls setFilter with the query string based filters', () => {
+ jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue(defaultQueryParamsMock);
+
+ mountComponent();
+
+ expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', [
+ { type: 'type', value: { data: defaultQueryParamsMock.type } },
+ { type: FILTERED_SEARCH_TERM, value: { data: defaultQueryParamsMock.search[0] } },
+ { type: FILTERED_SEARCH_TERM, value: { data: defaultQueryParamsMock.search[1] } },
+ ]);
+ });
+
+ it('calls setSorting and setFilters with the results of extractFilterAndSorting', () => {
+ jest
+ .spyOn(packageUtils, 'extractFilterAndSorting')
+ .mockReturnValue({ filters: ['foo'], sorting: { sort: 'desc' } });
+
+ mountComponent();
+
+ expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', { sort: 'desc' });
+ expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', ['foo']);
+ });
+ });
+
+ describe('empty state', () => {
+ it('generate the correct empty list link', () => {
+ mountComponent();
+
+ const link = findListComponent().find(GlLink);
+
+ expect(link.attributes('href')).toBe(emptyListHelpUrl);
+ expect(link.text()).toBe('publish and share your packages');
+ });
+
+ it('includes the right content on the default tab', () => {
+ mountComponent();
+
+ const heading = findEmptyState().find('h1');
+
+ expect(heading.text()).toBe('There are no packages yet');
+ });
+ });
+
+ describe('filter without results', () => {
+ beforeEach(() => {
+ createStore([{ type: 'something' }]);
+ mountComponent();
+ });
+
+ it('should show specific empty message', () => {
+ expect(findEmptyState().text()).toContain('Sorry, your filter produced no results');
+ expect(findEmptyState().text()).toContain(
+ 'To widen your search, change or remove the filters above',
+ );
+ });
+ });
+
+ describe('Package Search', () => {
+ it('exists', () => {
+ mountComponent();
+
+ expect(findPackageSearch().exists()).toBe(true);
+ });
+
+ it('on update fetches data from the store', () => {
+ mountComponent();
+ store.dispatch.mockClear();
+
+ findPackageSearch().vm.$emit('update');
+
+ expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList');
+ });
+ });
+
+ describe('Infrastructure config', () => {
+ it('defaults to package registry components', () => {
+ mountComponent();
+
+ expect(findPackageSearch().exists()).toBe(true);
+ expect(findPackageTitle().exists()).toBe(true);
+
+ expect(findInfrastructureTitle().exists()).toBe(false);
+ expect(findInfrastructureSearch().exists()).toBe(false);
+ });
+
+ it('mount different component based on the provided values', () => {
+ mountComponent({
+ titleComponent: 'InfrastructureTitle',
+ searchComponent: 'InfrastructureSearch',
+ });
+
+ expect(findPackageSearch().exists()).toBe(false);
+ expect(findPackageTitle().exists()).toBe(false);
+
+ expect(findInfrastructureTitle().exists()).toBe(true);
+ expect(findInfrastructureSearch().exists()).toBe(true);
+ });
+ });
+
+ describe('delete alert handling', () => {
+ const originalLocation = window.location.href;
+ const search = `?${SHOW_DELETE_SUCCESS_ALERT}=true`;
+
+ beforeEach(() => {
+ createStore();
+ jest.spyOn(commonUtils, 'historyReplaceState').mockImplementation(() => {});
+ setWindowLocation(search);
+ });
+
+ afterEach(() => {
+ setWindowLocation(originalLocation);
+ });
+
+ it(`creates a flash if the query string contains ${SHOW_DELETE_SUCCESS_ALERT}`, () => {
+ mountComponent();
+
+ expect(createFlash).toHaveBeenCalledWith({
+ message: DELETE_PACKAGE_SUCCESS_MESSAGE,
+ type: 'notice',
+ });
+ });
+
+ it('calls historyReplaceState with a clean url', () => {
+ mountComponent();
+
+ expect(commonUtils.historyReplaceState).toHaveBeenCalledWith(originalLocation);
+ });
+
+ it(`does nothing if the query string does not contain ${SHOW_DELETE_SUCCESS_ALERT}`, () => {
+ setWindowLocation('?');
+ mountComponent();
+
+ expect(createFlash).not.toHaveBeenCalled();
+ expect(commonUtils.historyReplaceState).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js
new file mode 100644
index 00000000000..b624e66482d
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js
@@ -0,0 +1,217 @@
+import { GlTable, GlPagination, GlModal } from '@gitlab/ui';
+import { mount, createLocalVue } from '@vue/test-utils';
+import { last } from 'lodash';
+import Vuex from 'vuex';
+import stubChildren from 'helpers/stub_children';
+import { packageList } from 'jest/packages/mock_data';
+import PackagesListRow from '~/packages/shared/components/package_list_row.vue';
+import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
+import { TrackingActions } from '~/packages/shared/constants';
+import * as SharedUtils from '~/packages/shared/utils';
+import PackagesList from '~/packages_and_registries/package_registry/components/list/packages_list.vue';
+import Tracking from '~/tracking';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('packages_list', () => {
+ let wrapper;
+ let store;
+
+ const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>bar</div>' };
+
+ const findPackagesListLoader = () => wrapper.find(PackagesListLoader);
+ const findPackageListPagination = () => wrapper.find(GlPagination);
+ const findPackageListDeleteModal = () => wrapper.find(GlModal);
+ const findEmptySlot = () => wrapper.find(EmptySlotStub);
+ const findPackagesListRow = () => wrapper.find(PackagesListRow);
+
+ const createStore = (isGroupPage, packages, isLoading) => {
+ const state = {
+ isLoading,
+ packages,
+ pagination: {
+ perPage: 1,
+ total: 1,
+ page: 1,
+ },
+ config: {
+ isGroupPage,
+ },
+ sorting: {
+ orderBy: 'version',
+ sort: 'desc',
+ },
+ };
+ store = new Vuex.Store({
+ state,
+ getters: {
+ getList: () => packages,
+ },
+ });
+ store.dispatch = jest.fn();
+ };
+
+ const mountComponent = ({
+ isGroupPage = false,
+ packages = packageList,
+ isLoading = false,
+ ...options
+ } = {}) => {
+ createStore(isGroupPage, packages, isLoading);
+
+ wrapper = mount(PackagesList, {
+ localVue,
+ store,
+ stubs: {
+ ...stubChildren(PackagesList),
+ GlTable,
+ GlModal,
+ },
+ ...options,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when is loading', () => {
+ beforeEach(() => {
+ mountComponent({
+ packages: [],
+ isLoading: true,
+ });
+ });
+
+ it('shows skeleton loader when loading', () => {
+ expect(findPackagesListLoader().exists()).toBe(true);
+ });
+ });
+
+ describe('when is not loading', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('does not show skeleton loader when not loading', () => {
+ expect(findPackagesListLoader().exists()).toBe(false);
+ });
+ });
+
+ describe('layout', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('contains a pagination component', () => {
+ const sorting = findPackageListPagination();
+ expect(sorting.exists()).toBe(true);
+ });
+
+ it('contains a modal component', () => {
+ const sorting = findPackageListDeleteModal();
+ expect(sorting.exists()).toBe(true);
+ });
+ });
+
+ describe('when the user can destroy the package', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('setItemToBeDeleted sets itemToBeDeleted and open the modal', () => {
+ const mockModalShow = jest.spyOn(wrapper.vm.$refs.packageListDeleteModal, 'show');
+ const item = last(wrapper.vm.list);
+
+ findPackagesListRow().vm.$emit('packageToDelete', item);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.itemToBeDeleted).toEqual(item);
+ expect(mockModalShow).toHaveBeenCalled();
+ });
+ });
+
+ it('deleteItemConfirmation resets itemToBeDeleted', () => {
+ wrapper.setData({ itemToBeDeleted: 1 });
+ wrapper.vm.deleteItemConfirmation();
+ expect(wrapper.vm.itemToBeDeleted).toEqual(null);
+ });
+
+ it('deleteItemConfirmation emit package:delete', () => {
+ const itemToBeDeleted = { id: 2 };
+ wrapper.setData({ itemToBeDeleted });
+ wrapper.vm.deleteItemConfirmation();
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.emitted('package:delete')[0]).toEqual([itemToBeDeleted]);
+ });
+ });
+
+ it('deleteItemCanceled resets itemToBeDeleted', () => {
+ wrapper.setData({ itemToBeDeleted: 1 });
+ wrapper.vm.deleteItemCanceled();
+ expect(wrapper.vm.itemToBeDeleted).toEqual(null);
+ });
+ });
+
+ describe('when the list is empty', () => {
+ beforeEach(() => {
+ mountComponent({
+ packages: [],
+ slots: {
+ 'empty-state': EmptySlotStub,
+ },
+ });
+ });
+
+ it('show the empty slot', () => {
+ const emptySlot = findEmptySlot();
+ expect(emptySlot.exists()).toBe(true);
+ });
+ });
+
+ describe('pagination component', () => {
+ let pagination;
+ let modelEvent;
+
+ beforeEach(() => {
+ mountComponent();
+ pagination = findPackageListPagination();
+ // retrieve the event used by v-model, a more sturdy approach than hardcoding it
+ modelEvent = pagination.vm.$options.model.event;
+ });
+
+ it('emits page:changed events when the page changes', () => {
+ pagination.vm.$emit(modelEvent, 2);
+ expect(wrapper.emitted('page:changed')).toEqual([[2]]);
+ });
+ });
+
+ describe('tracking', () => {
+ let eventSpy;
+ let utilSpy;
+ const category = 'foo';
+
+ beforeEach(() => {
+ mountComponent();
+ eventSpy = jest.spyOn(Tracking, 'event');
+ utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category);
+ wrapper.setData({ itemToBeDeleted: { package_type: 'conan' } });
+ });
+
+ it('tracking category calls packageTypeToTrackCategory', () => {
+ expect(wrapper.vm.tracking.category).toBe(category);
+ expect(utilSpy).toHaveBeenCalledWith('conan');
+ });
+
+ it('deleteItemConfirmation calls event', () => {
+ wrapper.vm.deleteItemConfirmation();
+ expect(eventSpy).toHaveBeenCalledWith(
+ category,
+ TrackingActions.DELETE_PACKAGE,
+ expect.any(Object),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js
new file mode 100644
index 00000000000..42bc9fa3a9e
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js
@@ -0,0 +1,128 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { sortableFields } from '~/packages/list/utils';
+import component from '~/packages_and_registries/package_registry/components/list/package_search.vue';
+import PackageTypeToken from '~/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue';
+import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
+import UrlSync from '~/vue_shared/components/url_sync.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('Package Search', () => {
+ let wrapper;
+ let store;
+
+ const findRegistrySearch = () => wrapper.findComponent(RegistrySearch);
+ const findUrlSync = () => wrapper.findComponent(UrlSync);
+
+ const createStore = (isGroupPage) => {
+ const state = {
+ config: {
+ isGroupPage,
+ },
+ sorting: {
+ orderBy: 'version',
+ sort: 'desc',
+ },
+ filter: [],
+ };
+ store = new Vuex.Store({
+ state,
+ });
+ store.dispatch = jest.fn();
+ };
+
+ const mountComponent = (isGroupPage = false) => {
+ createStore(isGroupPage);
+
+ wrapper = shallowMount(component, {
+ localVue,
+ store,
+ stubs: {
+ UrlSync,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('has a registry search component', () => {
+ mountComponent();
+
+ expect(findRegistrySearch().exists()).toBe(true);
+ expect(findRegistrySearch().props()).toMatchObject({
+ filter: store.state.filter,
+ sorting: store.state.sorting,
+ tokens: expect.arrayContaining([
+ expect.objectContaining({ token: PackageTypeToken, type: 'type', icon: 'package' }),
+ ]),
+ sortableFields: sortableFields(),
+ });
+ });
+
+ it.each`
+ isGroupPage | page
+ ${false} | ${'project'}
+ ${true} | ${'group'}
+ `('in a $page page binds the right props', ({ isGroupPage }) => {
+ mountComponent(isGroupPage);
+
+ expect(findRegistrySearch().props()).toMatchObject({
+ filter: store.state.filter,
+ sorting: store.state.sorting,
+ tokens: expect.arrayContaining([
+ expect.objectContaining({ token: PackageTypeToken, type: 'type', icon: 'package' }),
+ ]),
+ sortableFields: sortableFields(isGroupPage),
+ });
+ });
+
+ it('on sorting:changed emits update event and calls vuex setSorting', () => {
+ const payload = { sort: 'foo' };
+
+ mountComponent();
+
+ findRegistrySearch().vm.$emit('sorting:changed', payload);
+
+ expect(store.dispatch).toHaveBeenCalledWith('setSorting', payload);
+ expect(wrapper.emitted('update')).toEqual([[]]);
+ });
+
+ it('on filter:changed calls vuex setFilter', () => {
+ const payload = ['foo'];
+
+ mountComponent();
+
+ findRegistrySearch().vm.$emit('filter:changed', payload);
+
+ expect(store.dispatch).toHaveBeenCalledWith('setFilter', payload);
+ });
+
+ it('on filter:submit emits update event', () => {
+ mountComponent();
+
+ findRegistrySearch().vm.$emit('filter:submit');
+
+ expect(wrapper.emitted('update')).toEqual([[]]);
+ });
+
+ it('has a UrlSync component', () => {
+ mountComponent();
+
+ expect(findUrlSync().exists()).toBe(true);
+ });
+
+ it('on query:changed calls updateQuery from UrlSync', () => {
+ jest.spyOn(UrlSync.methods, 'updateQuery').mockImplementation(() => {});
+
+ mountComponent();
+
+ findRegistrySearch().vm.$emit('query:changed');
+
+ expect(UrlSync.methods.updateQuery).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js
new file mode 100644
index 00000000000..3fa96ce1d29
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_title_spec.js
@@ -0,0 +1,71 @@
+import { shallowMount } from '@vue/test-utils';
+import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '~/packages/list/constants';
+import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+
+describe('PackageTitle', () => {
+ let wrapper;
+ let store;
+
+ const findTitleArea = () => wrapper.find(TitleArea);
+ const findMetadataItem = () => wrapper.find(MetadataItem);
+
+ const mountComponent = (propsData = { helpUrl: 'foo' }) => {
+ wrapper = shallowMount(PackageTitle, {
+ store,
+ propsData,
+ stubs: {
+ TitleArea,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('title area', () => {
+ it('exists', () => {
+ mountComponent();
+
+ expect(findTitleArea().exists()).toBe(true);
+ });
+
+ it('has the correct props', () => {
+ mountComponent();
+
+ expect(findTitleArea().props()).toMatchObject({
+ title: LIST_TITLE_TEXT,
+ infoMessages: [{ text: LIST_INTRO_TEXT, link: 'foo' }],
+ });
+ });
+ });
+
+ describe.each`
+ count | exist | text
+ ${null} | ${false} | ${''}
+ ${undefined} | ${false} | ${''}
+ ${0} | ${true} | ${'0 Packages'}
+ ${1} | ${true} | ${'1 Package'}
+ ${2} | ${true} | ${'2 Packages'}
+ `('when count is $count metadata item', ({ count, exist, text }) => {
+ beforeEach(() => {
+ mountComponent({ count, helpUrl: 'foo' });
+ });
+
+ it(`is ${exist} that it exists`, () => {
+ expect(findMetadataItem().exists()).toBe(exist);
+ });
+
+ if (exist) {
+ it('has the correct props', () => {
+ expect(findMetadataItem().props()).toMatchObject({
+ icon: 'package',
+ text,
+ });
+ });
+ }
+ });
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js
new file mode 100644
index 00000000000..b0cbe34f0b9
--- /dev/null
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/tokens/package_type_token_spec.js
@@ -0,0 +1,48 @@
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import component from '~/packages/list/components/tokens/package_type_token.vue';
+import { PACKAGE_TYPES } from '~/packages/list/constants';
+
+describe('packages_filter', () => {
+ let wrapper;
+
+ const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
+ const findFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion);
+
+ const mountComponent = ({ attrs, listeners } = {}) => {
+ wrapper = shallowMount(component, {
+ attrs,
+ listeners,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('it binds all of his attrs to filtered search token', () => {
+ mountComponent({ attrs: { foo: 'bar' } });
+
+ expect(findFilteredSearchToken().attributes('foo')).toBe('bar');
+ });
+
+ it('it binds all of his events to filtered search token', () => {
+ const clickListener = jest.fn();
+ mountComponent({ listeners: { click: clickListener } });
+
+ findFilteredSearchToken().vm.$emit('click');
+
+ expect(clickListener).toHaveBeenCalled();
+ });
+
+ it.each(PACKAGE_TYPES.map((p, index) => [p, index]))(
+ 'displays a suggestion for %p',
+ (packageType, index) => {
+ mountComponent();
+ const item = findFilteredSearchSuggestions().at(index);
+ expect(item.text()).toBe(packageType.title);
+ expect(item.props('value')).toBe(packageType.type);
+ },
+ );
+});
diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js
index 98ff29ef728..9438a2d2d72 100644
--- a/spec/frontend/packages_and_registries/package_registry/mock_data.js
+++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js
@@ -133,7 +133,7 @@ export const composerMetadata = () => ({
},
});
-export const pypyMetadata = () => ({
+export const pypiMetadata = () => ({
requiredPython: '1.0.0',
});
@@ -157,7 +157,7 @@ export const packageDetailsQuery = (extendPackage) => ({
metadata: {
...conanMetadata(),
...composerMetadata(),
- ...pypyMetadata(),
+ ...pypiMetadata(),
...mavenMetadata(),
...nugetMetadata(),
},
diff --git a/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js b/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js
index 63c1260560b..f84800d8266 100644
--- a/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js
+++ b/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js
@@ -65,7 +65,7 @@ describe('CustomizeHomepageBanner', () => {
await wrapper.vm.$nextTick();
const button = wrapper.find(`[href='${wrapper.vm.preferencesBehaviorPath}']`);
- expect(button.attributes('data-track-event')).toEqual(preferencesTrackingEvent);
+ expect(button.attributes('data-track-action')).toEqual(preferencesTrackingEvent);
expect(button.attributes('data-track-label')).toEqual(provide.trackLabel);
});
diff --git a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap
index 4ba9120d196..417567c9f4c 100644
--- a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap
+++ b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap
@@ -11,8 +11,12 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`]
<gl-dropdown-stub
category="primary"
+ clearalltext="Clear all"
headertext=""
hideheaderborder="true"
+ highlighteditemstitle="Selected"
+ highlighteditemstitleclass="gl-px-5"
+ showhighlighteditemstitle="true"
size="medium"
text="rspec"
variant="default"
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
deleted file mode 100644
index 091edc7505c..00000000000
--- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_b_spec.js.snap
+++ /dev/null
@@ -1,604 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-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"
- >
- Learn GitLab
- </h1>
-
- <p
- class="gl-text-gray-700 gl-mb-0"
- >
- 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"
- >
- 22% completed
- </p>
-
- <div
- class="progress"
- max="9"
- value="2"
- >
- <div
- aria-valuemax="9"
- aria-valuemin="0"
- aria-valuenow="2"
- class="progress-bar"
- role="progressbar"
- style="width: 22.22222222222222%;"
- />
- </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"
- >
- <!---->
-
- <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"
- role="img"
- >
- <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
- alt="Invite your colleagues"
- 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"
- data-track-action="click_link"
- data-track-label="Invite your colleagues"
- data-track-property="Growth::Activation::Experiment::LearnGitLabB"
- 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"
- >
- <!---->
-
- <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"
- role="img"
- >
- <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
- alt="Create or import a repository"
- 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"
- data-track-action="click_link"
- data-track-label="Create or import a repository"
- data-track-property="Growth::Activation::Experiment::LearnGitLabB"
- 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
- alt="Set-up CI/CD"
- 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"
- data-track-action="click_link"
- data-track-label="Set-up CI/CD"
- data-track-property="Growth::Activation::Experiment::LearnGitLabB"
- 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
- alt="Try GitLab Ultimate for free"
- 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"
- data-track-action="click_link"
- data-track-label="Try GitLab Ultimate for free"
- data-track-property="Growth::Activation::Experiment::LearnGitLabB"
- 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
- alt="Add code owners"
- 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"
- data-track-action="click_link"
- data-track-label="Add code owners"
- data-track-property="Growth::Activation::Experiment::LearnGitLabB"
- 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
- alt="Enable require merge approvals"
- 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"
- data-track-action="click_link"
- data-track-label="Enable require merge approvals"
- data-track-property="Growth::Activation::Experiment::LearnGitLabB"
- 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
- alt="Create an issue"
- src="http://example.com/images/illustration.svg"
- />
-
- <h6>
- Create an issue
- </h6>
-
- <p
- class="gl-font-sm gl-text-gray-700"
- >
- Create/import issues (tickets) to collaborate on ideas and plan work.
- </p>
-
- <a
- class="gl-link"
- data-track-action="click_link"
- data-track-label="Create an issue"
- data-track-property="Growth::Activation::Experiment::LearnGitLabB"
- href="http://example.com/"
- rel="noopener noreferrer"
- target="_blank"
- >
- Create an issue
- </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
- alt="Submit a merge request (MR)"
- 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"
- data-track-action="click_link"
- data-track-label="Submit a merge request (MR)"
- data-track-property="Growth::Activation::Experiment::LearnGitLabB"
- 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
- alt="Run a Security scan using CI/CD"
- src="http://example.com/images/illustration.svg"
- />
-
- <h6>
- Run a Security scan using CI/CD
- </h6>
-
- <p
- class="gl-font-sm gl-text-gray-700"
- >
- Scan your code to uncover vulnerabilities before deploying.
- </p>
-
- <a
- class="gl-link"
- data-track-action="click_link"
- data-track-label="Run a Security scan using CI/CD"
- data-track-property="Growth::Activation::Experiment::LearnGitLabB"
- href="http://example.com/"
- rel="noopener noreferrer"
- target="_blank"
- >
- Run a Security scan using CI/CD
- </a>
- </div>
- </div>
-
- <!---->
- </div>
- </div>
- </div>
-</div>
-`;
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_spec.js.snap
index 59b42de2485..3aa0e99a858 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_spec.js.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Learn GitLab Design A renders correctly 1`] = `
+exports[`Learn GitLab renders correctly 1`] = `
<div>
<div
class="row"
@@ -136,7 +136,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = `
class="gl-link"
data-track-action="click_link"
data-track-label="Set up CI/CD"
- data-track-property="Growth::Conversion::Experiment::LearnGitLabA"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLab"
href="http://example.com/"
rel="noopener noreferrer"
target="_blank"
@@ -157,7 +157,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = `
class="gl-link"
data-track-action="click_link"
data-track-label="Start a free Ultimate trial"
- data-track-property="Growth::Conversion::Experiment::LearnGitLabA"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLab"
href="http://example.com/"
rel="noopener noreferrer"
target="_blank"
@@ -178,7 +178,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = `
class="gl-link"
data-track-action="click_link"
data-track-label="Add code owners"
- data-track-property="Growth::Conversion::Experiment::LearnGitLabA"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLab"
href="http://example.com/"
rel="noopener noreferrer"
target="_blank"
@@ -206,7 +206,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = `
class="gl-link"
data-track-action="click_link"
data-track-label="Add merge request approval"
- data-track-property="Growth::Conversion::Experiment::LearnGitLabA"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLab"
href="http://example.com/"
rel="noopener noreferrer"
target="_blank"
@@ -270,7 +270,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = `
class="gl-link"
data-track-action="click_link"
data-track-label="Create an issue"
- data-track-property="Growth::Conversion::Experiment::LearnGitLabA"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLab"
href="http://example.com/"
rel="noopener noreferrer"
target="_blank"
@@ -291,7 +291,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = `
class="gl-link"
data-track-action="click_link"
data-track-label="Submit a merge request"
- data-track-property="Growth::Conversion::Experiment::LearnGitLabA"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLab"
href="http://example.com/"
rel="noopener noreferrer"
target="_blank"
@@ -348,7 +348,7 @@ exports[`Learn GitLab Design A renders correctly 1`] = `
class="gl-link"
data-track-action="click_link"
data-track-label="Run a Security scan using CI/CD"
- data-track-property="Growth::Conversion::Experiment::LearnGitLabA"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLab"
href="http://example.com/"
rel="noopener noreferrer"
target="_blank"
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
deleted file mode 100644
index 207944bfa1f..00000000000
--- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_b_spec.js
+++ /dev/null
@@ -1,38 +0,0 @@
-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;
- });
-
- it('renders correctly', () => {
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('renders the progress percentage', () => {
- const text = wrapper.find('[data-testid="completion-percentage"]').text();
-
- expect(text).toBe('22% completed');
- });
-
- it('renders the progress bar with correct values', () => {
- const progressBar = wrapper.findComponent(GlProgressBar);
-
- expect(progressBar.attributes('value')).toBe('2');
- expect(progressBar.attributes('max')).toBe('9');
- });
-});
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_spec.js
index ac997c1f237..f8099d7e95a 100644
--- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_a_spec.js
+++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js
@@ -1,13 +1,13 @@
import { GlProgressBar } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
-import LearnGitlabA from '~/pages/projects/learn_gitlab/components/learn_gitlab_a.vue';
+import LearnGitlab from '~/pages/projects/learn_gitlab/components/learn_gitlab.vue';
import { testActions, testSections } from './mock_data';
-describe('Learn GitLab Design A', () => {
+describe('Learn GitLab', () => {
let wrapper;
const createWrapper = () => {
- wrapper = mount(LearnGitlabA, { propsData: { actions: testActions, sections: testSections } });
+ wrapper = mount(LearnGitlab, { propsData: { actions: testActions, sections: testSections } });
};
beforeEach(() => {
diff --git a/spec/frontend/pages/projects/new/components/new_project_url_select_spec.js b/spec/frontend/pages/projects/new/components/new_project_url_select_spec.js
new file mode 100644
index 00000000000..8a7f9229503
--- /dev/null
+++ b/spec/frontend/pages/projects/new/components/new_project_url_select_spec.js
@@ -0,0 +1,122 @@
+import { GlButton, GlDropdown, GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
+import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import NewProjectUrlSelect from '~/pages/projects/new/components/new_project_url_select.vue';
+import searchQuery from '~/pages/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql';
+
+describe('NewProjectUrlSelect component', () => {
+ let wrapper;
+
+ const data = {
+ currentUser: {
+ groups: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Group/26',
+ fullPath: 'flightjs',
+ },
+ {
+ id: 'gid://gitlab/Group/28',
+ fullPath: 'h5bp',
+ },
+ ],
+ },
+ namespace: {
+ id: 'gid://gitlab/Namespace/1',
+ fullPath: 'root',
+ },
+ },
+ };
+
+ const localVue = createLocalVue();
+ localVue.use(VueApollo);
+
+ const requestHandlers = [[searchQuery, jest.fn().mockResolvedValue({ data })]];
+ const apolloProvider = createMockApollo(requestHandlers);
+
+ const provide = {
+ namespaceFullPath: 'h5bp',
+ namespaceId: '28',
+ rootUrl: 'https://gitlab.com/',
+ trackLabel: 'blank_project',
+ };
+
+ const mountComponent = ({ mountFn = shallowMount } = {}) =>
+ mountFn(NewProjectUrlSelect, { localVue, apolloProvider, provide });
+
+ const findButtonLabel = () => wrapper.findComponent(GlButton);
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findHiddenInput = () => wrapper.find('input');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders the root url as a label', () => {
+ wrapper = mountComponent();
+
+ expect(findButtonLabel().text()).toBe(provide.rootUrl);
+ expect(findButtonLabel().props('label')).toBe(true);
+ });
+
+ it('renders a dropdown with the initial namespace full path as the text', () => {
+ wrapper = mountComponent();
+
+ expect(findDropdown().props('text')).toBe(provide.namespaceFullPath);
+ });
+
+ it('renders a dropdown with the initial namespace id in the hidden input', () => {
+ wrapper = mountComponent();
+
+ expect(findHiddenInput().attributes('value')).toBe(provide.namespaceId);
+ });
+
+ it('renders expected dropdown items', async () => {
+ wrapper = mountComponent({ mountFn: mount });
+
+ jest.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+
+ const listItems = wrapper.findAll('li');
+
+ expect(listItems.at(0).findComponent(GlDropdownSectionHeader).text()).toBe('Groups');
+ expect(listItems.at(1).text()).toBe(data.currentUser.groups.nodes[0].fullPath);
+ expect(listItems.at(2).text()).toBe(data.currentUser.groups.nodes[1].fullPath);
+ expect(listItems.at(3).findComponent(GlDropdownSectionHeader).text()).toBe('Users');
+ expect(listItems.at(4).text()).toBe(data.currentUser.namespace.fullPath);
+ });
+
+ it('updates hidden input with selected namespace', async () => {
+ wrapper = mountComponent();
+
+ jest.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+
+ wrapper.findComponent(GlDropdownItem).vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findHiddenInput().attributes()).toMatchObject({
+ name: 'project[namespace_id]',
+ value: getIdFromGraphQLId(data.currentUser.groups.nodes[0].id).toString(),
+ });
+ });
+
+ it('tracks clicking on the dropdown', () => {
+ wrapper = mountComponent();
+
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+
+ findDropdown().vm.$emit('show');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'activate_form_input', {
+ label: provide.trackLabel,
+ property: 'project_path',
+ });
+
+ unmockTracking();
+ });
+});
diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
index de0d70a07d7..f3d76ca2c1b 100644
--- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
+++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
@@ -42,11 +42,6 @@ describe('Interval Pattern Input Component', () => {
wrapper = mount(IntervalPatternInput, {
propsData: { ...props },
- provide: {
- glFeatures: {
- ciDailyLimitForPipelineSchedules: true,
- },
- },
data() {
return {
randomHour: data?.hour || mockHour,
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 6aa725fbd7d..601fcfedbe0 100644
--- a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js
+++ b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js
@@ -21,7 +21,7 @@ describe('SigninTabsMemoizer', () => {
beforeEach(() => {
loadFixtures(fixtureTemplate);
- jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(true);
+ jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true);
});
it('does nothing if no tab was previously selected', () => {
@@ -90,7 +90,7 @@ describe('SigninTabsMemoizer', () => {
});
it('should set .isLocalStorageAvailable', () => {
- expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled();
+ expect(AccessorUtilities.canUseLocalStorage).toHaveBeenCalled();
expect(memo.isLocalStorageAvailable).toBe(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 39081e07e52..2f934898ef1 100644
--- a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js
+++ b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js
@@ -1,5 +1,6 @@
import { GlFormTextarea, GlFormInput, GlLoadingIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
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';
@@ -48,7 +49,10 @@ describe('Pipeline Editor | Commit section', () => {
let wrapper;
let mockMutate;
- const defaultProps = { ciFileContent: mockCiYml };
+ const defaultProps = {
+ ciFileContent: mockCiYml,
+ commitSha: mockCommitSha,
+ };
const createComponent = ({ props = {}, options = {}, provide = {} } = {}) => {
mockMutate = jest.fn().mockResolvedValue({
@@ -67,7 +71,6 @@ describe('Pipeline Editor | Commit section', () => {
provide: { ...mockProvide, ...provide },
data() {
return {
- commitSha: mockCommitSha,
currentBranch: mockDefaultBranch,
isNewCiConfigFile: Boolean(options?.isNewCiConfigfile),
};
@@ -97,8 +100,7 @@ describe('Pipeline Editor | Commit section', () => {
await findCommitForm().find('[data-testid="new-mr-checkbox"]').setChecked(openMergeRequest);
}
await findCommitForm().find('[type="submit"]').trigger('click');
- // Simulate the write to local cache that occurs after a commit
- await wrapper.setData({ commitSha: mockCommitNextSha });
+ await waitForPromises();
};
const cancelCommitForm = async () => {
@@ -175,6 +177,10 @@ describe('Pipeline Editor | Commit section', () => {
expect(wrapper.emitted('commit')[0]).toEqual([{ type: COMMIT_SUCCESS }]);
});
+ it('emits an event to refetch the commit sha', () => {
+ expect(wrapper.emitted('updateCommitSha')).toHaveLength(1);
+ });
+
it('shows no saving state', () => {
expect(findCommitBtnLoadingIcon().exists()).toBe(false);
});
@@ -188,7 +194,6 @@ describe('Pipeline Editor | Commit section', () => {
update: expect.any(Function),
variables: {
...mockVariables,
- lastCommitId: mockCommitNextSha,
branch: mockDefaultBranch,
},
});
@@ -215,6 +220,10 @@ describe('Pipeline Editor | Commit section', () => {
},
});
});
+
+ it('does not emit an event to refetch the commit sha', () => {
+ expect(wrapper.emitted('updateCommitSha')).toBeUndefined();
+ });
});
describe('when the user commits changes to open a new merge request', () => {
diff --git a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js
index c6c7f593cc5..85222f2ecbb 100644
--- a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js
+++ b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js
@@ -42,15 +42,12 @@ describe('Pipeline Editor | Text editor component', () => {
defaultBranch: mockDefaultBranch,
glFeatures,
},
+ propsData: {
+ commitSha: mockCommitSha,
+ },
attrs: {
value: mockCiYml,
},
- // Simulate graphQL client query result
- data() {
- return {
- commitSha: mockCommitSha,
- };
- },
listeners: {
[EDITOR_READY_EVENT]: editorReadyListener,
},
diff --git a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js
index 85b51d08f88..b5881790b0b 100644
--- a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js
+++ b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js
@@ -247,15 +247,6 @@ describe('Pipeline editor branch switcher', () => {
expect(wrapper.emitted('refetchContent')).toBeUndefined();
});
-
- it('emits the updateCommitSha event when selecting a different branch', async () => {
- expect(wrapper.emitted('updateCommitSha')).toBeUndefined();
-
- const branch = findDropdownItems().at(1);
- branch.vm.$emit('click');
-
- expect(wrapper.emitted('updateCommitSha')).toHaveLength(1);
- });
});
describe('when searching', () => {
diff --git a/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js b/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js
index 94a0a7d14ee..e24de832d6d 100644
--- a/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js
+++ b/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js
@@ -4,16 +4,10 @@ import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipelin
describe('Pipeline editor file nav', () => {
let wrapper;
- const mockProvide = {
- glFeatures: {
- pipelineEditorBranchSwitcher: true,
- },
- };
const createComponent = ({ provide = {} } = {}) => {
wrapper = shallowMount(PipelineEditorFileNav, {
provide: {
- ...mockProvide,
...provide,
},
});
@@ -34,16 +28,4 @@ describe('Pipeline editor file nav', () => {
expect(findBranchSwitcher().exists()).toBe(true);
});
});
-
- describe('with branch switcher feature flag OFF', () => {
- it('does not render the branch switcher', () => {
- createComponent({
- provide: {
- glFeatures: { pipelineEditorBranchSwitcher: false },
- },
- });
-
- expect(findBranchSwitcher().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
index a95921359cc..753682d438b 100644
--- a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js
+++ b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js
@@ -27,13 +27,11 @@ describe('Pipeline Status', () => {
wrapper = shallowMount(PipelineStatus, {
localVue,
apolloProvider: mockApollo,
+ propsData: {
+ commitSha: mockCommitSha,
+ },
provide: mockProvide,
stubs: { GlLink, GlSprintf },
- data() {
- return {
- commitSha: mockCommitSha,
- };
- },
});
};
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
index 76c68e21180..b019bae886c 100644
--- 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
@@ -7,7 +7,6 @@ describe('Pipeline editor empty state', () => {
let wrapper;
const defaultProvide = {
glFeatures: {
- pipelineEditorBranchSwitcher: true,
pipelineEditorEmptyStateAction: false,
},
emptyStateIllustrationPath: 'my/svg/path',
@@ -82,17 +81,5 @@ describe('Pipeline editor empty state', () => {
await findConfirmButton().vm.$emit('click');
expect(wrapper.emitted(expectedEvent)).toHaveLength(1);
});
-
- describe('with branch switcher feature flag OFF', () => {
- it('does not render the file nav', () => {
- createComponent({
- provide: {
- glFeatures: { pipelineEditorBranchSwitcher: false },
- },
- });
-
- expect(findFileNav().exists()).toBe(false);
- });
- });
});
});
diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js
index 4d4a8c21d78..f2104f25324 100644
--- a/spec/frontend/pipeline_editor/mock_data.js
+++ b/spec/frontend/pipeline_editor/mock_data.js
@@ -156,30 +156,43 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => {
};
};
-export const mockNewCommitShaResults = {
+export const mockCommitShaResults = {
data: {
project: {
- pipelines: {
- nodes: [
- {
- id: 'gid://gitlab/Ci::Pipeline/1',
- sha: 'd0d56d363d8a3f67a8ab9fc00207d468f30032ca',
- path: `/${mockProjectFullPath}/-/pipelines/488`,
- commitPath: `/${mockProjectFullPath}/-/commit/d0d56d363d8a3f67a8ab9fc00207d468f30032ca`,
+ repository: {
+ tree: {
+ lastCommit: {
+ sha: mockCommitSha,
},
- {
- id: 'gid://gitlab/Ci::Pipeline/2',
- sha: 'fcab2ece40b26f428dfa3aa288b12c3c5bdb06aa',
- path: `/${mockProjectFullPath}/-/pipelines/487`,
- commitPath: `/${mockProjectFullPath}/-/commit/fcab2ece40b26f428dfa3aa288b12c3c5bdb06aa`,
+ },
+ },
+ },
+ },
+};
+
+export const mockNewCommitShaResults = {
+ data: {
+ project: {
+ repository: {
+ tree: {
+ lastCommit: {
+ sha: 'eeff1122',
},
- {
- id: 'gid://gitlab/Ci::Pipeline/3',
- sha: '6c16b17c7f94a438ae19a96c285bb49e3c632cf4',
- path: `/${mockProjectFullPath}/-/pipelines/433`,
- commitPath: `/${mockProjectFullPath}/-/commit/6c16b17c7f94a438ae19a96c285bb49e3c632cf4`,
+ },
+ },
+ },
+ },
+};
+
+export const mockEmptyCommitShaResults = {
+ data: {
+ project: {
+ repository: {
+ tree: {
+ lastCommit: {
+ sha: '',
},
- ],
+ },
},
},
},
diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js
index 0c5c08d7190..393cad0546b 100644
--- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js
+++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js
@@ -26,9 +26,11 @@ import {
mockBlobContentQueryResponseNoCiFile,
mockCiYml,
mockCommitSha,
+ mockCommitShaResults,
mockDefaultBranch,
- mockProjectFullPath,
+ mockEmptyCommitShaResults,
mockNewCommitShaResults,
+ mockProjectFullPath,
} from './mock_data';
const localVue = createLocalVue();
@@ -54,7 +56,6 @@ describe('Pipeline editor app component', () => {
let mockBlobContentData;
let mockCiConfigData;
let mockGetTemplate;
- let mockUpdateCommitSha;
let mockLatestCommitShaQuery;
let mockPipelineQuery;
@@ -71,6 +72,11 @@ describe('Pipeline editor app component', () => {
SourceEditor: MockSourceEditor,
PipelineEditorEmptyState,
},
+ data() {
+ return {
+ commitSha: '',
+ };
+ },
mocks: {
$apollo: {
queries: {
@@ -96,18 +102,7 @@ describe('Pipeline editor app component', () => {
[getPipelineQuery, mockPipelineQuery],
];
- const resolvers = {
- Query: {
- commitSha() {
- return mockCommitSha;
- },
- },
- Mutation: {
- updateCommitSha: mockUpdateCommitSha,
- },
- };
-
- mockApollo = createMockApollo(handlers, resolvers);
+ mockApollo = createMockApollo(handlers);
const options = {
localVue,
@@ -137,7 +132,6 @@ describe('Pipeline editor app component', () => {
mockBlobContentData = jest.fn();
mockCiConfigData = jest.fn();
mockGetTemplate = jest.fn();
- mockUpdateCommitSha = jest.fn();
mockLatestCommitShaQuery = jest.fn();
mockPipelineQuery = jest.fn();
});
@@ -159,11 +153,16 @@ describe('Pipeline editor app component', () => {
beforeEach(() => {
mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
mockCiConfigData.mockResolvedValue(mockCiConfigQueryResponse);
+ mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults);
});
describe('when file exists', () => {
beforeEach(async () => {
await createComponentWithApollo();
+
+ jest
+ .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling')
+ .mockImplementation(jest.fn());
});
it('shows pipeline editor home component', () => {
@@ -181,18 +180,32 @@ describe('Pipeline editor app component', () => {
sha: mockCommitSha,
});
});
+
+ it('does not poll for the commit sha', () => {
+ expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(0);
+ });
});
describe('when no CI config file exists', () => {
- it('shows an empty state and does not show editor home component', async () => {
+ beforeEach(async () => {
mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseNoCiFile);
await createComponentWithApollo();
+ jest
+ .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling')
+ .mockImplementation(jest.fn());
+ });
+
+ it('shows an empty state and does not show editor home component', async () => {
expect(findEmptyState().exists()).toBe(true);
expect(findAlert().exists()).toBe(false);
expect(findEditorHome().exists()).toBe(false);
});
+ it('does not poll for the commit sha', () => {
+ expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(0);
+ });
+
describe('because of a fetching error', () => {
it('shows a unkown error message', async () => {
const loadUnknownFailureText = 'The CI configuration was not loaded, please try again.';
@@ -230,6 +243,7 @@ describe('Pipeline editor app component', () => {
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.mockResolvedValue(mockBlobContentQueryResponseNoCiFile);
+ mockLatestCommitShaQuery.mockResolvedValue(mockEmptyCommitShaResults);
await createComponentWithApollo({
provide: {
@@ -254,9 +268,9 @@ describe('Pipeline editor app component', () => {
const updateSuccessMessage = 'Your changes have been successfully committed.';
describe('and the commit mutation succeeds', () => {
- beforeEach(() => {
+ beforeEach(async () => {
window.scrollTo = jest.fn();
- createComponent();
+ await createComponentWithApollo();
findEditorHome().vm.$emit('commit', { type: COMMIT_SUCCESS });
});
@@ -268,7 +282,43 @@ describe('Pipeline editor app component', () => {
it('scrolls to the top of the page to bring attention to the confirmation message', () => {
expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
});
+
+ it('polls for commit sha while pipeline data is not yet available for current branch', async () => {
+ jest
+ .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling')
+ .mockImplementation(jest.fn());
+
+ // simulate a commit to the current branch
+ findEditorHome().vm.$emit('updateCommitSha');
+ await waitForPromises();
+
+ expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(1);
+ });
+
+ it('stops polling for commit sha when pipeline data is available for newly committed branch', async () => {
+ jest
+ .spyOn(wrapper.vm.$apollo.queries.commitSha, 'stopPolling')
+ .mockImplementation(jest.fn());
+
+ mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults);
+ await wrapper.vm.$apollo.queries.commitSha.refetch();
+
+ expect(wrapper.vm.$apollo.queries.commitSha.stopPolling).toHaveBeenCalledTimes(1);
+ });
+
+ it('stops polling for commit sha when pipeline data is available for current branch', async () => {
+ jest
+ .spyOn(wrapper.vm.$apollo.queries.commitSha, 'stopPolling')
+ .mockImplementation(jest.fn());
+
+ mockLatestCommitShaQuery.mockResolvedValue(mockNewCommitShaResults);
+ findEditorHome().vm.$emit('updateCommitSha');
+ await waitForPromises();
+
+ expect(wrapper.vm.$apollo.queries.commitSha.stopPolling).toHaveBeenCalledTimes(1);
+ });
});
+
describe('and the commit mutation fails', () => {
const commitFailedReasons = ['Commit failed'];
@@ -320,6 +370,10 @@ describe('Pipeline editor app component', () => {
});
describe('when refetching content', () => {
+ beforeEach(() => {
+ mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults);
+ });
+
it('refetches blob content', async () => {
await createComponentWithApollo();
jest
@@ -352,6 +406,7 @@ describe('Pipeline editor app component', () => {
const originalLocation = window.location.href;
beforeEach(() => {
+ mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults);
setWindowLocation('?template=Android');
});
@@ -371,45 +426,4 @@ describe('Pipeline editor app component', () => {
expect(findTextEditor().exists()).toBe(true);
});
});
-
- describe('when updating commit sha', () => {
- const newCommitSha = mockNewCommitShaResults.data.project.pipelines.nodes[0].sha;
-
- beforeEach(async () => {
- mockUpdateCommitSha.mockResolvedValue(newCommitSha);
- mockLatestCommitShaQuery.mockResolvedValue(mockNewCommitShaResults);
- await createComponentWithApollo();
- });
-
- it('fetches updated commit sha for the new branch', async () => {
- expect(mockLatestCommitShaQuery).not.toHaveBeenCalled();
-
- wrapper
- .findComponent(PipelineEditorHome)
- .vm.$emit('updateCommitSha', { newBranch: 'new-branch' });
- await waitForPromises();
-
- expect(mockLatestCommitShaQuery).toHaveBeenCalledWith({
- projectPath: mockProjectFullPath,
- ref: 'new-branch',
- });
- });
-
- it('updates commit sha with the newly fetched commit sha', async () => {
- expect(mockUpdateCommitSha).not.toHaveBeenCalled();
-
- wrapper
- .findComponent(PipelineEditorHome)
- .vm.$emit('updateCommitSha', { newBranch: 'new-branch' });
- await waitForPromises();
-
- expect(mockUpdateCommitSha).toHaveBeenCalled();
- expect(mockUpdateCommitSha).toHaveBeenCalledWith(
- expect.any(Object),
- { commitSha: mockNewCommitShaResults.data.project.pipelines.nodes[0].sha },
- expect.any(Object),
- expect.any(Object),
- );
- });
- });
});
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 2a3f4f56f36..9e2bf1bd367 100644
--- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js
+++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js
@@ -45,6 +45,7 @@ describe('Pipeline New Form', () => {
const findWarningAlertSummary = () => findWarningAlert().find(GlSprintf);
const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]');
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+ const findCCAlert = () => wrapper.findComponent(CreditCardValidationRequiredAlert);
const getFormPostParams = () => JSON.parse(mock.history.post[0].data);
const selectBranch = (branch) => {
@@ -387,7 +388,7 @@ describe('Pipeline New Form', () => {
});
it('does not show the credit card validation required alert', () => {
- expect(wrapper.findComponent(CreditCardValidationRequiredAlert).exists()).toBe(false);
+ expect(findCCAlert().exists()).toBe(false);
});
describe('when the error response is credit card validation required', () => {
@@ -408,7 +409,19 @@ describe('Pipeline New Form', () => {
it('shows credit card validation required alert', () => {
expect(findErrorAlert().exists()).toBe(false);
- expect(wrapper.findComponent(CreditCardValidationRequiredAlert).exists()).toBe(true);
+ expect(findCCAlert().exists()).toBe(true);
+ });
+
+ it('clears error and hides the alert on dismiss', async () => {
+ expect(findCCAlert().exists()).toBe(true);
+ expect(wrapper.vm.$data.error).toBe(mockCreditCardValidationRequiredError.errors[0]);
+
+ findCCAlert().vm.$emit('dismiss');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findCCAlert().exists()).toBe(false);
+ expect(wrapper.vm.$data.error).toBe(null);
});
});
});
diff --git a/spec/frontend/pipelines/__snapshots__/parsing_utils_spec.js.snap b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap
index 60625d301c0..60625d301c0 100644
--- a/spec/frontend/pipelines/__snapshots__/parsing_utils_spec.js.snap
+++ b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap
diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
index e0ba6b2e8da..661c8d99477 100644
--- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
+++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
@@ -33,8 +33,6 @@ describe('Pipelines filtered search', () => {
};
beforeEach(() => {
- window.gon = { features: { pipelineSourceFilter: true } };
-
mock = new MockAdapter(axios);
jest.spyOn(Api, 'projectUsers').mockResolvedValue(users);
diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js
index 1fba3823161..4b2b61c8edd 100644
--- a/spec/frontend/pipelines/graph/graph_component_spec.js
+++ b/spec/frontend/pipelines/graph/graph_component_spec.js
@@ -1,5 +1,5 @@
import { mount, shallowMount } from '@vue/test-utils';
-import { GRAPHQL, LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants';
+import { LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants';
import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import JobItem from '~/pipelines/components/graph/job_item.vue';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
@@ -54,9 +54,6 @@ describe('graph component', () => {
...data,
};
},
- provide: {
- dataMethod: GRAPHQL,
- },
stubs: {
'links-inner': true,
'linked-pipeline': true,
diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js
index 4c7ea5edda9..cbc5d11403e 100644
--- a/spec/frontend/pipelines/graph/job_item_spec.js
+++ b/spec/frontend/pipelines/graph/job_item_spec.js
@@ -14,7 +14,29 @@ describe('pipeline graph job item', () => {
};
const triggerActiveClass = 'gl-shadow-x0-y0-b3-s1-blue-500';
- const delayedJobFixture = getJSONFixture('jobs/delayed.json');
+
+ const delayedJob = {
+ __typename: 'CiJob',
+ name: 'delayed job',
+ scheduledAt: '2015-07-03T10:01:00.000Z',
+ needs: [],
+ status: {
+ __typename: 'DetailedStatus',
+ icon: 'status_scheduled',
+ tooltip: 'delayed manual action (%{remainingTime})',
+ hasDetails: true,
+ detailsPath: '/root/kinder-pipe/-/jobs/5339',
+ group: 'scheduled',
+ action: {
+ __typename: 'StatusAction',
+ icon: 'time-out',
+ title: 'Unschedule',
+ path: '/frontend-fixtures/builds-project/-/jobs/142/unschedule',
+ buttonTitle: 'Unschedule job',
+ },
+ },
+ };
+
const mockJob = {
id: 4256,
name: 'test',
@@ -24,8 +46,8 @@ describe('pipeline graph job item', () => {
label: 'passed',
tooltip: 'passed',
group: 'success',
- details_path: '/root/ci-mock/builds/4256',
- has_details: true,
+ detailsPath: '/root/ci-mock/builds/4256',
+ hasDetails: true,
action: {
icon: 'retry',
title: 'Retry',
@@ -42,8 +64,8 @@ describe('pipeline graph job item', () => {
text: 'passed',
label: 'passed',
group: 'success',
- details_path: '/root/ci-mock/builds/4257',
- has_details: false,
+ detailsPath: '/root/ci-mock/builds/4257',
+ hasDetails: false,
},
};
@@ -58,7 +80,7 @@ describe('pipeline graph job item', () => {
wrapper.vm.$nextTick(() => {
const link = wrapper.find('a');
- expect(link.attributes('href')).toBe(mockJob.status.details_path);
+ expect(link.attributes('href')).toBe(mockJob.status.detailsPath);
expect(link.attributes('title')).toBe(`${mockJob.name} - ${mockJob.status.label}`);
@@ -145,7 +167,7 @@ describe('pipeline graph job item', () => {
describe('for delayed job', () => {
it('displays remaining time in tooltip', () => {
createWrapper({
- job: delayedJobFixture,
+ job: delayedJob,
});
expect(findJobWithLink().attributes('title')).toBe(
diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js
index c7d95526a0c..af5cd907dd8 100644
--- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js
+++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js
@@ -4,11 +4,7 @@ import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { UPSTREAM, DOWNSTREAM } from '~/pipelines/components/graph/constants';
import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
-import mockData from './linked_pipelines_mock_data';
-
-const mockPipeline = mockData.triggered[0];
-const validTriggeredPipelineId = mockPipeline.project.id;
-const invalidTriggeredPipelineId = mockPipeline.project.id + 5;
+import mockPipeline from './linked_pipelines_mock_data';
describe('Linked pipeline', () => {
let wrapper;
@@ -39,10 +35,10 @@ describe('Linked pipeline', () => {
describe('rendered output', () => {
const props = {
pipeline: mockPipeline,
- projectId: invalidTriggeredPipelineId,
columnTitle: 'Downstream',
type: DOWNSTREAM,
expanded: false,
+ isLoading: false,
};
beforeEach(() => {
@@ -60,7 +56,7 @@ describe('Linked pipeline', () => {
});
it('should render the pipeline status icon svg', () => {
- expect(wrapper.find('.ci-status-icon-failed svg').exists()).toBe(true);
+ expect(wrapper.find('.ci-status-icon-success svg').exists()).toBe(true);
});
it('should have a ci-status child component', () => {
@@ -73,8 +69,8 @@ describe('Linked pipeline', () => {
it('should correctly compute the tooltip text', () => {
expect(wrapper.vm.tooltipText).toContain(mockPipeline.project.name);
- expect(wrapper.vm.tooltipText).toContain(mockPipeline.details.status.label);
- expect(wrapper.vm.tooltipText).toContain(mockPipeline.source_job.name);
+ expect(wrapper.vm.tooltipText).toContain(mockPipeline.status.label);
+ expect(wrapper.vm.tooltipText).toContain(mockPipeline.sourceJob.name);
expect(wrapper.vm.tooltipText).toContain(mockPipeline.id);
});
@@ -82,11 +78,7 @@ describe('Linked pipeline', () => {
const titleAttr = findLinkedPipeline().attributes('title');
expect(titleAttr).toContain(mockPipeline.project.name);
- expect(titleAttr).toContain(mockPipeline.details.status.label);
- });
-
- it('sets the loading prop to false', () => {
- expect(findButton().props('loading')).toBe(false);
+ expect(titleAttr).toContain(mockPipeline.status.label);
});
it('should display multi-project label when pipeline project id is not the same as triggered pipeline project id', () => {
@@ -96,18 +88,20 @@ describe('Linked pipeline', () => {
describe('parent/child', () => {
const downstreamProps = {
- pipeline: mockPipeline,
- projectId: validTriggeredPipelineId,
+ pipeline: {
+ ...mockPipeline,
+ multiproject: false,
+ },
columnTitle: 'Downstream',
type: DOWNSTREAM,
expanded: false,
+ isLoading: false,
};
const upstreamProps = {
...downstreamProps,
columnTitle: 'Upstream',
type: UPSTREAM,
- expanded: false,
};
it('parent/child label container should exist', () => {
@@ -122,7 +116,7 @@ describe('Linked pipeline', () => {
it('should have the name of the trigger job on the card when it is a child pipeline', () => {
createWrapper(downstreamProps);
- expect(findDownstreamPipelineTitle().text()).toBe(mockPipeline.source_job.name);
+ expect(findDownstreamPipelineTitle().text()).toBe(mockPipeline.sourceJob.name);
});
it('should display parent label when pipeline project id is the same as triggered_by pipeline project id', () => {
@@ -132,12 +126,12 @@ describe('Linked pipeline', () => {
it('downstream pipeline should contain the correct link', () => {
createWrapper(downstreamProps);
- expect(findPipelineLink().attributes('href')).toBe(mockData.triggered_by.path);
+ expect(findPipelineLink().attributes('href')).toBe(downstreamProps.pipeline.path);
});
it('upstream pipeline should contain the correct link', () => {
createWrapper(upstreamProps);
- expect(findPipelineLink().attributes('href')).toBe(mockData.triggered_by.path);
+ expect(findPipelineLink().attributes('href')).toBe(upstreamProps.pipeline.path);
});
it.each`
@@ -183,11 +177,11 @@ describe('Linked pipeline', () => {
describe('when isLoading is true', () => {
const props = {
- pipeline: { ...mockPipeline, isLoading: true },
- projectId: invalidTriggeredPipelineId,
+ pipeline: mockPipeline,
columnTitle: 'Downstream',
type: DOWNSTREAM,
expanded: false,
+ isLoading: true,
};
beforeEach(() => {
@@ -202,10 +196,10 @@ describe('Linked pipeline', () => {
describe('on click/hover', () => {
const props = {
pipeline: mockPipeline,
- projectId: validTriggeredPipelineId,
columnTitle: 'Downstream',
type: DOWNSTREAM,
expanded: false,
+ isLoading: false,
};
beforeEach(() => {
@@ -228,7 +222,7 @@ describe('Linked pipeline', () => {
it('should emit downstreamHovered with job name on mouseover', () => {
findLinkedPipeline().trigger('mouseover');
- expect(wrapper.emitted().downstreamHovered).toStrictEqual([['trigger_job']]);
+ expect(wrapper.emitted().downstreamHovered).toStrictEqual([['test_c']]);
});
it('should emit downstreamHovered with empty string on mouseleave', () => {
@@ -238,7 +232,7 @@ describe('Linked pipeline', () => {
it('should emit pipelineExpanded with job name and expanded state on click', () => {
findExpandButton().trigger('click');
- expect(wrapper.emitted().pipelineExpandToggle).toStrictEqual([['trigger_job', true]]);
+ expect(wrapper.emitted().pipelineExpandToggle).toStrictEqual([['test_c', true]]);
});
});
});
diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js
index 24cc6e76098..2f03b846525 100644
--- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js
+++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js
@@ -4,7 +4,6 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import {
DOWNSTREAM,
- GRAPHQL,
UPSTREAM,
LAYER_VIEW,
STAGE_VIEW,
@@ -52,9 +51,6 @@ describe('Linked Pipelines Column', () => {
...defaultProps,
...props,
},
- provide: {
- dataMethod: GRAPHQL,
- },
});
};
diff --git a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js
index eb05669463b..955b70cbd3b 100644
--- a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js
+++ b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js
@@ -1,3800 +1,22 @@
export default {
- id: 23211253,
- user: {
- id: 3585,
- name: 'Achilleas Pipinellis',
- username: 'axil',
- state: 'active',
- avatar_url: 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png',
- web_url: 'https://gitlab.com/axil',
- status_tooltip_html:
- '\u003cspan class="user-status-emoji has-tooltip" title="I like pizza" data-html="true" data-placement="top"\u003e\u003cgl-emoji title="slice of pizza" data-name="pizza" data-unicode-version="6.0"\u003e🍕\u003c/gl-emoji\u003e\u003c/span\u003e',
- path: '/axil',
+ __typename: 'Pipeline',
+ id: 195,
+ iid: '5',
+ path: '/root/elemenohpee/-/pipelines/195',
+ status: {
+ __typename: 'DetailedStatus',
+ group: 'success',
+ label: 'passed',
+ icon: 'status_success',
},
- active: false,
- coverage: null,
- source: 'push',
- source_job: {
- name: 'trigger_job',
+ sourceJob: {
+ __typename: 'CiJob',
+ name: 'test_c',
},
- created_at: '2018-06-05T11:31:30.452Z',
- updated_at: '2018-10-31T16:35:31.305Z',
- path: '/gitlab-org/gitlab-runner/pipelines/23211253',
- flags: {
- latest: false,
- stuck: false,
- auto_devops: false,
- merge_request: false,
- yaml_errors: false,
- retryable: false,
- cancelable: false,
- failure_reason: false,
+ project: {
+ __typename: 'Project',
+ name: 'elemenohpee',
+ fullPath: 'root/elemenohpee',
},
- details: {
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/gitlab-org/gitlab-runner/pipelines/23211253',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- },
- duration: 53,
- finished_at: '2018-10-31T16:35:31.299Z',
- stages: [
- {
- name: 'prebuild',
- title: 'prebuild: passed',
- groups: [
- {
- name: 'review-docs-deploy',
- size: 1,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'manual play action',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/gitlab-org/gitlab-runner/-/jobs/72469032',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-org/gitlab-runner/-/jobs/72469032/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- jobs: [
- {
- id: 72469032,
- name: 'review-docs-deploy',
- started: '2018-10-31T16:34:58.778Z',
- archived: false,
- build_path: '/gitlab-org/gitlab-runner/-/jobs/72469032',
- retry_path: '/gitlab-org/gitlab-runner/-/jobs/72469032/retry',
- play_path: '/gitlab-org/gitlab-runner/-/jobs/72469032/play',
- playable: true,
- scheduled: false,
- created_at: '2018-06-05T11:31:30.495Z',
- updated_at: '2018-10-31T16:35:31.251Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'manual play action',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/gitlab-org/gitlab-runner/-/jobs/72469032',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-org/gitlab-runner/-/jobs/72469032/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- },
- ],
- },
- ],
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/gitlab-org/gitlab-runner/pipelines/23211253#prebuild',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- },
- path: '/gitlab-org/gitlab-runner/pipelines/23211253#prebuild',
- dropdown_path: '/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=prebuild',
- },
- {
- name: 'test',
- title: 'test: passed',
- groups: [
- {
- name: 'docs check links',
- size: 1,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/gitlab-org/gitlab-runner/-/jobs/72469033',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/gitlab-org/gitlab-runner/-/jobs/72469033/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- jobs: [
- {
- id: 72469033,
- name: 'docs check links',
- started: '2018-06-05T11:31:33.240Z',
- archived: false,
- build_path: '/gitlab-org/gitlab-runner/-/jobs/72469033',
- retry_path: '/gitlab-org/gitlab-runner/-/jobs/72469033/retry',
- playable: false,
- scheduled: false,
- created_at: '2018-06-05T11:31:30.627Z',
- updated_at: '2018-06-05T11:31:54.363Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/gitlab-org/gitlab-runner/-/jobs/72469033',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/gitlab-org/gitlab-runner/-/jobs/72469033/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- },
- ],
- },
- ],
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/gitlab-org/gitlab-runner/pipelines/23211253#test',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- },
- path: '/gitlab-org/gitlab-runner/pipelines/23211253#test',
- dropdown_path: '/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=test',
- },
- {
- name: 'cleanup',
- title: 'cleanup: skipped',
- groups: [
- {
- name: 'review-docs-cleanup',
- size: 1,
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual stop action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-org/gitlab-runner/-/jobs/72469034',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'stop',
- title: 'Stop',
- path: '/gitlab-org/gitlab-runner/-/jobs/72469034/play',
- method: 'post',
- button_title: 'Stop this environment',
- },
- },
- jobs: [
- {
- id: 72469034,
- name: 'review-docs-cleanup',
- started: null,
- archived: false,
- build_path: '/gitlab-org/gitlab-runner/-/jobs/72469034',
- play_path: '/gitlab-org/gitlab-runner/-/jobs/72469034/play',
- playable: true,
- scheduled: false,
- created_at: '2018-06-05T11:31:30.760Z',
- updated_at: '2018-06-05T11:31:56.037Z',
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual stop action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-org/gitlab-runner/-/jobs/72469034',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'stop',
- title: 'Stop',
- path: '/gitlab-org/gitlab-runner/-/jobs/72469034/play',
- method: 'post',
- button_title: 'Stop this environment',
- },
- },
- },
- ],
- },
- ],
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-org/gitlab-runner/pipelines/23211253#cleanup',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- path: '/gitlab-org/gitlab-runner/pipelines/23211253#cleanup',
- dropdown_path: '/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=cleanup',
- },
- ],
- artifacts: [],
- manual_actions: [
- {
- name: 'review-docs-cleanup',
- path: '/gitlab-org/gitlab-runner/-/jobs/72469034/play',
- playable: true,
- scheduled: false,
- },
- {
- name: 'review-docs-deploy',
- path: '/gitlab-org/gitlab-runner/-/jobs/72469032/play',
- playable: true,
- scheduled: false,
- },
- ],
- scheduled_actions: [],
- },
- ref: {
- name: 'docs/add-development-guide-to-readme',
- path: '/gitlab-org/gitlab-runner/commits/docs/add-development-guide-to-readme',
- tag: false,
- branch: true,
- merge_request: false,
- },
- commit: {
- id: '8083eb0a920572214d0dccedd7981f05d535ad46',
- short_id: '8083eb0a',
- title: 'Add link to development guide in readme',
- created_at: '2018-06-05T11:30:48.000Z',
- parent_ids: ['1d7cf79b5a1a2121b9474ac20d61c1b8f621289d'],
- message:
- 'Add link to development guide in readme\n\nCloses https://gitlab.com/gitlab-org/gitlab-runner/issues/3122\n',
- author_name: 'Achilleas Pipinellis',
- author_email: 'axil@gitlab.com',
- authored_date: '2018-06-05T11:30:48.000Z',
- committer_name: 'Achilleas Pipinellis',
- committer_email: 'axil@gitlab.com',
- committed_date: '2018-06-05T11:30:48.000Z',
- author: {
- id: 3585,
- name: 'Achilleas Pipinellis',
- username: 'axil',
- state: 'active',
- avatar_url: 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png',
- web_url: 'https://gitlab.com/axil',
- status_tooltip_html: null,
- path: '/axil',
- },
- author_gravatar_url:
- 'https://secure.gravatar.com/avatar/1d37af00eec153a8333a4ce18e9aea41?s=80\u0026d=identicon',
- commit_url:
- 'https://gitlab.com/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46',
- commit_path: '/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46',
- },
- project: { id: 20 },
- triggered_by: {
- id: 12,
- user: {
- id: 376774,
- name: 'Alessio Caiazza',
- username: 'nolith',
- state: 'active',
- avatar_url: 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png',
- web_url: 'https://gitlab.com/nolith',
- status_tooltip_html: null,
- path: '/nolith',
- },
- active: false,
- coverage: null,
- source: 'pipeline',
- source_job: {
- name: 'trigger_job',
- },
- path: '/gitlab-com/gitlab-docs/pipelines/34993051',
- details: {
- status: {
- icon: 'status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- tooltip: 'failed',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/pipelines/34993051',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- },
- duration: 118,
- finished_at: '2018-10-31T16:41:40.615Z',
- stages: [
- {
- name: 'build-images',
- title: 'build-images: skipped',
- groups: [
- {
- name: 'image:bootstrap',
- size: 1,
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- jobs: [
- {
- id: 11421321982853,
- name: 'image:bootstrap',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982853',
- play_path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play',
- playable: true,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.704Z',
- updated_at: '2018-10-31T16:35:24.118Z',
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- },
- ],
- },
- {
- name: 'image:builder-onbuild',
- size: 1,
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- jobs: [
- {
- id: 1149822131854,
- name: 'image:builder-onbuild',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982854',
- play_path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play',
- playable: true,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.728Z',
- updated_at: '2018-10-31T16:35:24.070Z',
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- },
- ],
- },
- {
- name: 'image:nginx-onbuild',
- size: 1,
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- jobs: [
- {
- id: 11498285523424,
- name: 'image:nginx-onbuild',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982855',
- play_path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play',
- playable: true,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.753Z',
- updated_at: '2018-10-31T16:35:24.033Z',
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- },
- ],
- },
- ],
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images',
- dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images',
- },
- {
- name: 'build',
- title: 'build: failed',
- groups: [
- {
- name: 'compile_dev',
- size: 1,
- status: {
- icon: 'status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- tooltip: 'failed - (script failure)',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/528/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- jobs: [
- {
- id: 1149846949786,
- name: 'compile_dev',
- started: '2018-10-31T16:39:41.598Z',
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114984694',
- retry_path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry',
- playable: false,
- scheduled: false,
- created_at: '2018-10-31T16:39:41.138Z',
- updated_at: '2018-10-31T16:41:40.072Z',
- status: {
- icon: 'status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- tooltip: 'failed - (script failure)',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/528/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- recoverable: false,
- },
- ],
- },
- ],
- status: {
- icon: 'status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- tooltip: 'failed',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- },
- path: '/gitlab-com/gitlab-docs/pipelines/34993051#build',
- dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build',
- },
- {
- name: 'deploy',
- title: 'deploy: skipped',
- groups: [
- {
- name: 'review',
- size: 1,
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- jobs: [
- {
- id: 11498282342357,
- name: 'review',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982857',
- playable: false,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.805Z',
- updated_at: '2018-10-31T16:41:40.569Z',
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- },
- ],
- },
- {
- name: 'review_stop',
- size: 1,
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- jobs: [
- {
- id: 114982858,
- name: 'review_stop',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982858',
- playable: false,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.840Z',
- updated_at: '2018-10-31T16:41:40.480Z',
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- },
- ],
- },
- ],
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy',
- dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy',
- },
- ],
- artifacts: [],
- manual_actions: [
- {
- name: 'image:bootstrap',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play',
- playable: true,
- scheduled: false,
- },
- {
- name: 'image:builder-onbuild',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play',
- playable: true,
- scheduled: false,
- },
- {
- name: 'image:nginx-onbuild',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play',
- playable: true,
- scheduled: false,
- },
- {
- name: 'review_stop',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982858/play',
- playable: false,
- scheduled: false,
- },
- ],
- scheduled_actions: [],
- },
- project: {
- id: 20,
- name: 'Test',
- full_path: '/gitlab-com/gitlab-docs',
- full_name: 'GitLab.com / GitLab Docs',
- },
- triggered_by: {
- id: 349932310342451,
- user: {
- id: 376774,
- name: 'Alessio Caiazza',
- username: 'nolith',
- state: 'active',
- avatar_url:
- 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png',
- web_url: 'https://gitlab.com/nolith',
- status_tooltip_html: null,
- path: '/nolith',
- },
- active: false,
- coverage: null,
- source: 'pipeline',
- source_job: {
- name: 'trigger_job',
- },
- path: '/gitlab-com/gitlab-docs/pipelines/34993051',
- details: {
- status: {
- icon: 'status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- tooltip: 'failed',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/pipelines/34993051',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- },
- duration: 118,
- finished_at: '2018-10-31T16:41:40.615Z',
- stages: [
- {
- name: 'build-images',
- title: 'build-images: skipped',
- groups: [
- {
- name: 'image:bootstrap',
- size: 1,
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- jobs: [
- {
- id: 11421321982853,
- name: 'image:bootstrap',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982853',
- play_path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play',
- playable: true,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.704Z',
- updated_at: '2018-10-31T16:35:24.118Z',
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- },
- ],
- },
- {
- name: 'image:builder-onbuild',
- size: 1,
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- jobs: [
- {
- id: 1149822131854,
- name: 'image:builder-onbuild',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982854',
- play_path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play',
- playable: true,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.728Z',
- updated_at: '2018-10-31T16:35:24.070Z',
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- },
- ],
- },
- {
- name: 'image:nginx-onbuild',
- size: 1,
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- jobs: [
- {
- id: 11498285523424,
- name: 'image:nginx-onbuild',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982855',
- play_path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play',
- playable: true,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.753Z',
- updated_at: '2018-10-31T16:35:24.033Z',
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- },
- ],
- },
- ],
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images',
- dropdown_path:
- '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images',
- },
- {
- name: 'build',
- title: 'build: failed',
- groups: [
- {
- name: 'compile_dev',
- size: 1,
- status: {
- icon: 'status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- tooltip: 'failed - (script failure)',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- jobs: [
- {
- id: 1149846949786,
- name: 'compile_dev',
- started: '2018-10-31T16:39:41.598Z',
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114984694',
- retry_path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry',
- playable: false,
- scheduled: false,
- created_at: '2018-10-31T16:39:41.138Z',
- updated_at: '2018-10-31T16:41:40.072Z',
- status: {
- icon: 'status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- tooltip: 'failed - (script failure)',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- recoverable: false,
- },
- ],
- },
- ],
- status: {
- icon: 'status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- tooltip: 'failed',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- },
- path: '/gitlab-com/gitlab-docs/pipelines/34993051#build',
- dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build',
- },
- {
- name: 'deploy',
- title: 'deploy: skipped',
- groups: [
- {
- name: 'review',
- size: 1,
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- jobs: [
- {
- id: 11498282342357,
- name: 'review',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982857',
- playable: false,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.805Z',
- updated_at: '2018-10-31T16:41:40.569Z',
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- },
- ],
- },
- {
- name: 'review_stop',
- size: 1,
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- jobs: [
- {
- id: 114982858,
- name: 'review_stop',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982858',
- playable: false,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.840Z',
- updated_at: '2018-10-31T16:41:40.480Z',
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- },
- ],
- },
- ],
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy',
- dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy',
- },
- ],
- artifacts: [],
- manual_actions: [
- {
- name: 'image:bootstrap',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play',
- playable: true,
- scheduled: false,
- },
- {
- name: 'image:builder-onbuild',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play',
- playable: true,
- scheduled: false,
- },
- {
- name: 'image:nginx-onbuild',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play',
- playable: true,
- scheduled: false,
- },
- {
- name: 'review_stop',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982858/play',
- playable: false,
- scheduled: false,
- },
- ],
- scheduled_actions: [],
- },
- project: {
- id: 20,
- name: 'GitLab Docs',
- full_path: '/gitlab-com/gitlab-docs',
- full_name: 'GitLab.com / GitLab Docs',
- },
- },
- triggered: [],
- },
- triggered: [
- {
- id: 34993051,
- user: {
- id: 376774,
- name: 'Alessio Caiazza',
- username: 'nolith',
- state: 'active',
- avatar_url:
- 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png',
- web_url: 'https://gitlab.com/nolith',
- status_tooltip_html: null,
- path: '/nolith',
- },
- active: false,
- coverage: null,
- source: 'pipeline',
- source_job: {
- name: 'trigger_job',
- },
- path: '/gitlab-com/gitlab-docs/pipelines/34993051',
- details: {
- status: {
- icon: 'status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- tooltip: 'failed',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/pipelines/34993051',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- },
- duration: 118,
- finished_at: '2018-10-31T16:41:40.615Z',
- stages: [
- {
- name: 'build-images',
- title: 'build-images: skipped',
- groups: [
- {
- name: 'image:bootstrap',
- size: 1,
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- jobs: [
- {
- id: 114982853,
- name: 'image:bootstrap',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982853',
- play_path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play',
- playable: true,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.704Z',
- updated_at: '2018-10-31T16:35:24.118Z',
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- },
- ],
- },
- {
- name: 'image:builder-onbuild',
- size: 1,
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- jobs: [
- {
- id: 114982854,
- name: 'image:builder-onbuild',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982854',
- play_path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play',
- playable: true,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.728Z',
- updated_at: '2018-10-31T16:35:24.070Z',
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- },
- ],
- },
- {
- name: 'image:nginx-onbuild',
- size: 1,
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- jobs: [
- {
- id: 114982855,
- name: 'image:nginx-onbuild',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982855',
- play_path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play',
- playable: true,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.753Z',
- updated_at: '2018-10-31T16:35:24.033Z',
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- },
- ],
- },
- ],
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images',
- dropdown_path:
- '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images',
- },
- {
- name: 'build',
- title: 'build: failed',
- groups: [
- {
- name: 'compile_dev',
- size: 1,
- status: {
- icon: 'status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- tooltip: 'failed - (script failure)',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/528/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- jobs: [
- {
- id: 114984694,
- name: 'compile_dev',
- started: '2018-10-31T16:39:41.598Z',
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114984694',
- retry_path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry',
- playable: false,
- scheduled: false,
- created_at: '2018-10-31T16:39:41.138Z',
- updated_at: '2018-10-31T16:41:40.072Z',
- status: {
- icon: 'status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- tooltip: 'failed - (script failure)',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/528/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- recoverable: false,
- },
- ],
- },
- ],
- status: {
- icon: 'status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- tooltip: 'failed',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- },
- path: '/gitlab-com/gitlab-docs/pipelines/34993051#build',
- dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build',
- },
- {
- name: 'deploy',
- title: 'deploy: skipped',
- groups: [
- {
- name: 'review',
- size: 1,
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- jobs: [
- {
- id: 114982857,
- name: 'review',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982857',
- playable: false,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.805Z',
- updated_at: '2018-10-31T16:41:40.569Z',
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- },
- ],
- },
- {
- name: 'review_stop',
- size: 1,
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- jobs: [
- {
- id: 114982858,
- name: 'review_stop',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982858',
- playable: false,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.840Z',
- updated_at: '2018-10-31T16:41:40.480Z',
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- },
- ],
- },
- ],
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy',
- dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy',
- },
- ],
- artifacts: [],
- manual_actions: [
- {
- name: 'image:bootstrap',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play',
- playable: true,
- scheduled: false,
- },
- {
- name: 'image:builder-onbuild',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play',
- playable: true,
- scheduled: false,
- },
- {
- name: 'image:nginx-onbuild',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play',
- playable: true,
- scheduled: false,
- },
- {
- name: 'review_stop',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982858/play',
- playable: false,
- scheduled: false,
- },
- ],
- scheduled_actions: [],
- },
- project: {
- id: 20,
- name: 'GitLab Docs',
- full_path: '/gitlab-com/gitlab-docs',
- full_name: 'GitLab.com / GitLab Docs',
- },
- },
- {
- id: 34993052,
- user: {
- id: 376774,
- name: 'Alessio Caiazza',
- username: 'nolith',
- state: 'active',
- avatar_url:
- 'https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png',
- web_url: 'https://gitlab.com/nolith',
- status_tooltip_html: null,
- path: '/nolith',
- },
- active: false,
- coverage: null,
- source: 'pipeline',
- source_job: {
- name: 'trigger_job',
- },
- path: '/gitlab-com/gitlab-docs/pipelines/34993051',
- details: {
- status: {
- icon: 'status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- tooltip: 'failed',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/pipelines/34993051',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- },
- duration: 118,
- finished_at: '2018-10-31T16:41:40.615Z',
- stages: [
- {
- name: 'build-images',
- title: 'build-images: skipped',
- groups: [
- {
- name: 'image:bootstrap',
- size: 1,
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- jobs: [
- {
- id: 114982853,
- name: 'image:bootstrap',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982853',
- play_path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play',
- playable: true,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.704Z',
- updated_at: '2018-10-31T16:35:24.118Z',
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982853',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- },
- ],
- },
- {
- name: 'image:builder-onbuild',
- size: 1,
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- jobs: [
- {
- id: 114982854,
- name: 'image:builder-onbuild',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982854',
- play_path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play',
- playable: true,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.728Z',
- updated_at: '2018-10-31T16:35:24.070Z',
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982854',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- },
- ],
- },
- {
- name: 'image:nginx-onbuild',
- size: 1,
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- jobs: [
- {
- id: 1224982855,
- name: 'image:nginx-onbuild',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982855',
- play_path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play',
- playable: true,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.753Z',
- updated_at: '2018-10-31T16:35:24.033Z',
- status: {
- icon: 'status_manual',
- text: 'manual',
- label: 'manual play action',
- group: 'manual',
- tooltip: 'manual action',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982855',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- },
- ],
- },
- ],
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- path: '/gitlab-com/gitlab-docs/pipelines/34993051#build-images',
- dropdown_path:
- '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images',
- },
- {
- name: 'build',
- title: 'build: failed',
- groups: [
- {
- name: 'compile_dev',
- size: 1,
- status: {
- icon: 'status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- tooltip: 'failed - (script failure)',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- jobs: [
- {
- id: 1123984694,
- name: 'compile_dev',
- started: '2018-10-31T16:39:41.598Z',
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114984694',
- retry_path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry',
- playable: false,
- scheduled: false,
- created_at: '2018-10-31T16:39:41.138Z',
- updated_at: '2018-10-31T16:41:40.072Z',
- status: {
- icon: 'status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- tooltip: 'failed - (script failure)',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114984694',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/gitlab-com/gitlab-docs/-/jobs/114984694/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- recoverable: false,
- },
- ],
- },
- ],
- status: {
- icon: 'status_failed',
- text: 'failed',
- label: 'failed',
- group: 'failed',
- tooltip: 'failed',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#build',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- },
- path: '/gitlab-com/gitlab-docs/pipelines/34993051#build',
- dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build',
- },
- {
- name: 'deploy',
- title: 'deploy: skipped',
- groups: [
- {
- name: 'review',
- size: 1,
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- jobs: [
- {
- id: 1143232982857,
- name: 'review',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982857',
- playable: false,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.805Z',
- updated_at: '2018-10-31T16:41:40.569Z',
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982857',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- },
- ],
- },
- {
- name: 'review_stop',
- size: 1,
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- jobs: [
- {
- id: 114921313182858,
- name: 'review_stop',
- started: null,
- archived: false,
- build_path: '/gitlab-com/gitlab-docs/-/jobs/114982858',
- playable: false,
- scheduled: false,
- created_at: '2018-10-31T16:35:23.840Z',
- updated_at: '2018-10-31T16:41:40.480Z',
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/-/jobs/114982858',
- illustration: {
- image:
- 'https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- },
- ],
- },
- ],
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy',
- illustration: null,
- favicon:
- 'https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- path: '/gitlab-com/gitlab-docs/pipelines/34993051#deploy',
- dropdown_path: '/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy',
- },
- ],
- artifacts: [],
- manual_actions: [
- {
- name: 'image:bootstrap',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982853/play',
- playable: true,
- scheduled: false,
- },
- {
- name: 'image:builder-onbuild',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982854/play',
- playable: true,
- scheduled: false,
- },
- {
- name: 'image:nginx-onbuild',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982855/play',
- playable: true,
- scheduled: false,
- },
- {
- name: 'review_stop',
- path: '/gitlab-com/gitlab-docs/-/jobs/114982858/play',
- playable: false,
- scheduled: false,
- },
- ],
- scheduled_actions: [],
- },
- project: {
- id: 20,
- name: 'GitLab Docs',
- full_path: '/gitlab-com/gitlab-docs',
- full_name: 'GitLab.com / GitLab Docs',
- },
- triggered: [
- {
- id: 26,
- user: null,
- active: false,
- coverage: null,
- source: 'push',
- source_job: {
- name: 'trigger_job',
- },
- created_at: '2019-01-06T17:48:37.599Z',
- updated_at: '2019-01-06T17:48:38.371Z',
- path: '/h5bp/html5-boilerplate/pipelines/26',
- flags: {
- latest: true,
- stuck: false,
- auto_devops: false,
- merge_request: false,
- yaml_errors: false,
- retryable: true,
- cancelable: false,
- failure_reason: false,
- },
- details: {
- status: {
- icon: 'status_warning',
- text: 'passed',
- label: 'passed with warnings',
- group: 'success-with-warnings',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/pipelines/26',
- illustration: null,
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- },
- duration: null,
- finished_at: '2019-01-06T17:48:38.370Z',
- stages: [
- {
- name: 'build',
- title: 'build: passed',
- groups: [
- {
- name: 'build:linux',
- size: 1,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/526',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/526/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- jobs: [
- {
- id: 526,
- name: 'build:linux',
- started: '2019-01-06T08:48:20.236Z',
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/526',
- retry_path: '/h5bp/html5-boilerplate/-/jobs/526/retry',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:37.806Z',
- updated_at: '2019-01-06T17:48:37.806Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/526',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/526/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- },
- ],
- },
- {
- name: 'build:osx',
- size: 1,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/527',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/527/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- jobs: [
- {
- id: 527,
- name: 'build:osx',
- started: '2019-01-06T07:48:20.237Z',
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/527',
- retry_path: '/h5bp/html5-boilerplate/-/jobs/527/retry',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:37.846Z',
- updated_at: '2019-01-06T17:48:37.846Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/527',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/527/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- },
- ],
- },
- ],
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/pipelines/26#build',
- illustration: null,
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- },
- path: '/h5bp/html5-boilerplate/pipelines/26#build',
- dropdown_path: '/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=build',
- },
- {
- name: 'test',
- title: 'test: passed with warnings',
- groups: [
- {
- name: 'jenkins',
- size: 1,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: null,
- group: 'success',
- tooltip: null,
- has_details: false,
- details_path: null,
- illustration: null,
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- },
- jobs: [
- {
- id: 546,
- name: 'jenkins',
- started: '2019-01-06T11:48:20.237Z',
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/546',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:38.359Z',
- updated_at: '2019-01-06T17:48:38.359Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: null,
- group: 'success',
- tooltip: null,
- has_details: false,
- details_path: null,
- illustration: null,
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- },
- },
- ],
- },
- {
- name: 'rspec:linux',
- size: 3,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: false,
- details_path: null,
- illustration: null,
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- },
- jobs: [
- {
- id: 528,
- name: 'rspec:linux 0 3',
- started: '2019-01-06T09:48:20.237Z',
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/528',
- retry_path: '/h5bp/html5-boilerplate/-/jobs/528/retry',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:37.885Z',
- updated_at: '2019-01-06T17:48:37.885Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/528',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/528/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- },
- {
- id: 529,
- name: 'rspec:linux 1 3',
- started: '2019-01-06T09:48:20.237Z',
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/529',
- retry_path: '/h5bp/html5-boilerplate/-/jobs/529/retry',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:37.907Z',
- updated_at: '2019-01-06T17:48:37.907Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/529',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/529/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- },
- {
- id: 530,
- name: 'rspec:linux 2 3',
- started: '2019-01-06T09:48:20.237Z',
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/530',
- retry_path: '/h5bp/html5-boilerplate/-/jobs/530/retry',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:37.927Z',
- updated_at: '2019-01-06T17:48:37.927Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/530',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/530/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- },
- ],
- },
- {
- name: 'rspec:osx',
- size: 1,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/535',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/535/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- jobs: [
- {
- id: 535,
- name: 'rspec:osx',
- started: '2019-01-06T09:48:20.237Z',
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/535',
- retry_path: '/h5bp/html5-boilerplate/-/jobs/535/retry',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:38.018Z',
- updated_at: '2019-01-06T17:48:38.018Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/535',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/535/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- },
- ],
- },
- {
- name: 'rspec:windows',
- size: 3,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: false,
- details_path: null,
- illustration: null,
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- },
- jobs: [
- {
- id: 531,
- name: 'rspec:windows 0 3',
- started: '2019-01-06T09:48:20.237Z',
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/531',
- retry_path: '/h5bp/html5-boilerplate/-/jobs/531/retry',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:37.944Z',
- updated_at: '2019-01-06T17:48:37.944Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/531',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/531/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- },
- {
- id: 532,
- name: 'rspec:windows 1 3',
- started: '2019-01-06T09:48:20.237Z',
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/532',
- retry_path: '/h5bp/html5-boilerplate/-/jobs/532/retry',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:37.962Z',
- updated_at: '2019-01-06T17:48:37.962Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/532',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/532/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- },
- {
- id: 534,
- name: 'rspec:windows 2 3',
- started: '2019-01-06T09:48:20.237Z',
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/534',
- retry_path: '/h5bp/html5-boilerplate/-/jobs/534/retry',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:37.999Z',
- updated_at: '2019-01-06T17:48:37.999Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/534',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/534/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- },
- ],
- },
- {
- name: 'spinach:linux',
- size: 1,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/536',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/536/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- jobs: [
- {
- id: 536,
- name: 'spinach:linux',
- started: '2019-01-06T09:48:20.237Z',
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/536',
- retry_path: '/h5bp/html5-boilerplate/-/jobs/536/retry',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:38.050Z',
- updated_at: '2019-01-06T17:48:38.050Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/536',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/536/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- },
- ],
- },
- {
- name: 'spinach:osx',
- size: 1,
- status: {
- icon: 'status_warning',
- text: 'failed',
- label: 'failed (allowed to fail)',
- group: 'failed-with-warnings',
- tooltip: 'failed - (unknown failure) (allowed to fail)',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/537',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/537/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- jobs: [
- {
- id: 537,
- name: 'spinach:osx',
- started: '2019-01-06T09:48:20.237Z',
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/537',
- retry_path: '/h5bp/html5-boilerplate/-/jobs/537/retry',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:38.069Z',
- updated_at: '2019-01-06T17:48:38.069Z',
- status: {
- icon: 'status_warning',
- text: 'failed',
- label: 'failed (allowed to fail)',
- group: 'failed-with-warnings',
- tooltip: 'failed - (unknown failure) (allowed to fail)',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/537',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/537/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- callout_message: 'There is an unknown failure, please try again',
- recoverable: true,
- },
- ],
- },
- ],
- status: {
- icon: 'status_warning',
- text: 'passed',
- label: 'passed with warnings',
- group: 'success-with-warnings',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/pipelines/26#test',
- illustration: null,
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- },
- path: '/h5bp/html5-boilerplate/pipelines/26#test',
- dropdown_path: '/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=test',
- },
- {
- name: 'security',
- title: 'security: passed',
- groups: [
- {
- name: 'container_scanning',
- size: 1,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/541',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/541/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- jobs: [
- {
- id: 541,
- name: 'container_scanning',
- started: '2019-01-06T09:48:20.237Z',
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/541',
- retry_path: '/h5bp/html5-boilerplate/-/jobs/541/retry',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:38.186Z',
- updated_at: '2019-01-06T17:48:38.186Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/541',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/541/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- },
- ],
- },
- {
- name: 'dast',
- size: 1,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/538',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/538/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- jobs: [
- {
- id: 538,
- name: 'dast',
- started: '2019-01-06T09:48:20.237Z',
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/538',
- retry_path: '/h5bp/html5-boilerplate/-/jobs/538/retry',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:38.087Z',
- updated_at: '2019-01-06T17:48:38.087Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/538',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/538/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- },
- ],
- },
- {
- name: 'dependency_scanning',
- size: 1,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/540',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/540/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- jobs: [
- {
- id: 540,
- name: 'dependency_scanning',
- started: '2019-01-06T09:48:20.237Z',
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/540',
- retry_path: '/h5bp/html5-boilerplate/-/jobs/540/retry',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:38.153Z',
- updated_at: '2019-01-06T17:48:38.153Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/540',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/540/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- },
- ],
- },
- {
- name: 'sast',
- size: 1,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/539',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/539/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- jobs: [
- {
- id: 539,
- name: 'sast',
- started: '2019-01-06T09:48:20.237Z',
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/539',
- retry_path: '/h5bp/html5-boilerplate/-/jobs/539/retry',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:38.121Z',
- updated_at: '2019-01-06T17:48:38.121Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/539',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/539/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- },
- ],
- },
- ],
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/pipelines/26#security',
- illustration: null,
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- },
- path: '/h5bp/html5-boilerplate/pipelines/26#security',
- dropdown_path: '/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=security',
- },
- {
- name: 'deploy',
- title: 'deploy: passed',
- groups: [
- {
- name: 'production',
- size: 1,
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/544',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- jobs: [
- {
- id: 544,
- name: 'production',
- started: null,
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/544',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:38.313Z',
- updated_at: '2019-01-06T17:48:38.313Z',
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/544',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- },
- ],
- },
- {
- name: 'staging',
- size: 1,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/542',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/542/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- jobs: [
- {
- id: 542,
- name: 'staging',
- started: '2019-01-06T11:48:20.237Z',
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/542',
- retry_path: '/h5bp/html5-boilerplate/-/jobs/542/retry',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:38.219Z',
- updated_at: '2019-01-06T17:48:38.219Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/542',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job does not have a trace.',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'retry',
- title: 'Retry',
- path: '/h5bp/html5-boilerplate/-/jobs/542/retry',
- method: 'post',
- button_title: 'Retry this job',
- },
- },
- },
- ],
- },
- {
- name: 'stop staging',
- size: 1,
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/543',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- jobs: [
- {
- id: 543,
- name: 'stop staging',
- started: null,
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/543',
- playable: false,
- scheduled: false,
- created_at: '2019-01-06T17:48:38.283Z',
- updated_at: '2019-01-06T17:48:38.283Z',
- status: {
- icon: 'status_skipped',
- text: 'skipped',
- label: 'skipped',
- group: 'skipped',
- tooltip: 'skipped',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/543',
- illustration: {
- image:
- '/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg',
- size: 'svg-430',
- title: 'This job has been skipped',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png',
- },
- },
- ],
- },
- ],
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/pipelines/26#deploy',
- illustration: null,
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- },
- path: '/h5bp/html5-boilerplate/pipelines/26#deploy',
- dropdown_path: '/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=deploy',
- },
- {
- name: 'notify',
- title: 'notify: passed',
- groups: [
- {
- name: 'slack',
- size: 1,
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'manual play action',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/545',
- illustration: {
- image:
- '/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/h5bp/html5-boilerplate/-/jobs/545/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- jobs: [
- {
- id: 545,
- name: 'slack',
- started: null,
- archived: false,
- build_path: '/h5bp/html5-boilerplate/-/jobs/545',
- retry_path: '/h5bp/html5-boilerplate/-/jobs/545/retry',
- play_path: '/h5bp/html5-boilerplate/-/jobs/545/play',
- playable: true,
- scheduled: false,
- created_at: '2019-01-06T17:48:38.341Z',
- updated_at: '2019-01-06T17:48:38.341Z',
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'manual play action',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/-/jobs/545',
- illustration: {
- image:
- '/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg',
- size: 'svg-394',
- title: 'This job requires a manual action',
- content:
- 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments',
- },
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- action: {
- icon: 'play',
- title: 'Play',
- path: '/h5bp/html5-boilerplate/-/jobs/545/play',
- method: 'post',
- button_title: 'Trigger this manual action',
- },
- },
- },
- ],
- },
- ],
- status: {
- icon: 'status_success',
- text: 'passed',
- label: 'passed',
- group: 'success',
- tooltip: 'passed',
- has_details: true,
- details_path: '/h5bp/html5-boilerplate/pipelines/26#notify',
- illustration: null,
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- },
- path: '/h5bp/html5-boilerplate/pipelines/26#notify',
- dropdown_path: '/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=notify',
- },
- ],
- artifacts: [
- {
- name: 'build:linux',
- expired: null,
- expire_at: null,
- path: '/h5bp/html5-boilerplate/-/jobs/526/artifacts/download',
- browse_path: '/h5bp/html5-boilerplate/-/jobs/526/artifacts/browse',
- },
- {
- name: 'build:osx',
- expired: null,
- expire_at: null,
- path: '/h5bp/html5-boilerplate/-/jobs/527/artifacts/download',
- browse_path: '/h5bp/html5-boilerplate/-/jobs/527/artifacts/browse',
- },
- ],
- manual_actions: [
- {
- name: 'stop staging',
- path: '/h5bp/html5-boilerplate/-/jobs/543/play',
- playable: false,
- scheduled: false,
- },
- {
- name: 'production',
- path: '/h5bp/html5-boilerplate/-/jobs/544/play',
- playable: false,
- scheduled: false,
- },
- {
- name: 'slack',
- path: '/h5bp/html5-boilerplate/-/jobs/545/play',
- playable: true,
- scheduled: false,
- },
- ],
- scheduled_actions: [],
- },
- ref: {
- name: 'main',
- path: '/h5bp/html5-boilerplate/commits/main',
- tag: false,
- branch: true,
- merge_request: false,
- },
- commit: {
- id: 'bad98c453eab56d20057f3929989251d45cd1a8b',
- short_id: 'bad98c45',
- title: 'remove instances of shrink-to-fit=no (#2103)',
- created_at: '2018-12-17T20:52:18.000Z',
- parent_ids: ['49130f6cfe9ff1f749015d735649a2bc6f66cf3a'],
- message:
- 'remove instances of shrink-to-fit=no (#2103)\n\ncloses #2102\r\n\r\nPer my findings, the need for it as a default was rectified with the release of iOS 9.3, where the viewport no longer shrunk to accommodate overflow, as was introduced in iOS 9.',
- author_name: "Scott O'Hara",
- author_email: 'scottaohara@users.noreply.github.com',
- authored_date: '2018-12-17T20:52:18.000Z',
- committer_name: 'Rob Larsen',
- committer_email: 'rob@drunkenfist.com',
- committed_date: '2018-12-17T20:52:18.000Z',
- author: null,
- author_gravatar_url:
- 'https://www.gravatar.com/avatar/6d597df7cf998d16cbe00ccac063b31e?s=80\u0026d=identicon',
- commit_url:
- 'http://localhost:3001/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b',
- commit_path: '/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b',
- },
- retry_path: '/h5bp/html5-boilerplate/pipelines/26/retry',
- triggered_by: {
- id: 4,
- user: null,
- active: false,
- coverage: null,
- source: 'push',
- source_job: {
- name: 'trigger_job',
- },
- path: '/gitlab-org/gitlab-test/pipelines/4',
- details: {
- status: {
- icon: 'status_warning',
- text: 'passed',
- label: 'passed with warnings',
- group: 'success-with-warnings',
- tooltip: 'passed',
- has_details: true,
- details_path: '/gitlab-org/gitlab-test/pipelines/4',
- illustration: null,
- favicon:
- '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
- },
- },
- project: {
- id: 1,
- name: 'Gitlab Test',
- full_path: '/gitlab-org/gitlab-test',
- full_name: 'Gitlab Org / Gitlab Test',
- },
- },
- triggered: [],
- project: {
- id: 20,
- name: 'GitLab Docs',
- full_path: '/gitlab-com/gitlab-docs',
- full_name: 'GitLab.com / GitLab Docs',
- },
- },
- ],
- },
- ],
+ multiproject: true,
};
diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js
index e531e26a858..9e51003da66 100644
--- a/spec/frontend/pipelines/header_component_spec.js
+++ b/spec/frontend/pipelines/header_component_spec.js
@@ -24,7 +24,7 @@ describe('Pipeline details header', () => {
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const defaultProvideOptions = {
- pipelineId: 14,
+ pipelineId: '14',
pipelineIid: 1,
paths: {
pipelinesPath: '/namespace/my-project/-/pipelines',
diff --git a/spec/frontend/pipelines/pipeline_multi_actions_spec.js b/spec/frontend/pipelines/pipeline_multi_actions_spec.js
index ce33b6011bf..a606595b37d 100644
--- a/spec/frontend/pipelines/pipeline_multi_actions_spec.js
+++ b/spec/frontend/pipelines/pipeline_multi_actions_spec.js
@@ -1,4 +1,4 @@
-import { GlAlert, GlDropdown, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlDropdown, GlSprintf, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
@@ -51,6 +51,7 @@ describe('Pipeline Multi Actions Dropdown', () => {
const findAlert = () => wrapper.findComponent(GlAlert);
const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findAllArtifactItems = () => wrapper.findAllByTestId(artifactItemTestId);
const findFirstArtifactItem = () => wrapper.findByTestId(artifactItemTestId);
const findEmptyMessage = () => wrapper.findByTestId('artifacts-empty-message');
@@ -103,6 +104,15 @@ describe('Pipeline Multi Actions Dropdown', () => {
expect(findEmptyMessage().exists()).toBe(true);
});
+ describe('while loading artifacts', () => {
+ it('should render a loading spinner and no empty message', () => {
+ createComponent({ mockData: { isLoading: true, artifacts: [] } });
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findEmptyMessage().exists()).toBe(false);
+ });
+ });
+
describe('with a failing request', () => {
it('should render an error message', async () => {
const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId);
diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js
index 76feaaad1ec..aa30062c987 100644
--- a/spec/frontend/pipelines/pipelines_spec.js
+++ b/spec/frontend/pipelines/pipelines_spec.js
@@ -105,8 +105,6 @@ describe('Pipelines', () => {
});
beforeEach(() => {
- window.gon = { features: { pipelineSourceFilter: true } };
-
mock = new MockAdapter(axios);
jest.spyOn(window.history, 'pushState');
diff --git a/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js
index 5d15f0a3c55..684d2d0664a 100644
--- a/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js
+++ b/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js
@@ -1,5 +1,6 @@
import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { PIPELINE_SOURCES } from 'ee_else_ce/pipelines/components/pipelines_list/tokens/constants';
import { stubComponent } from 'helpers/stub_component';
import PipelineSourceToken from '~/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue';
@@ -44,7 +45,7 @@ describe('Pipeline Source Token', () => {
describe('shows sources correctly', () => {
it('renders all pipeline sources available', () => {
- expect(findAllFilteredSearchSuggestions()).toHaveLength(wrapper.vm.sources.length);
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(PIPELINE_SOURCES.length);
});
});
});
diff --git a/spec/frontend/pipelines/parsing_utils_spec.js b/spec/frontend/pipelines/utils_spec.js
index 3a270c1c1b5..1c23a7e4fcf 100644
--- a/spec/frontend/pipelines/parsing_utils_spec.js
+++ b/spec/frontend/pipelines/utils_spec.js
@@ -1,6 +1,5 @@
import { createSankey } from '~/pipelines/components/dag/drawing_utils';
import {
- createNodeDict,
makeLinksFromNodes,
filterByAncestors,
generateColumnsFromLayersListBare,
@@ -9,6 +8,7 @@ import {
removeOrphanNodes,
getMaxNodes,
} from '~/pipelines/components/parsing_utils';
+import { createNodeDict } from '~/pipelines/utils';
import { mockParsedGraphQLNodes, missingJob } from './components/dag/mock_data';
import { generateResponse, mockPipelineResponse } from './graph/mock_data';
diff --git a/spec/frontend/pipelines_spec.js b/spec/frontend/pipelines_spec.js
deleted file mode 100644
index add91fbcc23..00000000000
--- a/spec/frontend/pipelines_spec.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import Pipelines from '~/pipelines';
-
-describe('Pipelines', () => {
- beforeEach(() => {
- loadFixtures('static/pipeline_graph.html');
- });
-
- it('should be defined', () => {
- expect(Pipelines).toBeDefined();
- });
-
- it('should create a `Pipelines` instance without options', () => {
- expect(() => {
- new Pipelines(); // eslint-disable-line no-new
- }).not.toThrow();
- });
-});
diff --git a/spec/frontend/popovers/components/popovers_spec.js b/spec/frontend/popovers/components/popovers_spec.js
index 25c509346d1..2751a878e51 100644
--- a/spec/frontend/popovers/components/popovers_spec.js
+++ b/spec/frontend/popovers/components/popovers_spec.js
@@ -54,17 +54,20 @@ describe('popovers/components/popovers.vue', () => {
expect(wrapper.findAll(GlPopover)).toHaveLength(1);
});
- it('supports HTML content', async () => {
- const content = 'content with <b>HTML</b>';
- await buildWrapper(
- createPopoverTarget({
- content,
- html: true,
- }),
- );
- const html = wrapper.find(GlPopover).html();
-
- expect(html).toContain(content);
+ describe('supports HTML content', () => {
+ const svgIcon = '<svg><use xlink:href="icons.svg#test"></use></svg>';
+
+ it.each`
+ description | content | render
+ ${'renders html content correctly'} | ${'<b>HTML</b>'} | ${'<b>HTML</b>'}
+ ${'removes any unsafe content'} | ${'<script>alert(XSS)</script>'} | ${''}
+ ${'renders svg icons correctly'} | ${svgIcon} | ${svgIcon}
+ `('$description', async ({ content, render }) => {
+ await buildWrapper(createPopoverTarget({ content, html: true }));
+
+ const html = wrapper.find(GlPopover).html();
+ expect(html).toContain(render);
+ });
});
it.each`
diff --git a/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js b/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js
index b5ee62f2042..6ef49390c47 100644
--- a/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js
+++ b/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js
@@ -60,7 +60,7 @@ describe('~/projects/pipelines/charts/components/pipeline_charts.vue', () => {
expect(chart.props('yAxisTitle')).toBe('Minutes');
expect(chart.props('xAxisTitle')).toBe('Commit');
expect(chart.props('bars')).toBe(wrapper.vm.timesChartTransformedData);
- expect(chart.props('option')).toBe(wrapper.vm.$options.timesChartOptions);
+ expect(chart.props('option')).toBe(wrapper.vm.chartOptions);
});
});
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 5323c1afbb5..eacf858f22c 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
@@ -107,6 +107,29 @@ describe('ServiceDeskSetting', () => {
});
});
+ describe('project suffix', () => {
+ it('input is hidden', () => {
+ wrapper = createComponent({
+ props: { customEmailEnabled: false },
+ });
+
+ const input = wrapper.findByTestId('project-suffix');
+
+ expect(input.exists()).toBe(false);
+ });
+
+ it('input is enabled', () => {
+ wrapper = createComponent({
+ props: { customEmailEnabled: true },
+ });
+
+ const input = wrapper.findByTestId('project-suffix');
+
+ expect(input.exists()).toBe(true);
+ expect(input.attributes('disabled')).toBeUndefined();
+ });
+ });
+
describe('customEmail is the same as incomingEmail', () => {
const email = 'foo@bar.com';
diff --git a/spec/frontend/projects/storage_counter/components/app_spec.js b/spec/frontend/projects/storage_counter/components/app_spec.js
new file mode 100644
index 00000000000..f3da01e0602
--- /dev/null
+++ b/spec/frontend/projects/storage_counter/components/app_spec.js
@@ -0,0 +1,150 @@
+import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import StorageCounterApp from '~/projects/storage_counter/components/app.vue';
+import { TOTAL_USAGE_DEFAULT_TEXT } from '~/projects/storage_counter/constants';
+import getProjectStorageCount from '~/projects/storage_counter/queries/project_storage.query.graphql';
+import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue';
+import {
+ mockGetProjectStorageCountGraphQLResponse,
+ mockEmptyResponse,
+ projectData,
+ defaultProvideValues,
+} from '../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+describe('Storage counter app', () => {
+ let wrapper;
+
+ const createMockApolloProvider = ({ reject = false, mockedValue } = {}) => {
+ let response;
+
+ if (reject) {
+ response = jest.fn().mockRejectedValue(mockedValue || new Error('GraphQL error'));
+ } else {
+ response = jest.fn().mockResolvedValue(mockedValue);
+ }
+
+ const requestHandlers = [[getProjectStorageCount, response]];
+
+ return createMockApollo(requestHandlers);
+ };
+
+ const createComponent = ({ provide = {}, mockApollo } = {}) => {
+ wrapper = extendedWrapper(
+ shallowMount(StorageCounterApp, {
+ localVue,
+ apolloProvider: mockApollo,
+ provide: {
+ ...defaultProvideValues,
+ ...provide,
+ },
+ }),
+ );
+ };
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findUsagePercentage = () => wrapper.findByTestId('total-usage');
+ const findUsageQuotasHelpLink = () => wrapper.findByTestId('usage-quotas-help-link');
+ const findUsageGraph = () => wrapper.findComponent(UsageGraph);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('with apollo fetching successful', () => {
+ let mockApollo;
+
+ beforeEach(async () => {
+ mockApollo = createMockApolloProvider({
+ mockedValue: mockGetProjectStorageCountGraphQLResponse,
+ });
+ createComponent({ mockApollo });
+ await waitForPromises();
+ });
+
+ it('renders correct total usage', () => {
+ expect(findUsagePercentage().text()).toBe(projectData.storage.totalUsage);
+ });
+
+ it('renders correct usage quotas help link', () => {
+ expect(findUsageQuotasHelpLink().attributes('href')).toBe(
+ defaultProvideValues.helpLinks.usageQuotasHelpPagePath,
+ );
+ });
+ });
+
+ describe('with apollo loading', () => {
+ let mockApollo;
+
+ beforeEach(() => {
+ mockApollo = createMockApolloProvider({
+ mockedValue: new Promise(() => {}),
+ });
+ createComponent({ mockApollo });
+ });
+
+ it('should show loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+ });
+
+ describe('with apollo returning empty data', () => {
+ let mockApollo;
+
+ beforeEach(async () => {
+ mockApollo = createMockApolloProvider({
+ mockedValue: mockEmptyResponse,
+ });
+ createComponent({ mockApollo });
+ await waitForPromises();
+ });
+
+ it('shows default text for total usage', () => {
+ expect(findUsagePercentage().text()).toBe(TOTAL_USAGE_DEFAULT_TEXT);
+ });
+ });
+
+ describe('with apollo fetching error', () => {
+ let mockApollo;
+
+ beforeEach(() => {
+ mockApollo = createMockApolloProvider();
+ createComponent({ mockApollo, reject: true });
+ });
+
+ it('renders gl-alert', () => {
+ expect(findAlert().exists()).toBe(true);
+ });
+ });
+
+ describe('rendering <usage-graph />', () => {
+ let mockApollo;
+
+ beforeEach(async () => {
+ mockApollo = createMockApolloProvider({
+ mockedValue: mockGetProjectStorageCountGraphQLResponse,
+ });
+ createComponent({ mockApollo });
+ await waitForPromises();
+ });
+
+ it('renders usage-graph component if project.statistics exists', () => {
+ expect(findUsageGraph().exists()).toBe(true);
+ });
+
+ it('passes project.statistics to usage-graph component', () => {
+ const {
+ __typename,
+ ...statistics
+ } = mockGetProjectStorageCountGraphQLResponse.data.project.statistics;
+ expect(findUsageGraph().props('rootStorageStatistics')).toMatchObject(statistics);
+ });
+ });
+});
diff --git a/spec/frontend/projects/storage_counter/components/storage_table_spec.js b/spec/frontend/projects/storage_counter/components/storage_table_spec.js
new file mode 100644
index 00000000000..14298318fff
--- /dev/null
+++ b/spec/frontend/projects/storage_counter/components/storage_table_spec.js
@@ -0,0 +1,62 @@
+import { GlTable } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import StorageTable from '~/projects/storage_counter/components/storage_table.vue';
+import { projectData, defaultProvideValues } from '../mock_data';
+
+describe('StorageTable', () => {
+ let wrapper;
+
+ const defaultProps = {
+ storageTypes: projectData.storage.storageTypes,
+ };
+
+ const createComponent = (props = {}) => {
+ wrapper = extendedWrapper(
+ mount(StorageTable, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ }),
+ );
+ };
+
+ const findTable = () => wrapper.findComponent(GlTable);
+
+ beforeEach(() => {
+ createComponent();
+ });
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('with storage types', () => {
+ it.each(projectData.storage.storageTypes)(
+ 'renders table row correctly %o',
+ ({ storageType: { id, name, description } }) => {
+ expect(wrapper.findByTestId(`${id}-name`).text()).toBe(name);
+ expect(wrapper.findByTestId(`${id}-description`).text()).toBe(description);
+ expect(wrapper.findByTestId(`${id}-help-link`).attributes('href')).toBe(
+ defaultProvideValues.helpLinks[id.replace(`Size`, `HelpPagePath`)]
+ .replace(`Size`, ``)
+ .replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`),
+ );
+ },
+ );
+ });
+
+ describe('without storage types', () => {
+ beforeEach(() => {
+ createComponent({ storageTypes: [] });
+ });
+
+ it('should render the table header <th>', () => {
+ expect(findTable().find('th').exists()).toBe(true);
+ });
+
+ it('should not render any table data <td>', () => {
+ expect(findTable().find('td').exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/projects/storage_counter/mock_data.js b/spec/frontend/projects/storage_counter/mock_data.js
new file mode 100644
index 00000000000..b9fa68b3ec7
--- /dev/null
+++ b/spec/frontend/projects/storage_counter/mock_data.js
@@ -0,0 +1,109 @@
+export const mockGetProjectStorageCountGraphQLResponse = {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/20',
+ statistics: {
+ buildArtifactsSize: 400000.0,
+ pipelineArtifactsSize: 25000.0,
+ lfsObjectsSize: 4800000.0,
+ packagesSize: 3800000.0,
+ repositorySize: 3900000.0,
+ snippetsSize: 1200000.0,
+ storageSize: 15300000.0,
+ uploadsSize: 900000.0,
+ wikiSize: 300000.0,
+ __typename: 'ProjectStatistics',
+ },
+ __typename: 'Project',
+ },
+ },
+};
+
+export const mockEmptyResponse = { data: { project: null } };
+
+export const defaultProvideValues = {
+ projectPath: '/project-path',
+ helpLinks: {
+ usageQuotasHelpPagePath: '/usage-quotas',
+ buildArtifactsHelpPagePath: '/build-artifacts',
+ lfsObjectsHelpPagePath: '/lsf-objects',
+ packagesHelpPagePath: '/packages',
+ repositoryHelpPagePath: '/repository',
+ snippetsHelpPagePath: '/snippets',
+ uploadsHelpPagePath: '/uploads',
+ wikiHelpPagePath: '/wiki',
+ },
+};
+
+export const projectData = {
+ storage: {
+ totalUsage: '14.6 MiB',
+ storageTypes: [
+ {
+ storageType: {
+ id: 'buildArtifactsSize',
+ name: 'Artifacts',
+ description: 'Pipeline artifacts and job artifacts, created with CI/CD.',
+ warningMessage:
+ 'There is a known issue with Artifact storage where the total could be incorrect for some projects. More details and progress are available in %{warningLinkStart}the epic%{warningLinkEnd}.',
+ helpPath: '/build-artifacts',
+ },
+ value: 400000,
+ },
+ {
+ storageType: {
+ id: 'lfsObjectsSize',
+ name: 'LFS Storage',
+ description: 'Audio samples, videos, datasets, and graphics.',
+ helpPath: '/lsf-objects',
+ },
+ value: 4800000,
+ },
+ {
+ storageType: {
+ id: 'packagesSize',
+ name: 'Packages',
+ description: 'Code packages and container images.',
+ helpPath: '/packages',
+ },
+ value: 3800000,
+ },
+ {
+ storageType: {
+ id: 'repositorySize',
+ name: 'Repository',
+ description: 'Git repository, managed by the Gitaly service.',
+ helpPath: '/repository',
+ },
+ value: 3900000,
+ },
+ {
+ storageType: {
+ id: 'snippetsSize',
+ name: 'Snippets',
+ description: 'Shared bits of code and text.',
+ helpPath: '/snippets',
+ },
+ value: 1200000,
+ },
+ {
+ storageType: {
+ id: 'uploadsSize',
+ name: 'Uploads',
+ description: 'File attachments and smaller design graphics.',
+ helpPath: '/uploads',
+ },
+ value: 900000,
+ },
+ {
+ storageType: {
+ id: 'wikiSize',
+ name: 'Wiki',
+ description: 'Wiki content.',
+ helpPath: '/wiki',
+ },
+ value: 300000,
+ },
+ ],
+ },
+};
diff --git a/spec/frontend/projects/storage_counter/utils_spec.js b/spec/frontend/projects/storage_counter/utils_spec.js
new file mode 100644
index 00000000000..57c755266a0
--- /dev/null
+++ b/spec/frontend/projects/storage_counter/utils_spec.js
@@ -0,0 +1,17 @@
+import { parseGetProjectStorageResults } from '~/projects/storage_counter/utils';
+import {
+ mockGetProjectStorageCountGraphQLResponse,
+ projectData,
+ defaultProvideValues,
+} from './mock_data';
+
+describe('parseGetProjectStorageResults', () => {
+ it('parses project statistics correctly', () => {
+ expect(
+ parseGetProjectStorageResults(
+ mockGetProjectStorageCountGraphQLResponse.data,
+ defaultProvideValues.helpLinks,
+ ),
+ ).toMatchObject(projectData);
+ });
+});
diff --git a/spec/frontend/projects/terraform_notification/terraform_notification_spec.js b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js
index 71c22998b08..6576ce70d60 100644
--- a/spec/frontend/projects/terraform_notification/terraform_notification_spec.js
+++ b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js
@@ -1,51 +1,91 @@
import { GlBanner } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { setCookie, parseBoolean } from '~/lib/utils/common_utils';
+import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser';
+import { mockTracking } from 'helpers/tracking_helper';
import TerraformNotification from '~/projects/terraform_notification/components/terraform_notification.vue';
-
-jest.mock('~/lib/utils/common_utils');
+import {
+ EVENT_LABEL,
+ DISMISS_EVENT,
+ CLICK_EVENT,
+} from '~/projects/terraform_notification/constants';
const terraformImagePath = '/path/to/image';
-const bannerDismissedKey = 'terraform_notification_dismissed';
describe('TerraformNotificationBanner', () => {
let wrapper;
+ let trackingSpy;
+ let userCalloutDismissSpy;
const provideData = {
terraformImagePath,
- bannerDismissedKey,
};
const findBanner = () => wrapper.findComponent(GlBanner);
- beforeEach(() => {
+ const createComponent = ({ shouldShowCallout = true } = {}) => {
+ userCalloutDismissSpy = jest.fn();
+
wrapper = shallowMount(TerraformNotification, {
provide: provideData,
- stubs: { GlBanner },
+ stubs: {
+ GlBanner,
+ UserCalloutDismisser: makeMockUserCalloutDismisser({
+ dismiss: userCalloutDismissSpy,
+ shouldShowCallout,
+ }),
+ },
});
+ };
+
+ beforeEach(() => {
+ createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
afterEach(() => {
wrapper.destroy();
- parseBoolean.mockReturnValue(false);
});
- describe('when the dismiss cookie is not set', () => {
+ describe('when user has already dismissed the banner', () => {
+ beforeEach(() => {
+ createComponent({
+ shouldShowCallout: false,
+ });
+ });
+ it('should not render the banner', () => {
+ expect(findBanner().exists()).toBe(false);
+ });
+ });
+
+ describe("when user hasn't yet dismissed the banner", () => {
it('should render the banner', () => {
expect(findBanner().exists()).toBe(true);
});
});
describe('when close button is clicked', () => {
- beforeEach(async () => {
- await findBanner().vm.$emit('close');
+ beforeEach(() => {
+ wrapper.vm.$refs.calloutDismisser.dismiss = userCalloutDismissSpy;
+ findBanner().vm.$emit('close');
+ });
+ it('should send the dismiss event', () => {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, DISMISS_EVENT, {
+ label: EVENT_LABEL,
+ });
});
+ it('should call the dismiss callback', () => {
+ expect(userCalloutDismissSpy).toHaveBeenCalledTimes(1);
+ });
+ });
- it('should set the cookie with the bannerDismissedKey', () => {
- expect(setCookie).toHaveBeenCalledWith(bannerDismissedKey, true);
+ describe('when docs link is clicked', () => {
+ beforeEach(() => {
+ findBanner().vm.$emit('primary');
});
- it('should remove the banner', () => {
- expect(findBanner().exists()).toBe(false);
+ it('should send button click event', () => {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, CLICK_EVENT, {
+ label: EVENT_LABEL,
+ });
});
});
});
diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js
index d462995328b..8331adcdfc2 100644
--- a/spec/frontend/repository/components/blob_content_viewer_spec.js
+++ b/spec/frontend/repository/components/blob_content_viewer_spec.js
@@ -375,6 +375,30 @@ describe('Blob content viewer component', () => {
expect(findBlobHeader().props('isBinary')).toBe(true);
},
);
+
+ it('passes the correct header props when viewing a non-text file', async () => {
+ fullFactory({
+ mockData: {
+ blobInfo: {
+ ...simpleMockData,
+ simpleViewer: {
+ ...simpleMockData.simpleViewer,
+ fileType: 'image',
+ },
+ },
+ },
+ stubs: {
+ BlobContent: true,
+ BlobReplace: true,
+ },
+ });
+
+ await nextTick();
+
+ expect(findBlobHeader().props('hideViewerSwitcher')).toBe(true);
+ expect(findBlobHeader().props('isBinary')).toBe(true);
+ expect(findBlobEdit().props('showEditButton')).toBe(false);
+ });
});
describe('BlobButtonGroup', () => {
diff --git a/spec/frontend/repository/components/blob_viewers/image_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/image_viewer_spec.js
new file mode 100644
index 00000000000..6735dddf51e
--- /dev/null
+++ b/spec/frontend/repository/components/blob_viewers/image_viewer_spec.js
@@ -0,0 +1,25 @@
+import { shallowMount } from '@vue/test-utils';
+import ImageViewer from '~/repository/components/blob_viewers/image_viewer.vue';
+
+describe('Image Viewer', () => {
+ let wrapper;
+
+ const propsData = {
+ url: 'some/image.png',
+ alt: 'image.png',
+ };
+
+ const createComponent = () => {
+ wrapper = shallowMount(ImageViewer, { propsData });
+ };
+
+ const findImage = () => wrapper.find('[data-testid="image"]');
+
+ it('renders a Source Editor component', () => {
+ createComponent();
+
+ expect(findImage().exists()).toBe(true);
+ expect(findImage().attributes('src')).toBe(propsData.url);
+ expect(findImage().attributes('alt')).toBe(propsData.alt);
+ });
+});
diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js
index 1d1ec58100f..e36287eff29 100644
--- a/spec/frontend/repository/components/tree_content_spec.js
+++ b/spec/frontend/repository/components/tree_content_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import filesQuery from 'shared_queries/repository/files.query.graphql';
+import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql';
import FilePreview from '~/repository/components/preview/index.vue';
import FileTable from '~/repository/components/table/index.vue';
import TreeContent from '~/repository/components/tree_content.vue';
@@ -22,6 +22,7 @@ function factory(path, data = () => ({})) {
provide: {
glFeatures: {
increasePageSizeExponentially: true,
+ paginatedTreeGraphqlQuery: true,
},
},
});
@@ -58,7 +59,7 @@ describe('Repository table component', () => {
it('normalizes edge nodes', () => {
factory('/');
- const output = vm.vm.normalizeData('blobs', [{ node: '1' }, { node: '2' }]);
+ const output = vm.vm.normalizeData('blobs', { nodes: ['1', '2'] });
expect(output).toEqual(['1', '2']);
});
@@ -168,7 +169,7 @@ describe('Repository table component', () => {
vm.vm.fetchFiles();
expect($apollo.query).toHaveBeenCalledWith({
- query: filesQuery,
+ query: paginatedTreeQuery,
variables: {
pageSize,
nextPageCursor: '',
diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
index c1596711be7..3292f635f6b 100644
--- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
+++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
@@ -2,6 +2,7 @@ import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility';
@@ -14,16 +15,20 @@ import RunnerPagination from '~/runner/components/runner_pagination.vue';
import RunnerTypeHelp from '~/runner/components/runner_type_help.vue';
import {
+ ADMIN_FILTERED_SEARCH_NAMESPACE,
CREATED_ASC,
CREATED_DESC,
DEFAULT_SORT,
INSTANCE_TYPE,
PARAM_KEY_STATUS,
+ PARAM_KEY_RUNNER_TYPE,
+ PARAM_KEY_TAG,
STATUS_ACTIVE,
RUNNER_PAGE_SIZE,
} from '~/runner/constants';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
import { captureException } from '~/runner/sentry_utils';
+import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { runnersData, runnersDataPaginated } from '../mock_data';
@@ -47,10 +52,14 @@ describe('AdminRunnersApp', () => {
const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp);
const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp);
const findRunnerList = () => wrapper.findComponent(RunnerList);
- const findRunnerPagination = () => wrapper.findComponent(RunnerPagination);
+ const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
+ const findRunnerPaginationPrev = () =>
+ findRunnerPagination().findByLabelText('Go to previous page');
+ const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page');
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
+ const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
- const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => {
+ const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => {
const handlers = [[getRunnersQuery, mockRunnersQuery]];
wrapper = mountFn(AdminRunnersApp, {
@@ -68,7 +77,7 @@ describe('AdminRunnersApp', () => {
setWindowLocation('/admin/runners');
mockRunnersQuery = jest.fn().mockResolvedValue(runnersData);
- createComponentWithApollo();
+ createComponent();
await waitForPromises();
});
@@ -77,8 +86,16 @@ describe('AdminRunnersApp', () => {
wrapper.destroy();
});
+ it('shows the runner type help', () => {
+ expect(findRunnerTypeHelp().exists()).toBe(true);
+ });
+
+ it('shows the runner setup instructions', () => {
+ expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken);
+ });
+
it('shows the runners list', () => {
- expect(runnersData.data.runners.nodes).toMatchObject(findRunnerList().props('runners'));
+ expect(findRunnerList().props('runners')).toEqual(runnersData.data.runners.nodes);
});
it('requests the runners with no filters', () => {
@@ -90,20 +107,38 @@ describe('AdminRunnersApp', () => {
});
});
- it('shows the runner type help', () => {
- expect(findRunnerTypeHelp().exists()).toBe(true);
+ it('sets tokens in the filtered search', () => {
+ createComponent({ mountFn: mount });
+
+ expect(findFilteredSearch().props('tokens')).toEqual([
+ expect.objectContaining({
+ type: PARAM_KEY_STATUS,
+ options: expect.any(Array),
+ }),
+ expect.objectContaining({
+ type: PARAM_KEY_RUNNER_TYPE,
+ options: expect.any(Array),
+ }),
+ expect.objectContaining({
+ type: PARAM_KEY_TAG,
+ recentTokenValuesStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`,
+ }),
+ ]);
});
- it('shows the runner setup instructions', () => {
- expect(findRunnerManualSetupHelp().exists()).toBe(true);
- expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken);
+ it('shows the active runner count', () => {
+ createComponent({ mountFn: mount });
+
+ expect(findRunnerFilteredSearchBar().text()).toMatch(
+ `Runners currently online: ${mockActiveRunnersCount}`,
+ );
});
describe('when a filter is preselected', () => {
beforeEach(async () => {
setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`);
- createComponentWithApollo();
+ createComponent();
await waitForPromises();
});
@@ -133,7 +168,7 @@ describe('AdminRunnersApp', () => {
describe('when a filter is selected by the user', () => {
beforeEach(() => {
findRunnerFilteredSearchBar().vm.$emit('input', {
- filters: [{ type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }],
+ filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }],
sort: CREATED_ASC,
});
});
@@ -154,11 +189,19 @@ describe('AdminRunnersApp', () => {
});
});
+ it('when runners have not loaded, shows a loading state', () => {
+ createComponent();
+ expect(findRunnerList().props('loading')).toBe(true);
+ });
+
describe('when no runners are found', () => {
beforeEach(async () => {
- mockRunnersQuery = jest.fn().mockResolvedValue({ data: { runners: { nodes: [] } } });
- createComponentWithApollo();
- await waitForPromises();
+ mockRunnersQuery = jest.fn().mockResolvedValue({
+ data: {
+ runners: { nodes: [] },
+ },
+ });
+ createComponent();
});
it('shows a message for no results', async () => {
@@ -166,17 +209,14 @@ describe('AdminRunnersApp', () => {
});
});
- it('when runners have not loaded, shows a loading state', () => {
- createComponentWithApollo();
- expect(findRunnerList().props('loading')).toBe(true);
- });
-
describe('when runners query fails', () => {
- beforeEach(async () => {
+ beforeEach(() => {
mockRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!'));
- createComponentWithApollo();
+ createComponent();
+ });
- await waitForPromises();
+ it('error is shown to the user', async () => {
+ expect(createFlash).toHaveBeenCalledTimes(1);
});
it('error is reported to sentry', async () => {
@@ -185,17 +225,13 @@ describe('AdminRunnersApp', () => {
component: 'AdminRunnersApp',
});
});
-
- it('error is shown to the user', async () => {
- expect(createFlash).toHaveBeenCalledTimes(1);
- });
});
describe('Pagination', () => {
beforeEach(() => {
mockRunnersQuery = jest.fn().mockResolvedValue(runnersDataPaginated);
- createComponentWithApollo({ mountFn: mount });
+ createComponent({ mountFn: mount });
});
it('more pages can be selected', () => {
@@ -203,14 +239,11 @@ describe('AdminRunnersApp', () => {
});
it('cannot navigate to the previous page', () => {
- expect(findRunnerPagination().find('[aria-disabled]').text()).toBe('Prev');
+ expect(findRunnerPaginationPrev().attributes('aria-disabled')).toBe('true');
});
it('navigates to the next page', async () => {
- const nextPageBtn = findRunnerPagination().find('a');
- expect(nextPageBtn.text()).toBe('Next');
-
- await nextPageBtn.trigger('click');
+ await findRunnerPaginationNext().trigger('click');
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
sort: CREATED_DESC,
diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js
index 85cf7ea92df..46948af1f28 100644
--- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js
+++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js
@@ -2,8 +2,16 @@ import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
+import { statusTokenConfig } from '~/runner/components/search_tokens/status_token_config';
import TagToken from '~/runner/components/search_tokens/tag_token.vue';
-import { PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG } from '~/runner/constants';
+import { tagTokenConfig } from '~/runner/components/search_tokens/tag_token_config';
+import { typeTokenConfig } from '~/runner/components/search_tokens/type_token_config';
+import {
+ PARAM_KEY_STATUS,
+ PARAM_KEY_RUNNER_TYPE,
+ PARAM_KEY_TAG,
+ STATUS_ACTIVE,
+} from '~/runner/constants';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
@@ -13,12 +21,12 @@ describe('RunnerList', () => {
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch);
const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem);
- const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message');
+ const findActiveRunnersMessage = () => wrapper.findByTestId('runner-count');
const mockDefaultSort = 'CREATED_DESC';
const mockOtherSort = 'CONTACTED_DESC';
const mockFilters = [
- { type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } },
+ { type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } },
{ type: 'filtered-search-term', value: { data: '' } },
];
const mockActiveRunnersCount = 2;
@@ -28,13 +36,16 @@ describe('RunnerList', () => {
shallowMount(RunnerFilteredSearchBar, {
propsData: {
namespace: 'runners',
+ tokens: [],
value: {
filters: [],
sort: mockDefaultSort,
},
- activeRunnersCount: mockActiveRunnersCount,
...props,
},
+ slots: {
+ 'runner-count': `Runners currently online: ${mockActiveRunnersCount}`,
+ },
stubs: {
FilteredSearch,
GlFilteredSearch,
@@ -64,12 +75,6 @@ describe('RunnerList', () => {
);
});
- it('Displays a large active runner count', () => {
- createComponent({ props: { activeRunnersCount: 2000 } });
-
- expect(findActiveRunnersMessage().text()).toBe('Runners currently online: 2,000');
- });
-
it('sets sorting options', () => {
const SORT_OPTIONS_COUNT = 2;
@@ -78,7 +83,13 @@ describe('RunnerList', () => {
expect(findSortOptions().at(1).text()).toBe('Last contact');
});
- it('sets tokens', () => {
+ it('sets tokens to the filtered search', () => {
+ createComponent({
+ props: {
+ tokens: [statusTokenConfig, typeTokenConfig, tagTokenConfig],
+ },
+ });
+
expect(findFilteredSearch().props('tokens')).toEqual([
expect.objectContaining({
type: PARAM_KEY_STATUS,
diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js
index 5fff3581e39..344d1e5c150 100644
--- a/spec/frontend/runner/components/runner_list_spec.js
+++ b/spec/frontend/runner/components/runner_list_spec.js
@@ -56,7 +56,7 @@ describe('RunnerList', () => {
});
it('Displays a list of runners', () => {
- expect(findRows()).toHaveLength(3);
+ expect(findRows()).toHaveLength(4);
expect(findSkeletonLoader().exists()).toBe(false);
});
diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js
index 15029d7a911..0e0844a785b 100644
--- a/spec/frontend/runner/components/runner_update_form_spec.js
+++ b/spec/frontend/runner/components/runner_update_form_spec.js
@@ -54,7 +54,7 @@ describe('RunnerUpdateForm', () => {
? ACCESS_LEVEL_REF_PROTECTED
: ACCESS_LEVEL_NOT_PROTECTED,
runUntagged: findRunUntaggedCheckbox().element.checked,
- locked: findLockedCheckbox().element.checked,
+ locked: findLockedCheckbox().element?.checked || false,
ipAddress: findIpInput().element.value,
maximumTimeout: findMaxJobTimeoutInput().element.value || null,
tagList: findTagsInput().element.value.split(',').filter(Boolean),
@@ -153,15 +153,15 @@ describe('RunnerUpdateForm', () => {
});
it.each`
- runnerType | attrDisabled | outcome
- ${INSTANCE_TYPE} | ${'disabled'} | ${'disabled'}
- ${GROUP_TYPE} | ${'disabled'} | ${'disabled'}
- ${PROJECT_TYPE} | ${undefined} | ${'enabled'}
- `(`When runner is $runnerType, locked field is $outcome`, ({ runnerType, attrDisabled }) => {
+ runnerType | exists | outcome
+ ${INSTANCE_TYPE} | ${false} | ${'hidden'}
+ ${GROUP_TYPE} | ${false} | ${'hidden'}
+ ${PROJECT_TYPE} | ${true} | ${'shown'}
+ `(`When runner is $runnerType, locked field is $outcome`, ({ runnerType, exists }) => {
const runner = { ...mockRunner, runnerType };
createComponent({ props: { runner } });
- expect(findLockedCheckbox().attributes('disabled')).toBe(attrDisabled);
+ expect(findLockedCheckbox().exists()).toBe(exists);
});
describe('On submit, runner gets updated', () => {
diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js
index 6a0863e92b4..e80da40e3bd 100644
--- a/spec/frontend/runner/group_runners/group_runners_app_spec.js
+++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js
@@ -1,26 +1,85 @@
-import { shallowMount } from '@vue/test-utils';
+import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+import { updateHistory } from '~/lib/utils/url_utility';
+
+import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
+import RunnerList from '~/runner/components/runner_list.vue';
import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
+import RunnerPagination from '~/runner/components/runner_pagination.vue';
import RunnerTypeHelp from '~/runner/components/runner_type_help.vue';
+
+import {
+ CREATED_ASC,
+ CREATED_DESC,
+ DEFAULT_SORT,
+ INSTANCE_TYPE,
+ PARAM_KEY_STATUS,
+ PARAM_KEY_RUNNER_TYPE,
+ STATUS_ACTIVE,
+ RUNNER_PAGE_SIZE,
+} from '~/runner/constants';
+import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql';
import GroupRunnersApp from '~/runner/group_runners/group_runners_app.vue';
+import { captureException } from '~/runner/sentry_utils';
+import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import { groupRunnersData, groupRunnersDataPaginated } from '../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+const mockGroupFullPath = 'group1';
const mockRegistrationToken = 'AABBCC';
+const mockRunners = groupRunnersData.data.group.runners.nodes;
+const mockGroupRunnersLimitedCount = mockRunners.length;
+
+jest.mock('~/flash');
+jest.mock('~/runner/sentry_utils');
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ updateHistory: jest.fn(),
+}));
describe('GroupRunnersApp', () => {
let wrapper;
+ let mockGroupRunnersQuery;
const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp);
const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp);
+ const findRunnerList = () => wrapper.findComponent(RunnerList);
+ const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
+ const findRunnerPaginationPrev = () =>
+ findRunnerPagination().findByLabelText('Go to previous page');
+ const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page');
+ const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
+ const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
+
+ const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => {
+ const handlers = [[getGroupRunnersQuery, mockGroupRunnersQuery]];
- const createComponent = ({ mountFn = shallowMount } = {}) => {
wrapper = mountFn(GroupRunnersApp, {
+ localVue,
+ apolloProvider: createMockApollo(handlers),
propsData: {
registrationToken: mockRegistrationToken,
+ groupFullPath: mockGroupFullPath,
+ groupRunnersLimitedCount: mockGroupRunnersLimitedCount,
+ ...props,
},
});
};
- beforeEach(() => {
+ beforeEach(async () => {
+ setWindowLocation(`/groups/${mockGroupFullPath}/-/runners`);
+
+ mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersData);
+
createComponent();
+ await waitForPromises();
});
it('shows the runner type help', () => {
@@ -28,7 +87,179 @@ describe('GroupRunnersApp', () => {
});
it('shows the runner setup instructions', () => {
- expect(findRunnerManualSetupHelp().exists()).toBe(true);
expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken);
});
+
+ it('shows the runners list', () => {
+ expect(findRunnerList().props('runners')).toEqual(groupRunnersData.data.group.runners.nodes);
+ });
+
+ it('requests the runners with group path and no other filters', () => {
+ expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
+ groupFullPath: mockGroupFullPath,
+ status: undefined,
+ type: undefined,
+ sort: DEFAULT_SORT,
+ first: RUNNER_PAGE_SIZE,
+ });
+ });
+
+ it('sets tokens in the filtered search', () => {
+ createComponent({ mountFn: mount });
+
+ expect(findFilteredSearch().props('tokens')).toEqual([
+ expect.objectContaining({
+ type: PARAM_KEY_STATUS,
+ options: expect.any(Array),
+ }),
+ expect.objectContaining({
+ type: PARAM_KEY_RUNNER_TYPE,
+ options: expect.any(Array),
+ }),
+ ]);
+ });
+
+ describe('shows the active runner count', () => {
+ it('with a regular value', () => {
+ createComponent({ mountFn: mount });
+
+ expect(findRunnerFilteredSearchBar().text()).toMatch(
+ `Runners in this group: ${mockGroupRunnersLimitedCount}`,
+ );
+ });
+
+ it('at the limit', () => {
+ createComponent({ props: { groupRunnersLimitedCount: 1000 }, mountFn: mount });
+
+ expect(findRunnerFilteredSearchBar().text()).toMatch(`Runners in this group: 1,000`);
+ });
+
+ it('over the limit', () => {
+ createComponent({ props: { groupRunnersLimitedCount: 1001 }, mountFn: mount });
+
+ expect(findRunnerFilteredSearchBar().text()).toMatch(`Runners in this group: 1,000+`);
+ });
+ });
+
+ describe('when a filter is preselected', () => {
+ beforeEach(async () => {
+ setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`);
+
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('sets the filters in the search bar', () => {
+ expect(findRunnerFilteredSearchBar().props('value')).toEqual({
+ filters: [
+ { type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } },
+ { type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } },
+ ],
+ sort: 'CREATED_DESC',
+ pagination: { page: 1 },
+ });
+ });
+
+ it('requests the runners with filter parameters', () => {
+ expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
+ groupFullPath: mockGroupFullPath,
+ status: STATUS_ACTIVE,
+ type: INSTANCE_TYPE,
+ sort: DEFAULT_SORT,
+ first: RUNNER_PAGE_SIZE,
+ });
+ });
+ });
+
+ describe('when a filter is selected by the user', () => {
+ beforeEach(() => {
+ findRunnerFilteredSearchBar().vm.$emit('input', {
+ filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }],
+ sort: CREATED_ASC,
+ });
+ });
+
+ it('updates the browser url', () => {
+ expect(updateHistory).toHaveBeenLastCalledWith({
+ title: expect.any(String),
+ url: 'http://test.host/groups/group1/-/runners?status[]=ACTIVE&sort=CREATED_ASC',
+ });
+ });
+
+ it('requests the runners with filters', () => {
+ expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
+ groupFullPath: mockGroupFullPath,
+ status: STATUS_ACTIVE,
+ sort: CREATED_ASC,
+ first: RUNNER_PAGE_SIZE,
+ });
+ });
+ });
+
+ it('when runners have not loaded, shows a loading state', () => {
+ createComponent();
+ expect(findRunnerList().props('loading')).toBe(true);
+ });
+
+ describe('when no runners are found', () => {
+ beforeEach(async () => {
+ mockGroupRunnersQuery = jest.fn().mockResolvedValue({
+ data: {
+ group: {
+ runners: { nodes: [] },
+ },
+ },
+ });
+ createComponent();
+ });
+
+ it('shows a message for no results', async () => {
+ expect(wrapper.text()).toContain('No runners found');
+ });
+ });
+
+ describe('when runners query fails', () => {
+ beforeEach(() => {
+ mockGroupRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!'));
+ createComponent();
+ });
+
+ it('error is shown to the user', async () => {
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ });
+
+ it('error is reported to sentry', async () => {
+ expect(captureException).toHaveBeenCalledWith({
+ error: new Error('Network error: Error!'),
+ component: 'GroupRunnersApp',
+ });
+ });
+ });
+
+ describe('Pagination', () => {
+ beforeEach(() => {
+ mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersDataPaginated);
+
+ createComponent({ mountFn: mount });
+ });
+
+ it('more pages can be selected', () => {
+ expect(findRunnerPagination().text()).toMatchInterpolatedText('Prev Next');
+ });
+
+ it('cannot navigate to the previous page', () => {
+ expect(findRunnerPaginationPrev().attributes('aria-disabled')).toBe('true');
+ });
+
+ it('navigates to the next page', async () => {
+ await findRunnerPaginationNext().trigger('click');
+
+ expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
+ groupFullPath: mockGroupFullPath,
+ sort: CREATED_DESC,
+ first: RUNNER_PAGE_SIZE,
+ after: groupRunnersDataPaginated.data.group.runners.pageInfo.endCursor,
+ });
+ });
+ });
});
diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js
index 8f551feca6e..c90b9a4c426 100644
--- a/spec/frontend/runner/mock_data.js
+++ b/spec/frontend/runner/mock_data.js
@@ -1,6 +1,14 @@
+const runnerFixture = (filename) => getJSONFixture(`graphql/runner/${filename}`);
+
// Fixtures generated by: spec/frontend/fixtures/runner.rb
-export const runnersData = getJSONFixture('graphql/runner/get_runners.query.graphql.json');
-export const runnersDataPaginated = getJSONFixture(
- 'graphql/runner/get_runners.query.graphql.paginated.json',
+
+// Admin queries
+export const runnersData = runnerFixture('get_runners.query.graphql.json');
+export const runnersDataPaginated = runnerFixture('get_runners.query.graphql.paginated.json');
+export const runnerData = runnerFixture('get_runner.query.graphql.json');
+
+// Group queries
+export const groupRunnersData = runnerFixture('get_group_runners.query.graphql.json');
+export const groupRunnersDataPaginated = runnerFixture(
+ 'get_group_runners.query.graphql.paginated.json',
);
-export const runnerData = getJSONFixture('graphql/runner/get_runner.query.graphql.json');
diff --git a/spec/frontend/search/highlight_blob_search_result_spec.js b/spec/frontend/search/highlight_blob_search_result_spec.js
index 6908bcbd283..9fa3bfc1f9a 100644
--- a/spec/frontend/search/highlight_blob_search_result_spec.js
+++ b/spec/frontend/search/highlight_blob_search_result_spec.js
@@ -9,6 +9,6 @@ describe('search/highlight_blob_search_result', () => {
it('highlights lines with search term occurrence', () => {
setHighlightClass(searchKeyword);
- expect(document.querySelectorAll('.blob-result .hll').length).toBe(4);
+ expect(document.querySelectorAll('.js-blob-result .hll').length).toBe(4);
});
});
diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js
index 9f8c83f2873..b50248bb295 100644
--- a/spec/frontend/search/store/actions_spec.js
+++ b/spec/frontend/search/store/actions_spec.js
@@ -142,7 +142,13 @@ describe('Global Search Store Actions', () => {
actions.fetchProjects({ commit: mockCommit, state });
expect(Api.groupProjects).not.toHaveBeenCalled();
- expect(Api.projects).toHaveBeenCalled();
+ expect(Api.projects).toHaveBeenCalledWith(
+ state.query.search,
+ {
+ order_by: 'similarity',
+ },
+ expect.any(Function),
+ );
});
});
});
diff --git a/spec/frontend/search/store/utils_spec.js b/spec/frontend/search/store/utils_spec.js
index cd7f7dc3b5f..bcdad9f89dd 100644
--- a/spec/frontend/search/store/utils_spec.js
+++ b/spec/frontend/search/store/utils_spec.js
@@ -14,7 +14,7 @@ const CURRENT_TIME = new Date().getTime();
useLocalStorageSpy();
jest.mock('~/lib/utils/accessor', () => ({
- isLocalStorageAccessSafe: jest.fn().mockReturnValue(true),
+ canUseLocalStorage: jest.fn().mockReturnValue(true),
}));
describe('Global Search Store Utils', () => {
diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js
index fc5eeee9687..455db325066 100644
--- a/spec/frontend/shortcuts_spec.js
+++ b/spec/frontend/shortcuts_spec.js
@@ -70,8 +70,7 @@ describe('Shortcuts', () => {
const mdShortcuts = $(this).data('md-shortcuts');
// jQuery.map() automatically unwraps arrays, so we
- // have to double wrap the array to counteract this:
- // https://stackoverflow.com/a/4875669/1063392
+ // have to double wrap the array to counteract this
return mdShortcuts ? [mdShortcuts] : undefined;
})
.get();
diff --git a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js
index 8504684d23a..39f63b2a9f4 100644
--- a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js
+++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js
@@ -206,7 +206,7 @@ describe('Sidebar assignees widget', () => {
status: null,
},
],
- id: 1,
+ id: 'gid://gitlab/Issue/1',
},
],
]);
diff --git a/spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js b/spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js
index 57b9a10b23e..859e63b3df6 100644
--- a/spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js
+++ b/spec/frontend/sidebar/components/participants/sidebar_participants_widget_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -45,6 +45,14 @@ describe('Sidebar Participants Widget', () => {
expect(findParticipants().props('loading')).toBe(true);
});
+ it('emits toggleSidebar event when participants child component emits toggleSidebar', async () => {
+ createComponent();
+ findParticipants().vm.$emit('toggleSidebar');
+
+ await nextTick();
+ expect(wrapper.emitted('toggleSidebar')).toEqual([[]]);
+ });
+
describe('when participants are loaded', () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js
index ab08a1e65e2..7455f684380 100644
--- a/spec/frontend/sidebar/sidebar_labels_spec.js
+++ b/spec/frontend/sidebar/sidebar_labels_spec.js
@@ -156,7 +156,7 @@ describe('sidebar labels', () => {
variables: {
input: {
iid: defaultProps.iid,
- labelIds: [toLabelGid(27), toLabelGid(28), toLabelGid(29), toLabelGid(40)],
+ labelIds: [toLabelGid(29), toLabelGid(28), toLabelGid(27), toLabelGid(40)],
operationMode: MutationOperationMode.Replace,
projectPath: defaultProps.projectPath,
},
diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js
index 019ded87093..cb84c142d55 100644
--- a/spec/frontend/sidebar/sidebar_mediator_spec.js
+++ b/spec/frontend/sidebar/sidebar_mediator_spec.js
@@ -63,8 +63,6 @@ describe('Sidebar mediator', () => {
expect(mediator.store.assignees).toEqual(mockData.assignees);
expect(mediator.store.humanTimeEstimate).toEqual(mockData.human_time_estimate);
expect(mediator.store.humanTotalTimeSpent).toEqual(mockData.human_total_time_spent);
- expect(mediator.store.participants).toEqual(mockData.participants);
- expect(mediator.store.subscribed).toEqual(mockData.subscribed);
expect(mediator.store.timeEstimate).toEqual(mockData.time_estimate);
expect(mediator.store.totalTimeSpent).toEqual(mockData.total_time_spent);
});
@@ -117,19 +115,4 @@ describe('Sidebar mediator', () => {
urlSpy.mockRestore();
});
});
-
- it('toggle subscription', () => {
- mediator.store.setSubscribedState(false);
- mock.onPost(mediatorMockData.toggleSubscriptionEndpoint).reply(200, {});
- const spy = jest
- .spyOn(mediator.service, 'toggleSubscription')
- .mockReturnValue(Promise.resolve());
-
- return mediator.toggleSubscription().then(() => {
- expect(spy).toHaveBeenCalled();
- expect(mediator.store.subscribed).toEqual(true);
-
- spy.mockRestore();
- });
- });
});
diff --git a/spec/frontend/sidebar/sidebar_store_spec.js b/spec/frontend/sidebar/sidebar_store_spec.js
index 7b73dc868b7..3930dabfcfa 100644
--- a/spec/frontend/sidebar/sidebar_store_spec.js
+++ b/spec/frontend/sidebar/sidebar_store_spec.js
@@ -16,17 +16,6 @@ const ANOTHER_ASSINEE = {
avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
};
-const PARTICIPANT = {
- id: 1,
- state: 'active',
- username: 'marcene',
- name: 'Allie Will',
- web_url: 'foo.com',
- avatar_url: 'gravatar.com/avatar/xxx',
-};
-
-const PARTICIPANT_LIST = [PARTICIPANT, { ...PARTICIPANT, id: 2 }, { ...PARTICIPANT, id: 3 }];
-
describe('Sidebar store', () => {
let testContext;
@@ -113,28 +102,6 @@ describe('Sidebar store', () => {
expect(testContext.store.changing).toBe(true);
});
- it('sets participants data', () => {
- expect(testContext.store.participants.length).toEqual(0);
-
- testContext.store.setParticipantsData({
- participants: PARTICIPANT_LIST,
- });
-
- expect(testContext.store.isFetching.participants).toEqual(false);
- expect(testContext.store.participants.length).toEqual(PARTICIPANT_LIST.length);
- });
-
- it('sets subcriptions data', () => {
- expect(testContext.store.subscribed).toEqual(null);
-
- testContext.store.setSubscriptionsData({
- subscribed: true,
- });
-
- expect(testContext.store.isFetching.subscriptions).toEqual(false);
- expect(testContext.store.subscribed).toEqual(true);
- });
-
it('set assigned data', () => {
const users = {
assignees: UsersMockHelper.createNumberRandomUsers(3),
@@ -147,11 +114,11 @@ describe('Sidebar store', () => {
});
it('sets fetching state', () => {
- expect(testContext.store.isFetching.participants).toEqual(true);
+ expect(testContext.store.isFetching.assignees).toEqual(true);
- testContext.store.setFetchingState('participants', false);
+ testContext.store.setFetchingState('assignees', false);
- expect(testContext.store.isFetching.participants).toEqual(false);
+ expect(testContext.store.isFetching.assignees).toEqual(false);
});
it('sets loading state', () => {
diff --git a/spec/frontend/sidebar/track_invite_members_spec.js b/spec/frontend/sidebar/track_invite_members_spec.js
index 6c96e4cfc76..5946e3320c4 100644
--- a/spec/frontend/sidebar/track_invite_members_spec.js
+++ b/spec/frontend/sidebar/track_invite_members_spec.js
@@ -10,7 +10,7 @@ describe('Track user dropdown open', () => {
document.body.innerHTML = `
<div id="dummy-wrapper-element">
<div class="js-sidebar-assignee-dropdown">
- <div class="js-invite-members-track" data-track-event="_track_event_" data-track-label="_track_label_">
+ <div class="js-invite-members-track" data-track-action="_track_event_" data-track-label="_track_label_">
</div>
</div>
</div>
diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap
index 22e206bb483..40bc6fe6aa5 100644
--- a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap
+++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap
@@ -28,6 +28,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] =
data-uploads-path=""
>
<markdown-header-stub
+ data-testid="markdownHeader"
linecontent=""
suggestionstartindex="0"
/>
diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js
index a17efdd61a9..21fed51ff10 100644
--- a/spec/frontend/tracking_spec.js
+++ b/spec/frontend/tracking_spec.js
@@ -1,10 +1,15 @@
import { setHTMLFixture } from 'helpers/fixtures';
+import { TEST_HOST } from 'helpers/test_constants';
import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
-import { getExperimentData } from '~/experimentation/utils';
+import { getExperimentData, getAllExperimentContexts } from '~/experimentation/utils';
import Tracking, { initUserTracking, initDefaultTrackers } from '~/tracking';
+import { REFERRER_TTL, URLS_CACHE_STORAGE_KEY } from '~/tracking/constants';
import getStandardContext from '~/tracking/get_standard_context';
-jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() }));
+jest.mock('~/experimentation/utils', () => ({
+ getExperimentData: jest.fn(),
+ getAllExperimentContexts: jest.fn(),
+}));
describe('Tracking', () => {
let standardContext;
@@ -12,9 +17,11 @@ describe('Tracking', () => {
let bindDocumentSpy;
let trackLoadEventsSpy;
let enableFormTracking;
+ let setAnonymousUrlsSpy;
beforeAll(() => {
window.gl = window.gl || {};
+ window.gl.snowplowUrls = {};
window.gl.snowplowStandardContext = {
schema: 'iglu:com.gitlab/gitlab_standard',
data: {
@@ -29,6 +36,7 @@ describe('Tracking', () => {
beforeEach(() => {
getExperimentData.mockReturnValue(undefined);
+ getAllExperimentContexts.mockReturnValue([]);
window.snowplow = window.snowplow || (() => {});
window.snowplowOptions = {
@@ -70,6 +78,7 @@ describe('Tracking', () => {
enableFormTracking = jest
.spyOn(Tracking, 'enableFormTracking')
.mockImplementation(() => null);
+ setAnonymousUrlsSpy = jest.spyOn(Tracking, 'setAnonymousUrls').mockImplementation(() => null);
});
it('should activate features based on what has been enabled', () => {
@@ -100,6 +109,36 @@ describe('Tracking', () => {
initDefaultTrackers();
expect(trackLoadEventsSpy).toHaveBeenCalled();
});
+
+ it('calls the anonymized URLs method', () => {
+ initDefaultTrackers();
+ expect(setAnonymousUrlsSpy).toHaveBeenCalled();
+ });
+
+ describe('when there are experiment contexts', () => {
+ const experimentContexts = [
+ {
+ schema: TRACKING_CONTEXT_SCHEMA,
+ data: { experiment: 'experiment1', variant: 'control' },
+ },
+ {
+ schema: TRACKING_CONTEXT_SCHEMA,
+ data: { experiment: 'experiment_two', variant: 'candidate' },
+ },
+ ];
+
+ beforeEach(() => {
+ getAllExperimentContexts.mockReturnValue(experimentContexts);
+ });
+
+ it('includes those contexts alongside the standard context', () => {
+ initDefaultTrackers();
+ expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [
+ standardContext,
+ ...experimentContexts,
+ ]);
+ });
+ });
});
describe('.event', () => {
@@ -266,6 +305,110 @@ describe('Tracking', () => {
});
});
+ describe('.setAnonymousUrls', () => {
+ afterEach(() => {
+ window.gl.snowplowPseudonymizedPageUrl = '';
+ localStorage.removeItem(URLS_CACHE_STORAGE_KEY);
+ });
+
+ it('does nothing if URLs are not provided', () => {
+ Tracking.setAnonymousUrls();
+
+ expect(snowplowSpy).not.toHaveBeenCalled();
+ expect(localStorage.getItem(URLS_CACHE_STORAGE_KEY)).toBe(null);
+ });
+
+ it('sets the page URL when provided and populates the cache', () => {
+ window.gl.snowplowPseudonymizedPageUrl = TEST_HOST;
+
+ Tracking.setAnonymousUrls();
+
+ expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', TEST_HOST);
+ expect(JSON.parse(localStorage.getItem(URLS_CACHE_STORAGE_KEY))[0]).toStrictEqual({
+ url: TEST_HOST,
+ referrer: '',
+ originalUrl: window.location.href,
+ timestamp: Date.now(),
+ });
+ });
+
+ it('appends the hash/fragment to the pseudonymized URL', () => {
+ const hash = 'first-heading';
+ window.gl.snowplowPseudonymizedPageUrl = TEST_HOST;
+ window.location.hash = hash;
+
+ Tracking.setAnonymousUrls();
+
+ expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', `${TEST_HOST}#${hash}`);
+ });
+
+ it('does not set the referrer URL by default', () => {
+ window.gl.snowplowPseudonymizedPageUrl = TEST_HOST;
+
+ Tracking.setAnonymousUrls();
+
+ expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', expect.any(String));
+ });
+
+ describe('with referrers cache', () => {
+ const testUrl = '/namespace:1/project:2/-/merge_requests/5';
+ const testOriginalUrl = '/my-namespace/my-project/-/merge_requests/';
+ const setUrlsCache = (data) =>
+ localStorage.setItem(URLS_CACHE_STORAGE_KEY, JSON.stringify(data));
+
+ beforeEach(() => {
+ window.gl.snowplowPseudonymizedPageUrl = TEST_HOST;
+ Object.defineProperty(document, 'referrer', { value: '', configurable: true });
+ });
+
+ it('does nothing if a referrer can not be found', () => {
+ setUrlsCache([
+ {
+ url: testUrl,
+ originalUrl: TEST_HOST,
+ timestamp: Date.now(),
+ },
+ ]);
+
+ Tracking.setAnonymousUrls();
+
+ expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', expect.any(String));
+ });
+
+ it('sets referrer URL from the page URL found in cache', () => {
+ Object.defineProperty(document, 'referrer', { value: testOriginalUrl });
+ setUrlsCache([
+ {
+ url: testUrl,
+ originalUrl: testOriginalUrl,
+ timestamp: Date.now(),
+ },
+ ]);
+
+ Tracking.setAnonymousUrls();
+
+ expect(snowplowSpy).toHaveBeenCalledWith('setReferrerUrl', testUrl);
+ });
+
+ it('ignores and removes old entries from the cache', () => {
+ const oldTimestamp = Date.now() - (REFERRER_TTL + 1);
+ Object.defineProperty(document, 'referrer', { value: testOriginalUrl });
+ setUrlsCache([
+ {
+ url: testUrl,
+ originalUrl: testOriginalUrl,
+ timestamp: oldTimestamp,
+ },
+ ]);
+
+ Tracking.setAnonymousUrls();
+
+ expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', testUrl);
+ expect(localStorage.getItem(URLS_CACHE_STORAGE_KEY)).not.toContain(oldTimestamp);
+ });
+ });
+ });
+
describe.each`
term
${'event'}
@@ -349,7 +492,7 @@ describe('Tracking', () => {
it('includes experiment data if linked to an experiment', () => {
const mockExperimentData = {
variant: 'candidate',
- experiment: 'repo_integrations_link',
+ experiment: 'example',
key: '2bff73f6bb8cc11156c50a8ba66b9b8b',
};
getExperimentData.mockReturnValue(mockExperimentData);
diff --git a/spec/frontend/vue_mr_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap b/spec/frontend/vue_mr_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap
new file mode 100644
index 00000000000..a6c36764c41
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/states/__snapshots__/new_ready_to_merge_spec.js.snap
@@ -0,0 +1,37 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`New ready to merge state component renders permission text if canMerge (false) is false 1`] = `
+<div
+ class="mr-widget-body media"
+>
+ <status-icon-stub
+ status="success"
+ />
+
+ <p
+ class="media-body gl-m-0! gl-font-weight-bold"
+ >
+
+ Ready to merge by members who can write to the target branch.
+
+ </p>
+</div>
+`;
+
+exports[`New ready to merge state component renders permission text if canMerge (true) is false 1`] = `
+<div
+ class="mr-widget-body media"
+>
+ <status-icon-stub
+ status="success"
+ />
+
+ <p
+ class="media-body gl-m-0! gl-font-weight-bold"
+ >
+
+ Ready to merge!
+
+ </p>
+</div>
+`;
diff --git a/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js b/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js
new file mode 100644
index 00000000000..bdad0bada5f
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js
@@ -0,0 +1,49 @@
+import { shallowMount } from '@vue/test-utils';
+import MergeChecksFailed from '~/vue_merge_request_widget/components/states/merge_checks_failed.vue';
+
+let wrapper;
+
+function factory(propsData = {}) {
+ wrapper = shallowMount(MergeChecksFailed, {
+ propsData,
+ });
+}
+
+describe('Merge request widget merge checks failed state component', () => {
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it.each`
+ mrState | displayText
+ ${{ isPipelineFailed: true }} | ${'pipelineFailed'}
+ ${{ approvals: true, isApproved: false }} | ${'approvalNeeded'}
+ ${{ hasMergeableDiscussionsState: true }} | ${'unresolvedDiscussions'}
+ `('display $displayText text for $mrState', ({ mrState, displayText }) => {
+ factory({ mr: mrState });
+
+ expect(wrapper.text()).toContain(MergeChecksFailed.i18n[displayText]);
+ });
+
+ describe('unresolved discussions', () => {
+ it('renders jump to button', () => {
+ factory({ mr: { hasMergeableDiscussionsState: true } });
+
+ expect(wrapper.find('[data-testid="jumpToUnresolved"]').exists()).toBe(true);
+ });
+
+ it('renders resolve thread button', () => {
+ factory({
+ mr: {
+ hasMergeableDiscussionsState: true,
+ createIssueToResolveDiscussionsPath: 'https://gitlab.com',
+ },
+ });
+
+ expect(wrapper.find('[data-testid="resolveIssue"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="resolveIssue"]').attributes('href')).toBe(
+ 'https://gitlab.com',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js
index c6bfca4516f..e2d79c61b9b 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js
@@ -45,7 +45,7 @@ describe('UnresolvedDiscussions', () => {
expect(wrapper.element.innerText).toContain(`Merge blocked: all threads must be resolved.`);
expect(wrapper.element.innerText).toContain('Jump to first unresolved thread');
- expect(wrapper.element.innerText).toContain('Resolve all threads in new issue');
+ expect(wrapper.element.innerText).toContain('Create issue to resolve all threads');
expect(wrapper.element.querySelector('.js-create-issue').getAttribute('href')).toEqual(
TEST_HOST,
);
@@ -57,7 +57,7 @@ describe('UnresolvedDiscussions', () => {
expect(wrapper.element.innerText).toContain(`Merge blocked: all threads must be resolved.`);
expect(wrapper.element.innerText).toContain('Jump to first unresolved thread');
- expect(wrapper.element.innerText).not.toContain('Resolve all threads in new issue');
+ expect(wrapper.element.innerText).not.toContain('Create issue to resolve all threads');
expect(wrapper.element.querySelector('.js-create-issue')).toEqual(null);
});
});
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js
index 0609086997b..61e44140efc 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js
@@ -64,7 +64,7 @@ describe('Wip', () => {
expect(vm.isMakingRequest).toBeTruthy();
expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj);
expect(createFlash).toHaveBeenCalledWith({
- message: 'The merge request can now be merged.',
+ message: 'Marked as ready. Merging is now allowed.',
type: 'notice',
});
done();
diff --git a/spec/frontend/vue_mr_widget/components/states/new_ready_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/new_ready_to_merge_spec.js
new file mode 100644
index 00000000000..5ec9654a4af
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/states/new_ready_to_merge_spec.js
@@ -0,0 +1,31 @@
+import { shallowMount } from '@vue/test-utils';
+import ReadyToMerge from '~/vue_merge_request_widget/components/states/new_ready_to_merge.vue';
+
+let wrapper;
+
+function factory({ canMerge }) {
+ wrapper = shallowMount(ReadyToMerge, {
+ propsData: {
+ mr: {},
+ },
+ data() {
+ return { canMerge };
+ },
+ });
+}
+
+describe('New ready to merge state component', () => {
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it.each`
+ canMerge
+ ${true}
+ ${false}
+ `('renders permission text if canMerge ($canMerge) is false', ({ canMerge }) => {
+ factory({ canMerge });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
index bab928318ce..c7758b0faef 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
@@ -3,9 +3,13 @@
exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
<gl-dropdown-stub
category="primary"
+ clearalltext="Clear all"
headertext=""
hideheaderborder="true"
+ highlighteditemstitle="Selected"
+ highlighteditemstitleclass="gl-px-5"
right="true"
+ showhighlighteditemstitle="true"
size="medium"
text="Clone"
variant="info"
diff --git a/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap
index db174346729..7f655d67ae8 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap
@@ -2,7 +2,7 @@
exports[`Code Block with default props renders correctly 1`] = `
<pre
- class="code-block rounded"
+ class="code-block rounded code"
>
<code
class="d-block"
@@ -14,7 +14,7 @@ exports[`Code Block with default props renders correctly 1`] = `
exports[`Code Block with maxHeight set to "200px" renders correctly 1`] = `
<pre
- class="code-block rounded"
+ class="code-block rounded code"
style="max-height: 200px; overflow-y: auto;"
>
<code
diff --git a/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap
index f4f9cc288f9..87eaabf4e98 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap
@@ -9,7 +9,6 @@ exports[`MemoryGraph Render chart should draw container with chart 1`] = `
data="Nov 12 2019 19:17:33,2.87,Nov 12 2019 19:18:33,2.78,Nov 12 2019 19:19:33,2.78,Nov 12 2019 19:20:33,3.01"
height="25"
tooltiplabel="MB"
- variant="gray900"
/>
</div>
`;
diff --git a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap
index c4f351eb58d..f2ff12b2acd 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap
@@ -3,9 +3,13 @@
exports[`SplitButton renders actionItems 1`] = `
<gl-dropdown-stub
category="primary"
+ clearalltext="Clear all"
headertext=""
hideheaderborder="true"
+ highlighteditemstitle="Selected"
+ highlighteditemstitleclass="gl-px-5"
menu-class=""
+ showhighlighteditemstitle="true"
size="medium"
split="true"
text="professor"
diff --git a/spec/frontend/vue_shared/components/commit_spec.js b/spec/frontend/vue_shared/components/commit_spec.js
index 6a31742141b..d91853e7b79 100644
--- a/spec/frontend/vue_shared/components/commit_spec.js
+++ b/spec/frontend/vue_shared/components/commit_spec.js
@@ -162,8 +162,6 @@ describe('Commit component', () => {
expect(refEl.attributes('href')).toBe(props.commitRef.ref_url);
- expect(refEl.attributes('title')).toBe(props.commitRef.name);
-
expect(findIcon('branch').exists()).toBe(true);
});
});
@@ -195,8 +193,6 @@ describe('Commit component', () => {
expect(refEl.attributes('href')).toBe(props.mergeRequestRef.path);
- expect(refEl.attributes('title')).toBe(props.mergeRequestRef.title);
-
expect(findIcon('git-merge').exists()).toBe(true);
});
});
diff --git a/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js
new file mode 100644
index 00000000000..04f63b4bd45
--- /dev/null
+++ b/spec/frontend/vue_shared/components/diff_stats_dropdown_spec.js
@@ -0,0 +1,176 @@
+import {
+ GlSprintf,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import DiffStatsDropdown, { i18n } from '~/vue_shared/components/diff_stats_dropdown.vue';
+
+jest.mock('fuzzaldrin-plus', () => ({
+ filter: jest.fn().mockReturnValue([]),
+}));
+
+const mockFiles = [
+ {
+ added: 0,
+ href: '#a5cc2925ca8258af241be7e5b0381edf30266302',
+ icon: 'file-modified',
+ iconColor: '',
+ name: '',
+ path: '.gitignore',
+ removed: 3,
+ title: '.gitignore',
+ },
+ {
+ added: 1,
+ href: '#fa288d1472d29beccb489a676f68739ad365fc47',
+ icon: 'file-modified',
+ iconColor: 'danger',
+ name: 'package-lock.json',
+ path: 'lock/file/path',
+ removed: 1,
+ },
+];
+
+describe('Diff Stats Dropdown', () => {
+ let wrapper;
+
+ const createComponent = ({ changed = 0, added = 0, deleted = 0, files = [] } = {}) => {
+ wrapper = shallowMountExtended(DiffStatsDropdown, {
+ propsData: {
+ changed,
+ added,
+ deleted,
+ files,
+ },
+ stubs: {
+ GlSprintf,
+ GlDropdown,
+ },
+ });
+ };
+
+ const findChanged = () => wrapper.findComponent(GlDropdown);
+ const findChangedFiles = () => findChanged().findAllComponents(GlDropdownItem);
+ const findNoFilesText = () => findChanged().findComponent(GlDropdownText);
+ const findCollapsed = () => wrapper.findByTestId('diff-stats-additions-deletions-expanded');
+ const findExpanded = () => wrapper.findByTestId('diff-stats-additions-deletions-collapsed');
+ const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
+
+ describe('file item', () => {
+ beforeEach(() => {
+ createComponent({ files: mockFiles });
+ });
+
+ it('when no file name provided ', () => {
+ expect(findChangedFiles().at(0).text()).toContain(i18n.noFileNameAvailable);
+ });
+
+ it('when all file data is available', () => {
+ const fileData = findChangedFiles().at(1);
+ const fileText = findChangedFiles().at(1).text();
+ expect(fileText).toContain(mockFiles[1].name);
+ expect(fileText).toContain(mockFiles[1].path);
+ expect(fileData.props()).toMatchObject({
+ iconName: mockFiles[1].icon,
+ iconColor: mockFiles[1].iconColor,
+ });
+ });
+
+ it('when no files changed', () => {
+ createComponent({ files: [] });
+ expect(findNoFilesText().text()).toContain(i18n.noFilesFound);
+ });
+ });
+
+ describe.each`
+ changed | added | deleted | expectedDropdownHeader | expectedAddedDeletedExpanded | expectedAddedDeletedCollapsed
+ ${0} | ${0} | ${0} | ${'0 changed files'} | ${'+0 -0'} | ${'with 0 additions and 0 deletions'}
+ ${2} | ${0} | ${2} | ${'2 changed files'} | ${'+0 -2'} | ${'with 0 additions and 2 deletions'}
+ ${2} | ${2} | ${0} | ${'2 changed files'} | ${'+2 -0'} | ${'with 2 additions and 0 deletions'}
+ ${2} | ${1} | ${1} | ${'2 changed files'} | ${'+1 -1'} | ${'with 1 addition and 1 deletion'}
+ ${1} | ${0} | ${1} | ${'1 changed file'} | ${'+0 -1'} | ${'with 0 additions and 1 deletion'}
+ ${1} | ${1} | ${0} | ${'1 changed file'} | ${'+1 -0'} | ${'with 1 addition and 0 deletions'}
+ ${4} | ${2} | ${2} | ${'4 changed files'} | ${'+2 -2'} | ${'with 2 additions and 2 deletions'}
+ `(
+ 'when there are $changed changed file(s), $added added and $deleted deleted file(s)',
+ ({
+ changed,
+ added,
+ deleted,
+ expectedDropdownHeader,
+ expectedAddedDeletedExpanded,
+ expectedAddedDeletedCollapsed,
+ }) => {
+ beforeAll(() => {
+ createComponent({ changed, added, deleted });
+ });
+
+ afterAll(() => {
+ wrapper.destroy();
+ });
+
+ it(`dropdown header should be '${expectedDropdownHeader}'`, () => {
+ expect(findChanged().props('text')).toBe(expectedDropdownHeader);
+ });
+
+ it(`added and deleted count in expanded section should be '${expectedAddedDeletedExpanded}'`, () => {
+ expect(findExpanded().text()).toBe(expectedAddedDeletedExpanded);
+ });
+
+ it(`added and deleted count in collapsed section should be '${expectedAddedDeletedCollapsed}'`, () => {
+ expect(findCollapsed().text()).toBe(expectedAddedDeletedCollapsed);
+ });
+ },
+ );
+
+ describe('fuzzy file search', () => {
+ beforeEach(() => {
+ createComponent({ files: mockFiles });
+ });
+
+ it('should call `fuzzaldrinPlus.filter` to search for files when the search query is NOT empty', async () => {
+ const searchStr = 'file name';
+ findSearchBox().vm.$emit('input', searchStr);
+ await nextTick();
+ expect(fuzzaldrinPlus.filter).toHaveBeenCalledWith(mockFiles, searchStr, { key: 'name' });
+ });
+
+ it('should NOT call `fuzzaldrinPlus.filter` to search for files when the search query is empty', async () => {
+ const searchStr = '';
+ findSearchBox().vm.$emit('input', searchStr);
+ await nextTick();
+ expect(fuzzaldrinPlus.filter).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('selecting file dropdown item', () => {
+ beforeEach(() => {
+ createComponent({ files: mockFiles });
+ });
+
+ it('updates the URL ', () => {
+ findChangedFiles().at(0).vm.$emit('click');
+ expect(window.location.hash).toBe(mockFiles[0].href);
+ findChangedFiles().at(1).vm.$emit('click');
+ expect(window.location.hash).toBe(mockFiles[1].href);
+ });
+ });
+
+ describe('on dropdown open', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should set the search input focus', () => {
+ wrapper.vm.$refs.search.focusInput = jest.fn();
+ findChanged().vm.$emit('shown');
+
+ expect(wrapper.vm.$refs.search.focusInput).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js
index 1b97011bf7f..d85b6e6d115 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js
@@ -25,7 +25,7 @@ import {
const mockStorageKey = 'recent-tokens';
function setLocalStorageAvailability(isAvailable) {
- jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(isAvailable);
+ jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(isAvailable);
}
describe('Filtered Search Utils', () => {
@@ -309,7 +309,7 @@ describe('urlQueryToFilter', () => {
{
[FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }],
},
- { filteredSearchTermKey: 'search', legacySpacesDecode: false },
+ { filteredSearchTermKey: 'search' },
],
[
'search=my terms&foo=bar&nop=xxx',
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
index 529844817d3..bfb593bf82d 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
@@ -11,7 +11,10 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
-import { DEFAULT_MILESTONES } from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ DEFAULT_MILESTONES,
+ DEFAULT_MILESTONES_GRAPHQL,
+} from '~/vue_shared/components/filtered_search_bar/constants';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import { mockMilestoneToken, mockMilestones, mockRegularMilestone } from '../mock_data';
@@ -191,5 +194,22 @@ describe('MilestoneToken', () => {
expect(suggestions.at(index).text()).toBe(milestone.text);
});
});
+
+ describe('when getActiveMilestones is called and milestones is empty', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ active: true,
+ config: { ...mockMilestoneToken, defaultMilestones: DEFAULT_MILESTONES_GRAPHQL },
+ });
+ });
+
+ it('finds the correct value from the activeToken', () => {
+ DEFAULT_MILESTONES_GRAPHQL.forEach(({ value, title }) => {
+ const activeToken = wrapper.vm.getActiveMilestone([], value);
+
+ expect(activeToken.title).toEqual(title);
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/header_ci_component_spec.js b/spec/frontend/vue_shared/components/header_ci_component_spec.js
index b54d120b55b..42f4439df51 100644
--- a/spec/frontend/vue_shared/components/header_ci_component_spec.js
+++ b/spec/frontend/vue_shared/components/header_ci_component_spec.js
@@ -16,8 +16,6 @@ describe('Header CI Component', () => {
text: 'failed',
details_path: 'path',
},
- itemName: 'job',
- itemId: 123,
time: '2017-05-08T14:57:39.781Z',
user: {
web_url: 'path',
@@ -55,17 +53,13 @@ describe('Header CI Component', () => {
describe('render', () => {
beforeEach(() => {
- createComponent();
+ createComponent({ itemName: 'Pipeline' });
});
it('should render status badge', () => {
expect(findIconBadge().exists()).toBe(true);
});
- it('should render item name and id', () => {
- expect(findHeaderItemText().text()).toBe('job #123');
- });
-
it('should render timeago date', () => {
expect(findTimeAgo().exists()).toBe(true);
});
@@ -83,9 +77,29 @@ describe('Header CI Component', () => {
});
});
+ describe('with item id', () => {
+ beforeEach(() => {
+ createComponent({ itemName: 'Pipeline', itemId: '123' });
+ });
+
+ it('should render item name and id', () => {
+ expect(findHeaderItemText().text()).toBe('Pipeline #123');
+ });
+ });
+
+ describe('without item id', () => {
+ beforeEach(() => {
+ createComponent({ itemName: 'Job build_job' });
+ });
+
+ it('should render item name', () => {
+ expect(findHeaderItemText().text()).toBe('Job build_job');
+ });
+ });
+
describe('slot', () => {
it('should render header action buttons', () => {
- createComponent({}, { slots: { default: 'Test Actions' } });
+ createComponent({ itemName: 'Job build_job' }, { slots: { default: 'Test Actions' } });
expect(findActionButtons().exists()).toBe(true);
expect(findActionButtons().text()).toBe('Test Actions');
@@ -94,7 +108,7 @@ describe('Header CI Component', () => {
describe('shouldRenderTriggeredLabel', () => {
it('should render created keyword when the shouldRenderTriggeredLabel is false', () => {
- createComponent({ shouldRenderTriggeredLabel: false });
+ createComponent({ shouldRenderTriggeredLabel: false, itemName: 'Job build_job' });
expect(wrapper.text()).toContain('created');
expect(wrapper.text()).not.toContain('triggered');
diff --git a/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js b/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js
index 573501233b9..ad8331afcff 100644
--- a/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js
+++ b/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js
@@ -1,5 +1,7 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createStore as createMrStore } from '~/mr_notes/stores';
import createIssueStore from '~/notes/stores';
import IssuableHeaderWarnings from '~/vue_shared/components/issuable/issuable_header_warnings.vue';
@@ -12,52 +14,53 @@ localVue.use(Vuex);
describe('IssuableHeaderWarnings', () => {
let wrapper;
- let store;
- const findConfidentialIcon = () => wrapper.find('[data-testid="confidential"]');
- const findLockedIcon = () => wrapper.find('[data-testid="locked"]');
+ const findConfidentialIcon = () => wrapper.findByTestId('confidential');
+ const findLockedIcon = () => wrapper.findByTestId('locked');
+ const findHiddenIcon = () => wrapper.findByTestId('hidden');
const renderTestMessage = (renders) => (renders ? 'renders' : 'does not render');
- const setLock = (locked) => {
- store.getters.getNoteableData.discussion_locked = locked;
- };
-
- const setConfidential = (confidential) => {
- store.getters.getNoteableData.confidential = confidential;
- };
-
- const createComponent = () => {
- wrapper = shallowMount(IssuableHeaderWarnings, { store, localVue });
+ const createComponent = ({ store, provide }) => {
+ wrapper = shallowMountExtended(IssuableHeaderWarnings, {
+ store,
+ localVue,
+ provide,
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
- store = null;
});
describe.each`
issuableType
${ISSUABLE_TYPE_ISSUE} | ${ISSUABLE_TYPE_MR}
`(`when issuableType=$issuableType`, ({ issuableType }) => {
- beforeEach(() => {
- store = issuableType === ISSUABLE_TYPE_ISSUE ? createIssueStore() : createMrStore();
- createComponent();
- });
-
describe.each`
- lockStatus | confidentialStatus
- ${true} | ${true}
- ${true} | ${false}
- ${false} | ${true}
- ${false} | ${false}
+ lockStatus | confidentialStatus | hiddenStatus
+ ${true} | ${true} | ${false}
+ ${true} | ${false} | ${false}
+ ${false} | ${true} | ${false}
+ ${false} | ${false} | ${false}
+ ${true} | ${true} | ${true}
+ ${true} | ${false} | ${true}
+ ${false} | ${true} | ${true}
+ ${false} | ${false} | ${true}
`(
- `when locked=$lockStatus and confidential=$confidentialStatus`,
- ({ lockStatus, confidentialStatus }) => {
+ `when locked=$lockStatus, confidential=$confidentialStatus, and hidden=$hiddenStatus`,
+ ({ lockStatus, confidentialStatus, hiddenStatus }) => {
+ const store = issuableType === ISSUABLE_TYPE_ISSUE ? createIssueStore() : createMrStore();
+
beforeEach(() => {
- setLock(lockStatus);
- setConfidential(confidentialStatus);
+ store.getters.getNoteableData.confidential = confidentialStatus;
+ store.getters.getNoteableData.discussion_locked = lockStatus;
+
+ createComponent({ store, provide: { hidden: hiddenStatus } });
});
it(`${renderTestMessage(lockStatus)} the locked icon`, () => {
@@ -67,6 +70,19 @@ describe('IssuableHeaderWarnings', () => {
it(`${renderTestMessage(confidentialStatus)} the confidential icon`, () => {
expect(findConfidentialIcon().exists()).toBe(confidentialStatus);
});
+
+ it(`${renderTestMessage(confidentialStatus)} the hidden icon`, () => {
+ const hiddenIcon = findHiddenIcon();
+
+ expect(hiddenIcon.exists()).toBe(hiddenStatus);
+
+ if (hiddenStatus) {
+ expect(hiddenIcon.attributes('title')).toBe(
+ 'This issue is hidden because its author has been banned',
+ );
+ expect(getBinding(hiddenIcon.element, 'gl-tooltip')).not.toBeUndefined();
+ }
+ });
},
);
});
diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js
index 442032840e1..76e1a1162ad 100644
--- a/spec/frontend/vue_shared/components/markdown/field_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/field_spec.js
@@ -32,7 +32,7 @@ describe('Markdown field component', () => {
axiosMock.restore();
});
- function createSubject() {
+ function createSubject(lines = []) {
// We actually mount a wrapper component so that we can force Vue to rerender classes in order to test a regression
// caused by mixing Vanilla JS and Vue.
subject = mount(
@@ -60,6 +60,7 @@ describe('Markdown field component', () => {
markdownPreviewPath,
isSubmitting: false,
textareaValue,
+ lines,
},
},
);
@@ -243,4 +244,14 @@ describe('Markdown field component', () => {
});
});
});
+
+ describe('suggestions', () => {
+ it('escapes new line characters', () => {
+ createSubject([{ rich_text: 'hello world\\n' }]);
+
+ expect(subject.find('[data-testid="markdownHeader"]').props('lineContent')).toBe(
+ 'hello world%br',
+ );
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/registry/title_area_spec.js b/spec/frontend/vue_shared/components/registry/title_area_spec.js
index fb0009ebb8d..75aa3bc7096 100644
--- a/spec/frontend/vue_shared/components/registry/title_area_spec.js
+++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js
@@ -135,15 +135,16 @@ describe('title area', () => {
},
});
};
+
it('shows dynamic slots', async () => {
mountComponent();
// we manually add a new slot to simulate dynamic slots being evaluated after the initial mount
wrapper.vm.$slots[DYNAMIC_SLOT] = createDynamicSlot();
+ // updating the slots like we do on line 141 does not cause the updated lifecycle-hook to be triggered
+ wrapper.vm.$forceUpdate();
await wrapper.vm.$nextTick();
- expect(findDynamicSlot().exists()).toBe(false);
- await wrapper.vm.$nextTick();
expect(findDynamicSlot().exists()).toBe(true);
});
@@ -160,10 +161,8 @@ describe('title area', () => {
'metadata-foo': wrapper.vm.$slots['metadata-foo'],
};
- await wrapper.vm.$nextTick();
- expect(findDynamicSlot().exists()).toBe(false);
- expect(findMetadataSlot('metadata-foo').exists()).toBe(true);
-
+ // updating the slots like we do on line 159 does not cause the updated lifecycle-hook to be triggered
+ wrapper.vm.$forceUpdate();
await wrapper.vm.$nextTick();
expect(findSlotOrderElements().at(0).attributes('data-testid')).toBe(DYNAMIC_SLOT);
diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js
index 69db3ec7132..ad692a38e65 100644
--- a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js
+++ b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js
@@ -21,6 +21,7 @@ describe('RunnerAwsDeploymentsModal', () => {
wrapper = shallowMount(RunnerAwsDeploymentsModal, {
propsData: {
modalId: 'runner-aws-deployments-modal',
+ imgSrc: '/assets/aws-cloud-formation.png',
},
});
};
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 ed085fb66dc..165caea2751 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
@@ -8,12 +8,25 @@ exports[`Settings Block renders the correct markup 1`] = `
class="settings-header"
>
<h4>
- <div
- data-testid="title-slot"
- />
+ <span
+ aria-controls="settings_content_3"
+ aria-expanded="false"
+ class="gl-cursor-pointer"
+ data-testid="section-title-button"
+ id="settings_label_2"
+ role="button"
+ tabindex="0"
+ >
+ <div
+ data-testid="title-slot"
+ />
+ </span>
</h4>
<gl-button-stub
+ aria-controls="settings_content_3"
+ aria-expanded="false"
+ aria-label="Expand settings section"
buttontextclasses=""
category="primary"
icon=""
@@ -33,7 +46,11 @@ exports[`Settings Block renders the correct markup 1`] = `
</div>
<div
+ aria-labelledby="settings_label_2"
class="settings-content"
+ id="settings_content_3"
+ role="region"
+ tabindex="-1"
>
<div
data-testid="default-slot"
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 be5a15631eb..528dfd89690 100644
--- a/spec/frontend/vue_shared/components/settings/settings_block_spec.js
+++ b/spec/frontend/vue_shared/components/settings/settings_block_spec.js
@@ -1,12 +1,12 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import component from '~/vue_shared/components/settings/settings_block.vue';
+import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
describe('Settings Block', () => {
let wrapper;
const mountComponent = (propsData) => {
- wrapper = shallowMount(component, {
+ wrapper = shallowMount(SettingsBlock, {
propsData,
slots: {
title: '<div data-testid="title-slot"></div>',
@@ -18,13 +18,25 @@ describe('Settings Block', () => {
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]');
const findTitleSlot = () => wrapper.find('[data-testid="title-slot"]');
const findDescriptionSlot = () => wrapper.find('[data-testid="description-slot"]');
- const findExpandButton = () => wrapper.find(GlButton);
+ const findExpandButton = () => wrapper.findComponent(GlButton);
+ const findSectionTitleButton = () => wrapper.find('[data-testid="section-title-button"]');
+
+ const expectExpandedState = ({ expanded = true } = {}) => {
+ const settingsExpandButton = findExpandButton();
+
+ expect(wrapper.classes('expanded')).toBe(expanded);
+ expect(settingsExpandButton.text()).toBe(
+ expanded ? SettingsBlock.i18n.collapseText : SettingsBlock.i18n.expandText,
+ );
+ expect(settingsExpandButton.attributes('aria-label')).toBe(
+ expanded ? SettingsBlock.i18n.collapseAriaLabel : SettingsBlock.i18n.expandAriaLabel,
+ );
+ };
it('renders the correct markup', () => {
mountComponent();
@@ -75,33 +87,41 @@ describe('Settings Block', () => {
it('is collapsed by default', () => {
mountComponent();
- expect(wrapper.classes('expanded')).toBe(false);
+ expectExpandedState({ expanded: false });
});
it('adds expanded class when the expand button is clicked', async () => {
mountComponent();
- expect(wrapper.classes('expanded')).toBe(false);
- expect(findExpandButton().text()).toBe('Expand');
-
await findExpandButton().vm.$emit('click');
- expect(wrapper.classes('expanded')).toBe(true);
- expect(findExpandButton().text()).toBe('Collapse');
+ expectExpandedState({ expanded: true });
});
- it('is expanded when `defaultExpanded` is true no matter what', async () => {
- mountComponent({ defaultExpanded: true });
+ it('adds expanded class when the section title is clicked', async () => {
+ mountComponent();
- expect(wrapper.classes('expanded')).toBe(true);
+ await findSectionTitleButton().trigger('click');
- await findExpandButton().vm.$emit('click');
+ expectExpandedState({ expanded: true });
+ });
- expect(wrapper.classes('expanded')).toBe(true);
+ describe('when `collapsible` is `false`', () => {
+ beforeEach(() => {
+ mountComponent({ collapsible: false });
+ });
- await findExpandButton().vm.$emit('click');
+ it('does not render clickable section title', () => {
+ expect(findSectionTitleButton().exists()).toBe(false);
+ });
+
+ it('contains expanded class', () => {
+ expect(wrapper.classes('expanded')).toBe(true);
+ });
- expect(wrapper.classes('expanded')).toBe(true);
+ it('does not render expand toggle button', () => {
+ expect(findExpandButton().exists()).toBe(false);
+ });
});
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
index a1942e59571..e39e8794fdd 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
@@ -124,7 +124,7 @@ describe('DropdownContentsLabelsView', () => {
});
it('returns false when provided `label` param is not one of the selected labels', () => {
- expect(wrapper.vm.isLabelSelected(mockLabels[2])).toBe(false);
+ expect(wrapper.vm.isLabelSelected(mockLabels[1])).toBe(false);
});
});
@@ -203,7 +203,7 @@ describe('DropdownContentsLabelsView', () => {
it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => {
jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
wrapper.setData({
- currentHighlightItem: 1,
+ currentHighlightItem: 2,
});
wrapper.vm.handleKeyDown({
@@ -213,7 +213,7 @@ describe('DropdownContentsLabelsView', () => {
expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([
{
- ...mockLabels[1],
+ ...mockLabels[2],
set: true,
},
]);
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 c90e63313b2..960ea77cb6e 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
@@ -6,7 +6,7 @@ import DropdownValue from '~/vue_shared/components/sidebar/labels_select_vue/dro
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
-import { mockConfig, mockRegularLabel, mockScopedLabel } from './mock_data';
+import { mockConfig, mockLabels, mockRegularLabel, mockScopedLabel } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -14,6 +14,9 @@ localVue.use(Vuex);
describe('DropdownValue', () => {
let wrapper;
+ const findAllLabels = () => wrapper.findAllComponents(GlLabel);
+ const findLabel = (index) => findAllLabels().at(index).props('title');
+
const createComponent = (initialState = {}, slots = {}) => {
const store = new Vuex.Store(labelsSelectModule());
@@ -28,7 +31,6 @@ describe('DropdownValue', () => {
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
describe('methods', () => {
@@ -82,7 +84,17 @@ describe('DropdownValue', () => {
it('renders labels when `selectedLabels` is not empty', () => {
createComponent();
- expect(wrapper.findAll(GlLabel).length).toBe(2);
+ expect(findAllLabels()).toHaveLength(2);
+ });
+
+ it('orders scoped labels first', () => {
+ createComponent({ selectedLabels: mockLabels });
+
+ expect(findAllLabels()).toHaveLength(mockLabels.length);
+ expect(findLabel(0)).toBe('Foo::Bar');
+ expect(findLabel(1)).toBe('Boog');
+ expect(findLabel(2)).toBe('Bug');
+ expect(findLabel(3)).toBe('Foo Label');
});
});
});
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 730afcbecab..1faa3b0af1d 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
@@ -15,22 +15,22 @@ export const mockScopedLabel = {
};
export const mockLabels = [
- mockRegularLabel,
- mockScopedLabel,
{
- id: 28,
- title: 'Bug',
+ id: 29,
+ title: 'Boog',
description: 'Label for bugs',
color: '#FF0000',
textColor: '#FFFFFF',
},
{
- id: 29,
- title: 'Boog',
+ id: 28,
+ title: 'Bug',
description: 'Label for bugs',
color: '#FF0000',
textColor: '#FFFFFF',
},
+ mockRegularLabel,
+ mockScopedLabel,
];
export const mockCollapsedLabels = [
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js
deleted file mode 100644
index 0a42d389b67..00000000000
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js
+++ /dev/null
@@ -1,91 +0,0 @@
-import { GlIcon, GlButton } from '@gitlab/ui';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import Vuex from 'vuex';
-
-import DropdownButton from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue';
-
-import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
-
-import { mockConfig } from './mock_data';
-
-let store;
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-const createComponent = (initialState = mockConfig) => {
- store = new Vuex.Store(labelSelectModule());
-
- store.dispatch('setInitialState', initialState);
-
- return shallowMount(DropdownButton, {
- localVue,
- store,
- });
-};
-
-describe('DropdownButton', () => {
- let wrapper;
-
- beforeEach(() => {
- wrapper = createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- const findDropdownButton = () => wrapper.find(GlButton);
- const findDropdownText = () => wrapper.find('.dropdown-toggle-text');
- const findDropdownIcon = () => wrapper.find(GlIcon);
-
- describe('methods', () => {
- describe('handleButtonClick', () => {
- it.each`
- variant | expectPropagationStopped
- ${'standalone'} | ${true}
- ${'embedded'} | ${false}
- `(
- 'toggles dropdown content and handles event propagation when `state.variant` is "$variant"',
- ({ variant, expectPropagationStopped }) => {
- const event = { stopPropagation: jest.fn() };
-
- wrapper = createComponent({ ...mockConfig, variant });
-
- findDropdownButton().vm.$emit('click', event);
-
- expect(store.state.showDropdownContents).toBe(true);
- expect(event.stopPropagation).toHaveBeenCalledTimes(expectPropagationStopped ? 1 : 0);
- },
- );
- });
- });
-
- describe('template', () => {
- it('renders component container element', () => {
- expect(wrapper.find(GlButton).element).toBe(wrapper.element);
- });
-
- it('renders default button text element', () => {
- const dropdownTextEl = findDropdownText();
-
- expect(dropdownTextEl.exists()).toBe(true);
- expect(dropdownTextEl.text()).toBe('Label');
- });
-
- it('renders provided button text element', () => {
- store.state.dropdownButtonText = 'Custom label';
- const dropdownTextEl = findDropdownText();
-
- return wrapper.vm.$nextTick().then(() => {
- expect(dropdownTextEl.text()).toBe('Custom label');
- });
- });
-
- it('renders chevron icon element', () => {
- const iconEl = findDropdownIcon();
-
- expect(iconEl.exists()).toBe(true);
- expect(iconEl.props('name')).toBe('chevron-down');
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js
index 90bc1980ac3..843298a1406 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js
@@ -7,7 +7,12 @@ import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue';
import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql';
-import { mockSuggestedColors, createLabelSuccessfulResponse } from './mock_data';
+import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql';
+import {
+ mockSuggestedColors,
+ createLabelSuccessfulResponse,
+ labelsQueryResponse,
+} from './mock_data';
jest.mock('~/flash');
@@ -44,6 +49,14 @@ describe('DropdownContentsCreateView', () => {
const createComponent = ({ mutationHandler = createLabelSuccessHandler } = {}) => {
const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]);
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: projectLabelsQuery,
+ data: labelsQueryResponse.data,
+ variables: {
+ fullPath: '',
+ searchTerm: '',
+ },
+ });
wrapper = shallowMount(DropdownContentsCreateView, {
localVue,
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js
index 8bd944a3d54..537bbc8e71e 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js
@@ -45,8 +45,6 @@ describe('DropdownContentsLabelsView', () => {
provide: {
projectPath: 'test',
iid: 1,
- allowLabelCreate: true,
- labelsManagePath: '/gitlab-org/my-project/-/labels',
variant: DropdownVariant.Sidebar,
...injected,
},
@@ -69,10 +67,7 @@ describe('DropdownContentsLabelsView', () => {
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findLabelsList = () => wrapper.find('[data-testid="labels-list"]');
- const findDropdownWrapper = () => wrapper.find('[data-testid="dropdown-wrapper"]');
- const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]');
const findNoResultsMessage = () => wrapper.find('[data-testid="no-results"]');
- const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]');
describe('when loading labels', () => {
it('renders disabled search input field', async () => {
@@ -109,40 +104,6 @@ describe('DropdownContentsLabelsView', () => {
expect(findLabelsList().exists()).toBe(true);
expect(findLabels()).toHaveLength(2);
});
-
- it('changes highlighted label correctly on pressing down button', async () => {
- expect(findLabels().at(0).attributes('highlight')).toBeUndefined();
-
- await findDropdownWrapper().trigger('keydown.down');
- expect(findLabels().at(0).attributes('highlight')).toBe('true');
-
- await findDropdownWrapper().trigger('keydown.down');
- expect(findLabels().at(1).attributes('highlight')).toBe('true');
- expect(findLabels().at(0).attributes('highlight')).toBeUndefined();
- });
-
- it('changes highlighted label correctly on pressing up button', async () => {
- await findDropdownWrapper().trigger('keydown.down');
- await findDropdownWrapper().trigger('keydown.down');
- expect(findLabels().at(1).attributes('highlight')).toBe('true');
-
- await findDropdownWrapper().trigger('keydown.up');
- expect(findLabels().at(0).attributes('highlight')).toBe('true');
- });
-
- it('changes label selected state when Enter is pressed', async () => {
- expect(findLabels().at(0).attributes('islabelset')).toBeUndefined();
- await findDropdownWrapper().trigger('keydown.down');
- await findDropdownWrapper().trigger('keydown.enter');
-
- expect(findLabels().at(0).attributes('islabelset')).toBe('true');
- });
-
- it('emits `closeDropdown event` when Esc button is pressed', () => {
- findDropdownWrapper().trigger('keydown.esc');
-
- expect(wrapper.emitted('closeDropdown')).toEqual([[selectedLabels]]);
- });
});
it('when search returns 0 results', async () => {
@@ -170,44 +131,4 @@ describe('DropdownContentsLabelsView', () => {
await waitForPromises();
expect(createFlash).toHaveBeenCalled();
});
-
- it('does not render footer on standalone dropdown', () => {
- createComponent({ injected: { variant: DropdownVariant.Standalone } });
-
- expect(findDropdownFooter().exists()).toBe(false);
- });
-
- it('renders footer on sidebar dropdown', () => {
- createComponent();
-
- expect(findDropdownFooter().exists()).toBe(true);
- });
-
- it('renders footer on embedded dropdown', () => {
- createComponent({ injected: { variant: DropdownVariant.Embedded } });
-
- expect(findDropdownFooter().exists()).toBe(true);
- });
-
- it('does not render create label button if `allowLabelCreate` is false', () => {
- createComponent({ injected: { allowLabelCreate: false } });
-
- expect(findCreateLabelButton().exists()).toBe(false);
- });
-
- describe('when `allowLabelCreate` is true', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders create label button', () => {
- expect(findCreateLabelButton().exists()).toBe(true);
- });
-
- it('emits `toggleDropdownContentsCreateView` event on create label button click', () => {
- findCreateLabelButton().vm.$emit('click');
-
- expect(wrapper.emitted('toggleDropdownContentsCreateView')).toEqual([[]]);
- });
- });
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js
index 3c2fd0c5acc..a1b40a891ec 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js
@@ -1,77 +1,127 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import Vuex from 'vuex';
+import { GlDropdown } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue';
-import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
-
-import { mockConfig, mockLabels } from './mock_data';
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-const createComponent = (initialState = mockConfig, defaultProps = {}) => {
- const store = new Vuex.Store(labelsSelectModule());
-
- store.dispatch('setInitialState', initialState);
-
- return shallowMount(DropdownContents, {
- propsData: {
- ...defaultProps,
- labelsCreateTitle: 'test',
- selectedLabels: mockLabels,
- allowMultiselect: true,
- labelsListTitle: 'Assign labels',
- footerCreateLabelTitle: 'create',
- footerManageLabelTitle: 'manage',
- },
- localVue,
- store,
- });
-};
+import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue';
+import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue';
+
+import { mockLabels } from './mock_data';
describe('DropdownContent', () => {
let wrapper;
+ const createComponent = ({ props = {}, injected = {} } = {}) => {
+ wrapper = shallowMount(DropdownContents, {
+ propsData: {
+ labelsCreateTitle: 'test',
+ selectedLabels: mockLabels,
+ allowMultiselect: true,
+ labelsListTitle: 'Assign labels',
+ footerCreateLabelTitle: 'create',
+ footerManageLabelTitle: 'manage',
+ dropdownButtonText: 'Labels',
+ variant: 'sidebar',
+ ...props,
+ },
+ provide: {
+ allowLabelCreate: true,
+ labelsManagePath: 'foo/bar',
+ ...injected,
+ },
+ stubs: {
+ GlDropdown,
+ },
+ });
+ };
+
beforeEach(() => {
- wrapper = createComponent();
+ createComponent();
});
afterEach(() => {
wrapper.destroy();
});
- describe('computed', () => {
- describe('dropdownContentsView', () => {
- it('returns string "dropdown-contents-create-view" when `showDropdownContentsCreateView` prop is `true`', () => {
- wrapper.vm.$store.dispatch('toggleDropdownContentsCreateView');
+ const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]');
+ const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]');
+ const findGoBackButton = () => wrapper.find('[data-testid="go-back-button"]');
- expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-create-view');
- });
+ describe('Create view', () => {
+ beforeEach(() => {
+ wrapper.vm.toggleDropdownContentsCreateView();
+ });
- it('returns string "dropdown-contents-labels-view" when `showDropdownContentsCreateView` prop is `false`', () => {
- expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-labels-view');
- });
+ it('renders create view when `showDropdownContentsCreateView` prop is `true`', () => {
+ expect(wrapper.findComponent(DropdownContentsCreateView).exists()).toBe(true);
+ });
+
+ it('does not render footer', () => {
+ expect(findDropdownFooter().exists()).toBe(false);
+ });
+
+ it('does not render create label button', () => {
+ expect(findCreateLabelButton().exists()).toBe(false);
+ });
+
+ it('renders go back button', () => {
+ expect(findGoBackButton().exists()).toBe(true);
});
});
- describe('template', () => {
- it('renders component container element with class `labels-select-dropdown-contents` and no styles', () => {
- expect(wrapper.attributes('class')).toContain('labels-select-dropdown-contents');
- expect(wrapper.attributes('style')).toBeUndefined();
+ describe('Labels view', () => {
+ it('renders labels view when `showDropdownContentsCreateView` when `showDropdownContentsCreateView` prop is `false`', () => {
+ expect(wrapper.findComponent(DropdownContentsLabelsView).exists()).toBe(true);
});
- describe('when `renderOnTop` is true', () => {
- it.each`
- variant | expected
- ${DropdownVariant.Sidebar} | ${'bottom: 3rem'}
- ${DropdownVariant.Standalone} | ${'bottom: 2rem'}
- ${DropdownVariant.Embedded} | ${'bottom: 2rem'}
- `('renders upward for $variant variant', ({ variant, expected }) => {
- wrapper = createComponent({ ...mockConfig, variant }, { renderOnTop: true });
+ it('renders footer on sidebar dropdown', () => {
+ expect(findDropdownFooter().exists()).toBe(true);
+ });
+
+ it('does not render footer on standalone dropdown', () => {
+ createComponent({ props: { variant: DropdownVariant.Standalone } });
+
+ expect(findDropdownFooter().exists()).toBe(false);
+ });
- expect(wrapper.attributes('style')).toContain(expected);
+ it('renders footer on embedded dropdown', () => {
+ createComponent({ props: { variant: DropdownVariant.Embedded } });
+
+ expect(findDropdownFooter().exists()).toBe(true);
+ });
+
+ it('does not render go back button', () => {
+ expect(findGoBackButton().exists()).toBe(false);
+ });
+
+ it('does not render create label button if `allowLabelCreate` is false', () => {
+ createComponent({ injected: { allowLabelCreate: false } });
+
+ expect(findCreateLabelButton().exists()).toBe(false);
+ });
+
+ describe('when `allowLabelCreate` is true', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders create label button', () => {
+ expect(findCreateLabelButton().exists()).toBe(true);
});
+
+ it('triggers `toggleDropdownContent` method on create label button click', () => {
+ jest.spyOn(wrapper.vm, 'toggleDropdownContent').mockImplementation(() => {});
+ findCreateLabelButton().trigger('click');
+
+ expect(wrapper.vm.toggleDropdownContent).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element with classes `gl-w-full gl-mt-2` and no styles', () => {
+ expect(wrapper.attributes('class')).toContain('gl-w-full gl-mt-2');
+ expect(wrapper.attributes('style')).toBeUndefined();
});
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js
deleted file mode 100644
index d2401a1f725..00000000000
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import { GlButton, GlLoadingIcon } from '@gitlab/ui';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import Vuex from 'vuex';
-
-import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue';
-
-import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
-
-import { mockConfig } from './mock_data';
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-const createComponent = (initialState = mockConfig) => {
- const store = new Vuex.Store(labelsSelectModule());
-
- store.dispatch('setInitialState', initialState);
-
- return shallowMount(DropdownTitle, {
- localVue,
- store,
- propsData: {
- labelsSelectInProgress: false,
- },
- });
-};
-
-describe('DropdownTitle', () => {
- let wrapper;
-
- beforeEach(() => {
- wrapper = createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('template', () => {
- it('renders component container element with string "Labels"', () => {
- expect(wrapper.text()).toContain('Labels');
- });
-
- it('renders edit link', () => {
- const editBtnEl = wrapper.find(GlButton);
-
- expect(editBtnEl.exists()).toBe(true);
- expect(editBtnEl.text()).toBe('Edit');
- });
-
- it('renders loading icon element when `labelsSelectInProgress` prop is true', () => {
- wrapper.setProps({
- labelsSelectInProgress: true,
- });
-
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true);
- });
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js
index b3ffee2d020..e7e78cd7a33 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js
@@ -9,8 +9,8 @@ describe('DropdownValue', () => {
let wrapper;
const findAllLabels = () => wrapper.findAllComponents(GlLabel);
- const findRegularLabel = () => findAllLabels().at(0);
- const findScopedLabel = () => findAllLabels().at(1);
+ const findRegularLabel = () => findAllLabels().at(1);
+ const findScopedLabel = () => findAllLabels().at(0);
const findWrapper = () => wrapper.find('[data-testid="value-wrapper"]');
const findEmptyPlaceholder = () => wrapper.find('[data-testid="empty-placeholder"]');
@@ -20,11 +20,13 @@ describe('DropdownValue', () => {
propsData: {
selectedLabels: [mockRegularLabel, mockScopedLabel],
allowLabelRemove: true,
- allowScopedLabels: true,
labelsFilterBasePath: '/gitlab-org/my-project/issues',
labelsFilterParam: 'label_name',
...props,
},
+ provide: {
+ allowScopedLabels: true,
+ },
});
};
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js
index 23810339833..6e8841411a2 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js
@@ -1,4 +1,3 @@
-import { GlIcon, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue';
@@ -6,16 +5,10 @@ import { mockRegularLabel } from './mock_data';
const mockLabel = { ...mockRegularLabel, set: true };
-const createComponent = ({
- label = mockLabel,
- isLabelSet = mockLabel.set,
- highlight = true,
-} = {}) =>
+const createComponent = ({ label = mockLabel } = {}) =>
shallowMount(LabelItem, {
propsData: {
label,
- isLabelSet,
- highlight,
},
});
@@ -31,45 +24,6 @@ describe('LabelItem', () => {
});
describe('template', () => {
- it('renders gl-link component', () => {
- expect(wrapper.find(GlLink).exists()).toBe(true);
- });
-
- it('renders component root with class `is-focused` when `highlight` prop is true', () => {
- const wrapperTemp = createComponent({
- highlight: true,
- });
-
- expect(wrapperTemp.classes()).toContain('is-focused');
-
- wrapperTemp.destroy();
- });
-
- it('renders visible gl-icon component when `isLabelSet` prop is true', () => {
- const wrapperTemp = createComponent({
- isLabelSet: true,
- });
-
- const iconEl = wrapperTemp.find(GlIcon);
-
- expect(iconEl.isVisible()).toBe(true);
- expect(iconEl.props('name')).toBe('mobile-issue-close');
-
- wrapperTemp.destroy();
- });
-
- it('renders visible span element as placeholder instead of gl-icon when `isLabelSet` prop is false', () => {
- const wrapperTemp = createComponent({
- isLabelSet: false,
- });
-
- const placeholderEl = wrapperTemp.find('[data-testid="no-icon"]');
-
- expect(placeholderEl.isVisible()).toBe(true);
-
- wrapperTemp.destroy();
- });
-
it('renders label color element', () => {
const colorEl = wrapper.find('[data-testid="label-color-box"]');
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js
index e17dfd93efc..a18511fa21d 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js
@@ -1,193 +1,74 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import Vuex from 'vuex';
-
-import { isInViewport } from '~/lib/utils/common_utils';
-import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
-import DropdownButton from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue';
+import { shallowMount } from '@vue/test-utils';
+import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue';
-import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue';
import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue';
import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue';
import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
-import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
-
import { mockConfig } from './mock_data';
-jest.mock('~/lib/utils/common_utils', () => ({
- isInViewport: jest.fn().mockReturnValue(true),
-}));
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
describe('LabelsSelectRoot', () => {
let wrapper;
- let store;
const createComponent = (config = mockConfig, slots = {}) => {
wrapper = shallowMount(LabelsSelectRoot, {
- localVue,
slots,
- store,
propsData: config,
stubs: {
- 'dropdown-contents': DropdownContents,
+ DropdownContents,
+ SidebarEditableItem,
},
provide: {
iid: '1',
projectPath: 'test',
+ canUpdate: true,
+ allowLabelEdit: true,
},
});
};
- beforeEach(() => {
- store = new Vuex.Store(labelsSelectModule());
- });
-
afterEach(() => {
wrapper.destroy();
});
- describe('methods', () => {
- describe('handleDropdownClose', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('emits `updateSelectedLabels` & `onDropdownClose` events on component when provided `labels` param is not empty', () => {
- wrapper.vm.handleDropdownClose([{ id: 1 }, { id: 2 }]);
-
- expect(wrapper.emitted().updateSelectedLabels).toBeTruthy();
- expect(wrapper.emitted().onDropdownClose).toBeTruthy();
- });
-
- it('emits only `onDropdownClose` event on component when provided `labels` param is empty', () => {
- wrapper.vm.handleDropdownClose([]);
-
- expect(wrapper.emitted().updateSelectedLabels).toBeFalsy();
- expect(wrapper.emitted().onDropdownClose).toBeTruthy();
- });
- });
-
- describe('handleCollapsedValueClick', () => {
- it('emits `toggleCollapse` event on component', () => {
- createComponent();
- wrapper.vm.handleCollapsedValueClick();
-
- expect(wrapper.emitted().toggleCollapse).toBeTruthy();
- });
- });
+ it('renders component with classes `labels-select-wrapper position-relative`', () => {
+ createComponent();
+ expect(wrapper.classes()).toEqual(['labels-select-wrapper', 'position-relative']);
});
- describe('template', () => {
- it('renders component with classes `labels-select-wrapper position-relative`', () => {
- createComponent();
- expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative');
- });
-
- it.each`
- variant | cssClass
- ${'standalone'} | ${'is-standalone'}
- ${'embedded'} | ${'is-embedded'}
- `(
- 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"',
- ({ variant, cssClass }) => {
- createComponent({
- ...mockConfig,
- variant,
- });
-
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.classes()).toContain(cssClass);
- });
- },
- );
-
- it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => {
- createComponent();
- await wrapper.vm.$nextTick;
- expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true);
- });
-
- it('renders `dropdown-title` component', async () => {
- createComponent();
- await wrapper.vm.$nextTick;
- expect(wrapper.find(DropdownTitle).exists()).toBe(true);
- });
-
- it('renders `dropdown-value` component', async () => {
- createComponent(mockConfig, {
- default: 'None',
+ it.each`
+ variant | cssClass
+ ${'standalone'} | ${'is-standalone'}
+ ${'embedded'} | ${'is-embedded'}
+ `(
+ 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"',
+ ({ variant, cssClass }) => {
+ createComponent({
+ ...mockConfig,
+ variant,
});
- await wrapper.vm.$nextTick;
-
- const valueComp = wrapper.find(DropdownValue);
-
- expect(valueComp.exists()).toBe(true);
- expect(valueComp.text()).toBe('None');
- });
-
- it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', async () => {
- createComponent();
- wrapper.vm.$store.dispatch('toggleDropdownButton');
- await wrapper.vm.$nextTick;
- expect(wrapper.find(DropdownButton).exists()).toBe(true);
- });
-
- it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', async () => {
- createComponent();
- wrapper.vm.$store.dispatch('toggleDropdownContents');
- await wrapper.vm.$nextTick;
- expect(wrapper.find(DropdownContents).exists()).toBe(true);
- });
- describe('sets content direction based on viewport', () => {
- describe.each(Object.values(DropdownVariant))(
- 'when labels variant is "%s"',
- ({ variant }) => {
- beforeEach(() => {
- createComponent({ ...mockConfig, variant });
- wrapper.vm.$store.dispatch('toggleDropdownContents');
- });
-
- it('set direction when out of viewport', () => {
- isInViewport.mockImplementation(() => false);
- wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state);
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(true);
- });
- });
-
- it('does not set direction when inside of viewport', () => {
- isInViewport.mockImplementation(() => true);
- wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state);
-
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false);
- });
- });
- },
- );
- });
- });
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.classes()).toContain(cssClass);
+ });
+ },
+ );
- it('calls toggleDropdownContents action when isEditing prop is changing to true', async () => {
+ it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => {
createComponent();
-
- jest.spyOn(store, 'dispatch').mockResolvedValue();
- await wrapper.setProps({ isEditing: true });
-
- expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContents');
+ await wrapper.vm.$nextTick;
+ expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true);
});
- it('does not call toggleDropdownContents action when isEditing prop is changing to false', async () => {
- createComponent();
+ it('renders `dropdown-value` component', async () => {
+ createComponent(mockConfig, {
+ default: 'None',
+ });
+ await wrapper.vm.$nextTick;
- jest.spyOn(store, 'dispatch').mockResolvedValue();
- await wrapper.setProps({ isEditing: false });
+ const valueComp = wrapper.find(DropdownValue);
- expect(store.dispatch).not.toHaveBeenCalled();
+ expect(valueComp.exists()).toBe(true);
+ expect(valueComp.text()).toBe('None');
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js
index 5dd8fc1b8b2..fceaabec2d0 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js
@@ -34,18 +34,12 @@ export const mockLabels = [
];
export const mockConfig = {
- allowLabelEdit: true,
- allowLabelCreate: true,
- allowScopedLabels: true,
allowMultiselect: true,
labelsListTitle: 'Assign labels',
labelsCreateTitle: 'Create label',
variant: 'sidebar',
- dropdownOnly: false,
selectedLabels: [mockRegularLabel, mockScopedLabel],
labelsSelectInProgress: false,
- labelsFetchPath: '/gitlab-org/my-project/-/labels.json',
- labelsManagePath: '/gitlab-org/my-project/-/labels',
labelsFilterBasePath: '/gitlab-org/my-project/issues',
labelsFilterParam: 'label_name',
footerCreateLabelTitle: 'create',
@@ -83,9 +77,7 @@ export const createLabelSuccessfulResponse = {
id: 'gid://gitlab/ProjectLabel/126',
color: '#dc143c',
description: null,
- descriptionHtml: '',
title: 'ewrwrwer',
- textColor: '#FFFFFF',
__typename: 'Label',
},
errors: [],
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js
deleted file mode 100644
index ee905410ffa..00000000000
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js
+++ /dev/null
@@ -1,85 +0,0 @@
-import testAction from 'helpers/vuex_action_helper';
-import * as actions from '~/vue_shared/components/sidebar/labels_select_widget/store/actions';
-import * as types from '~/vue_shared/components/sidebar/labels_select_widget/store/mutation_types';
-import defaultState from '~/vue_shared/components/sidebar/labels_select_widget/store/state';
-
-jest.mock('~/flash');
-
-describe('LabelsSelect Actions', () => {
- let state;
- const mockInitialState = {
- labels: [],
- selectedLabels: [],
- };
-
- beforeEach(() => {
- state = { ...defaultState() };
- });
-
- describe('setInitialState', () => {
- it('sets initial store state', (done) => {
- testAction(
- actions.setInitialState,
- mockInitialState,
- state,
- [{ type: types.SET_INITIAL_STATE, payload: mockInitialState }],
- [],
- done,
- );
- });
- });
-
- describe('toggleDropdownButton', () => {
- it('toggles dropdown button', (done) => {
- testAction(
- actions.toggleDropdownButton,
- {},
- state,
- [{ type: types.TOGGLE_DROPDOWN_BUTTON }],
- [],
- done,
- );
- });
- });
-
- describe('toggleDropdownContents', () => {
- it('toggles dropdown contents', (done) => {
- testAction(
- actions.toggleDropdownContents,
- {},
- state,
- [{ type: types.TOGGLE_DROPDOWN_CONTENTS }],
- [],
- done,
- );
- });
- });
-
- describe('toggleDropdownContentsCreateView', () => {
- it('toggles dropdown create view', (done) => {
- testAction(
- actions.toggleDropdownContentsCreateView,
- {},
- state,
- [{ type: types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW }],
- [],
- done,
- );
- });
- });
-
- describe('updateSelectedLabels', () => {
- it('updates `state.labels` based on provided `labels` param', (done) => {
- const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
-
- testAction(
- actions.updateSelectedLabels,
- labels,
- state,
- [{ type: types.UPDATE_SELECTED_LABELS, payload: { labels } }],
- [],
- done,
- );
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js
deleted file mode 100644
index 40eb0323146..00000000000
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import * as getters from '~/vue_shared/components/sidebar/labels_select_widget/store/getters';
-
-describe('LabelsSelect Getters', () => {
- describe('dropdownButtonText', () => {
- it.each`
- labelType | dropdownButtonText | expected
- ${'default'} | ${''} | ${'Label'}
- ${'custom'} | ${'Custom label'} | ${'Custom label'}
- `(
- 'returns $labelType text when state.labels has no selected labels',
- ({ dropdownButtonText, expected }) => {
- const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
- const selectedLabels = [];
- const state = { labels, selectedLabels, dropdownButtonText };
-
- expect(getters.dropdownButtonText(state, {})).toBe(expected);
- },
- );
-
- it('returns label title when state.labels has only 1 label', () => {
- const labels = [{ id: 1, title: 'Foobar', set: true }];
-
- expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
- 'Foobar',
- );
- });
-
- it('returns first label title and remaining labels count when state.labels has more than 1 label', () => {
- const labels = [
- { id: 1, title: 'Foo', set: true },
- { id: 2, title: 'Bar', set: true },
- ];
-
- expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
- 'Foo +1 more',
- );
- });
- });
-
- describe('selectedLabelsList', () => {
- it('returns array of IDs of all labels within `state.selectedLabels`', () => {
- const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
-
- expect(getters.selectedLabelsList({ selectedLabels })).toEqual([1, 2, 3, 4]);
- });
- });
-
- describe('isDropdownVariantSidebar', () => {
- it('returns `true` when `state.variant` is "sidebar"', () => {
- expect(getters.isDropdownVariantSidebar({ variant: 'sidebar' })).toBe(true);
- });
- });
-
- describe('isDropdownVariantStandalone', () => {
- it('returns `true` when `state.variant` is "standalone"', () => {
- expect(getters.isDropdownVariantStandalone({ variant: 'standalone' })).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js
deleted file mode 100644
index 1f0e0eee420..00000000000
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js
+++ /dev/null
@@ -1,115 +0,0 @@
-import * as types from '~/vue_shared/components/sidebar/labels_select_widget/store/mutation_types';
-import mutations from '~/vue_shared/components/sidebar/labels_select_widget/store/mutations';
-
-describe('LabelsSelect Mutations', () => {
- describe(`${types.SET_INITIAL_STATE}`, () => {
- it('initializes provided props to store state', () => {
- const state = {};
- mutations[types.SET_INITIAL_STATE](state, {
- labels: 'foo',
- });
-
- expect(state.labels).toEqual('foo');
- });
- });
-
- describe(`${types.TOGGLE_DROPDOWN_BUTTON}`, () => {
- it('toggles value of `state.showDropdownButton`', () => {
- const state = {
- showDropdownButton: false,
- };
- mutations[types.TOGGLE_DROPDOWN_BUTTON](state);
-
- expect(state.showDropdownButton).toBe(true);
- });
- });
-
- describe(`${types.TOGGLE_DROPDOWN_CONTENTS}`, () => {
- it('toggles value of `state.showDropdownButton` when `state.dropdownOnly` is false', () => {
- const state = {
- dropdownOnly: false,
- showDropdownButton: false,
- variant: 'sidebar',
- };
- mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
-
- expect(state.showDropdownButton).toBe(true);
- });
-
- it('toggles value of `state.showDropdownContents`', () => {
- const state = {
- showDropdownContents: false,
- };
- mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
-
- expect(state.showDropdownContents).toBe(true);
- });
-
- it('sets value of `state.showDropdownContentsCreateView` to `false` when `showDropdownContents` is true', () => {
- const state = {
- showDropdownContents: false,
- showDropdownContentsCreateView: true,
- };
- mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
-
- expect(state.showDropdownContentsCreateView).toBe(false);
- });
- });
-
- describe(`${types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW}`, () => {
- it('toggles value of `state.showDropdownContentsCreateView`', () => {
- const state = {
- showDropdownContentsCreateView: false,
- };
- mutations[types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state);
-
- expect(state.showDropdownContentsCreateView).toBe(true);
- });
- });
-
- describe(`${types.UPDATE_SELECTED_LABELS}`, () => {
- let labels;
-
- beforeEach(() => {
- labels = [
- { id: 1, title: 'scoped::test', set: true },
- { id: 2, set: false, title: 'scoped::one' },
- { id: 3, title: '' },
- { id: 4, title: '' },
- ];
- });
-
- it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => {
- const updatedLabelIds = [2];
- const state = {
- labels,
- };
- mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: 2 }] });
-
- state.labels.forEach((label) => {
- if (updatedLabelIds.includes(label.id)) {
- expect(label.touched).toBe(true);
- expect(label.set).toBe(true);
- }
- });
- });
-
- describe('when label is scoped', () => {
- it('unsets the currently selected scoped label and sets the current label', () => {
- const state = {
- labels,
- };
- mutations[types.UPDATE_SELECTED_LABELS](state, {
- labels: [{ id: 2, title: 'scoped::one' }],
- });
-
- expect(state.labels).toEqual([
- { id: 1, title: 'scoped::test', set: false },
- { id: 2, set: true, title: 'scoped::one', touched: true },
- { id: 3, title: '' },
- { id: 4, title: '' },
- ]);
- });
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/storage_counter/usage_graph_spec.js b/spec/frontend/vue_shared/components/storage_counter/usage_graph_spec.js
new file mode 100644
index 00000000000..103eee4b9a8
--- /dev/null
+++ b/spec/frontend/vue_shared/components/storage_counter/usage_graph_spec.js
@@ -0,0 +1,137 @@
+import { shallowMount } from '@vue/test-utils';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue';
+
+let data;
+let wrapper;
+
+function mountComponent({ rootStorageStatistics, limit }) {
+ wrapper = shallowMount(UsageGraph, {
+ propsData: {
+ rootStorageStatistics,
+ limit,
+ },
+ });
+}
+function findStorageTypeUsagesSerialized() {
+ return wrapper
+ .findAll('[data-testid="storage-type-usage"]')
+ .wrappers.map((wp) => wp.element.style.flex);
+}
+
+describe('Storage Counter usage graph component', () => {
+ beforeEach(() => {
+ data = {
+ rootStorageStatistics: {
+ wikiSize: 5000,
+ repositorySize: 4000,
+ packagesSize: 3000,
+ lfsObjectsSize: 2000,
+ buildArtifactsSize: 500,
+ pipelineArtifactsSize: 500,
+ snippetsSize: 2000,
+ storageSize: 17000,
+ uploadsSize: 1000,
+ },
+ limit: 2000,
+ };
+ mountComponent(data);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders the legend in order', () => {
+ const types = wrapper.findAll('[data-testid="storage-type-legend"]');
+
+ const {
+ buildArtifactsSize,
+ pipelineArtifactsSize,
+ lfsObjectsSize,
+ packagesSize,
+ repositorySize,
+ wikiSize,
+ snippetsSize,
+ uploadsSize,
+ } = data.rootStorageStatistics;
+
+ expect(types.at(0).text()).toMatchInterpolatedText(`Wikis ${numberToHumanSize(wikiSize)}`);
+ expect(types.at(1).text()).toMatchInterpolatedText(
+ `Repositories ${numberToHumanSize(repositorySize)}`,
+ );
+ expect(types.at(2).text()).toMatchInterpolatedText(
+ `Packages ${numberToHumanSize(packagesSize)}`,
+ );
+ expect(types.at(3).text()).toMatchInterpolatedText(
+ `LFS Objects ${numberToHumanSize(lfsObjectsSize)}`,
+ );
+ expect(types.at(4).text()).toMatchInterpolatedText(
+ `Snippets ${numberToHumanSize(snippetsSize)}`,
+ );
+ expect(types.at(5).text()).toMatchInterpolatedText(
+ `Artifacts ${numberToHumanSize(buildArtifactsSize + pipelineArtifactsSize)}`,
+ );
+ expect(types.at(6).text()).toMatchInterpolatedText(`Uploads ${numberToHumanSize(uploadsSize)}`);
+ });
+
+ describe('when storage type is not used', () => {
+ beforeEach(() => {
+ data.rootStorageStatistics.wikiSize = 0;
+ mountComponent(data);
+ });
+
+ it('filters the storage type', () => {
+ expect(wrapper.text()).not.toContain('Wikis');
+ });
+ });
+
+ describe('when there is no storage usage', () => {
+ beforeEach(() => {
+ data.rootStorageStatistics.storageSize = 0;
+ mountComponent(data);
+ });
+
+ it('it does not render', () => {
+ expect(wrapper.html()).toEqual('');
+ });
+ });
+
+ describe('when limit is 0', () => {
+ beforeEach(() => {
+ data.limit = 0;
+ mountComponent(data);
+ });
+
+ it('sets correct flex values', () => {
+ expect(findStorageTypeUsagesSerialized()).toStrictEqual([
+ '0.29411764705882354',
+ '0.23529411764705882',
+ '0.17647058823529413',
+ '0.11764705882352941',
+ '0.11764705882352941',
+ '0.058823529411764705',
+ '0.058823529411764705',
+ ]);
+ });
+ });
+
+ describe('when storage exceeds limit', () => {
+ beforeEach(() => {
+ data.limit = data.rootStorageStatistics.storageSize - 1;
+ mountComponent(data);
+ });
+
+ it('it does render correclty', () => {
+ expect(findStorageTypeUsagesSerialized()).toStrictEqual([
+ '0.29411764705882354',
+ '0.23529411764705882',
+ '0.17647058823529413',
+ '0.11764705882352941',
+ '0.11764705882352941',
+ '0.058823529411764705',
+ '0.058823529411764705',
+ ]);
+ });
+ });
+});
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 538e67ef354..926223e0670 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
@@ -94,7 +94,7 @@ describe('User Popover Component', () => {
const bio = 'My super interesting bio';
it('should show only bio if work information is not available', () => {
- const user = { ...DEFAULT_PROPS.user, bio, bioHtml: bio };
+ const user = { ...DEFAULT_PROPS.user, bio };
createWrapper({ user });
@@ -117,7 +117,6 @@ describe('User Popover Component', () => {
const user = {
...DEFAULT_PROPS.user,
bio,
- bioHtml: bio,
workInformation: 'Frontend Engineer at GitLab',
};
@@ -127,16 +126,15 @@ describe('User Popover Component', () => {
expect(findWorkInformation().text()).toBe('Frontend Engineer at GitLab');
});
- it('should not encode special characters in bio', () => {
+ it('should encode special characters in bio', () => {
const user = {
...DEFAULT_PROPS.user,
- bio: 'I like CSS',
- bioHtml: 'I like <b>CSS</b>',
+ bio: 'I like <b>CSS</b>',
};
createWrapper({ user });
- expect(findBio().html()).toContain('I like <b>CSS</b>');
+ expect(findBio().html()).toContain('I like &lt;b&gt;CSS&lt;/b&gt;');
});
it('shows icon for bio', () => {
@@ -250,6 +248,13 @@ describe('User Popover Component', () => {
const securityBotDocsLink = findSecurityBotDocsLink();
expect(securityBotDocsLink.exists()).toBe(true);
expect(securityBotDocsLink.attributes('href')).toBe(SECURITY_BOT_USER.websiteUrl);
+ expect(securityBotDocsLink.text()).toBe('Learn more about GitLab Security Bot');
+ });
+
+ it("doesn't escape user's name", () => {
+ createWrapper({ user: { ...SECURITY_BOT_USER, name: '%<>\';"' } });
+ const securityBotDocsLink = findSecurityBotDocsLink();
+ expect(securityBotDocsLink.text()).toBe('Learn more about %<>\';"');
});
});
});
diff --git a/spec/frontend/zen_mode_spec.js b/spec/frontend/zen_mode_spec.js
index bf4b57d8afb..13f221fd9d9 100644
--- a/spec/frontend/zen_mode_spec.js
+++ b/spec/frontend/zen_mode_spec.js
@@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import Dropzone from 'dropzone';
import $ from 'jquery';
import Mousetrap from 'mousetrap';
-import initNotes from '~/init_notes';
+import GLForm from '~/gl_form';
import * as utils from '~/lib/utils/common_utils';
import ZenMode from '~/zen_mode';
@@ -34,7 +34,9 @@ describe('ZenMode', () => {
mock.onGet().reply(200);
loadFixtures(fixtureName);
- initNotes();
+
+ const form = $('.js-new-note-form');
+ new GLForm(form); // eslint-disable-line no-new
dropzoneForElementSpy = jest.spyOn(Dropzone, 'forElement').mockImplementation(() => ({
enable: () => true,
diff --git a/spec/frontend_integration/README.md b/spec/frontend_integration/README.md
index 573a385d81e..377294fb19f 100644
--- a/spec/frontend_integration/README.md
+++ b/spec/frontend_integration/README.md
@@ -11,6 +11,33 @@ Frontend integration specs:
As a result, they deserve their own special place.
+## Run frontend integration tests locally
+
+The frontend integration specs are all about testing integration frontend bundles against a
+mock backend. The mock backend is built using the fixtures and GraphQL schema.
+
+We can generate the necessary fixtures and GraphQL schema by running:
+
+```shell
+bundle exec rake frontend:fixtures gitlab:graphql:schema:dump
+```
+
+Then we can use [Jest](https://jestjs.io/) to run the frontend integration tests:
+
+```shell
+yarn jest:integration <path-to-integration-test>
+```
+
+If you'd like to run the frontend integration specs **without** setting up the fixtures first, then you
+can set `GL_IGNORE_WARNINGS=1`:
+
+```shell
+GL_IGNORE_WARNINGS=1 yarn jest:integration <path-to-integration-test>
+```
+
+The `jest-integration` job executes the frontend integration tests in our
+CI/CD pipelines.
+
## References
- https://docs.gitlab.com/ee/development/testing_guide/testing_levels.html#frontend-integration-tests
diff --git a/spec/javascripts/fly_out_nav_browser_spec.js b/spec/frontend_integration/fly_out_nav_browser_spec.js
index 12ea0e262bc..ef2afa20528 100644
--- a/spec/javascripts/fly_out_nav_browser_spec.js
+++ b/spec/frontend_integration/fly_out_nav_browser_spec.js
@@ -1,7 +1,3 @@
-// this file can't be migrated to jest because it relies on the browser to perform integration tests:
-// (specifically getClientBoundingRect and mouse movements)
-// see: https://gitlab.com/groups/gitlab-org/-/epics/895#what-if-theres-a-karma-spec-which-is-simply-unmovable-to-jest-ie-it-is-dependent-on-a-running-browser-environment
-
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { SIDEBAR_COLLAPSED_CLASS } from '~/contextual_sidebar';
import {
@@ -25,14 +21,46 @@ describe('Fly out sidebar navigation', () => {
let el;
let breakpointSize = 'lg';
+ const OLD_SIDEBAR_WIDTH = 200;
+ const CONTAINER_INITIAL_BOUNDING_RECT = {
+ x: 8,
+ y: 8,
+ width: 769,
+ height: 0,
+ top: 8,
+ right: 777,
+ bottom: 8,
+ left: 8,
+ };
+ const SUB_ITEMS_INITIAL_BOUNDING_RECT = {
+ x: 148,
+ y: 8,
+ width: 0,
+ height: 150,
+ top: 8,
+ right: 148,
+ bottom: 158,
+ left: 148,
+ };
+ const mockBoundingClientRect = (elem, rect) => {
+ jest.spyOn(elem, 'getBoundingClientRect').mockReturnValue(rect);
+ };
+
+ const findSubItems = () => document.querySelector('.sidebar-sub-level-items');
+ const mockBoundingRects = () => {
+ const subItems = findSubItems();
+ mockBoundingClientRect(el, CONTAINER_INITIAL_BOUNDING_RECT);
+ mockBoundingClientRect(subItems, SUB_ITEMS_INITIAL_BOUNDING_RECT);
+ };
+ const mockSidebarFragment = (styleProps = '') =>
+ `<div class="sidebar-sub-level-items" style="${styleProps}"></div>`;
+
beforeEach(() => {
el = document.createElement('div');
el.style.position = 'relative';
document.body.appendChild(el);
- spyOn(GlBreakpointInstance, 'getBreakpointSize').and.callFake(() => breakpointSize);
-
- setOpenMenu(null);
+ jest.spyOn(GlBreakpointInstance, 'getBreakpointSize').mockImplementation(() => breakpointSize);
});
afterEach(() => {
@@ -52,24 +80,16 @@ describe('Fly out sidebar navigation', () => {
expect(calculateTop(boundingRect, 100)).toBe(100);
});
-
- it('returns boundingRect - bottomOverflow', () => {
- const boundingRect = {
- top: window.innerHeight - 50,
- height: 100,
- };
-
- expect(calculateTop(boundingRect, 100)).toBe(window.innerHeight - 50);
- });
});
describe('getHideSubItemsInterval', () => {
beforeEach(() => {
- el.innerHTML =
- '<div class="sidebar-sub-level-items" style="position: fixed; top: 0; left: 100px; height: 150px;"></div>';
+ el.innerHTML = mockSidebarFragment('position: fixed; top: 0; left: 100px; height: 150px;');
+ mockBoundingRects();
});
it('returns 0 if currentOpenMenu is nil', () => {
+ setOpenMenu(null);
expect(getHideSubItemsInterval()).toBe(0);
});
@@ -92,7 +112,7 @@ describe('Fly out sidebar navigation', () => {
});
it('returns 0 when mouse is below sub-items', () => {
- const subItems = el.querySelector('.sidebar-sub-level-items');
+ const subItems = findSubItems();
showSubLevelItems(el);
documentMouseMove({
@@ -112,6 +132,7 @@ describe('Fly out sidebar navigation', () => {
clientX: el.getBoundingClientRect().left,
clientY: el.getBoundingClientRect().top,
});
+
showSubLevelItems(el);
documentMouseMove({
clientX: el.getBoundingClientRect().left + 20,
@@ -124,17 +145,20 @@ describe('Fly out sidebar navigation', () => {
describe('mouseLeaveTopItem', () => {
beforeEach(() => {
- spyOn(el.classList, 'remove');
+ jest.spyOn(el.classList, 'remove');
});
it('removes is-over class if currentOpenMenu is null', () => {
+ setOpenMenu(null);
+
mouseLeaveTopItem(el);
expect(el.classList.remove).toHaveBeenCalledWith('is-over');
});
it('removes is-over class if currentOpenMenu is null & there are sub-items', () => {
- el.innerHTML = '<div class="sidebar-sub-level-items" style="position: absolute;"></div>';
+ setOpenMenu(null);
+ el.innerHTML = mockSidebarFragment('position: absolute');
mouseLeaveTopItem(el);
@@ -142,9 +166,10 @@ describe('Fly out sidebar navigation', () => {
});
it('does not remove is-over class if currentOpenMenu is the passed in sub-items', () => {
- el.innerHTML = '<div class="sidebar-sub-level-items" style="position: absolute;"></div>';
+ setOpenMenu(null);
+ el.innerHTML = mockSidebarFragment('position: absolute');
- setOpenMenu(el.querySelector('.sidebar-sub-level-items'));
+ setOpenMenu(findSubItems());
mouseLeaveTopItem(el);
expect(el.classList.remove).not.toHaveBeenCalled();
@@ -153,29 +178,33 @@ describe('Fly out sidebar navigation', () => {
describe('mouseEnterTopItems', () => {
beforeEach(() => {
- el.innerHTML =
- '<div class="sidebar-sub-level-items" style="position: absolute; top: 0; left: 100px; height: 200px;"></div>';
+ el.innerHTML = mockSidebarFragment(
+ `position: absolute; top: 0; left: 100px; height: ${OLD_SIDEBAR_WIDTH}px;`,
+ );
+ mockBoundingRects();
});
it('shows sub-items after 0ms if no menu is open', (done) => {
+ const subItems = findSubItems();
mouseEnterTopItems(el);
expect(getHideSubItemsInterval()).toBe(0);
setTimeout(() => {
- expect(el.querySelector('.sidebar-sub-level-items').style.display).toBe('block');
-
+ expect(subItems.style.display).toBe('block');
done();
});
});
it('shows sub-items after 300ms if a menu is currently open', (done) => {
+ const subItems = findSubItems();
+
documentMouseMove({
clientX: el.getBoundingClientRect().left,
clientY: el.getBoundingClientRect().top,
});
- setOpenMenu(el.querySelector('.sidebar-sub-level-items'));
+ setOpenMenu(subItems);
documentMouseMove({
clientX: el.getBoundingClientRect().left + 20,
@@ -184,10 +213,8 @@ describe('Fly out sidebar navigation', () => {
mouseEnterTopItems(el, 0);
- expect(getHideSubItemsInterval()).toBe(300);
-
setTimeout(() => {
- expect(el.querySelector('.sidebar-sub-level-items').style.display).toBe('block');
+ expect(subItems.style.display).toBe('block');
done();
});
@@ -196,11 +223,11 @@ describe('Fly out sidebar navigation', () => {
describe('showSubLevelItems', () => {
beforeEach(() => {
- el.innerHTML = '<div class="sidebar-sub-level-items" style="position: absolute;"></div>';
+ el.innerHTML = mockSidebarFragment('position: absolute');
});
it('adds is-over class to el', () => {
- spyOn(el.classList, 'add');
+ jest.spyOn(el.classList, 'add');
showSubLevelItems(el);
@@ -212,17 +239,17 @@ describe('Fly out sidebar navigation', () => {
showSubLevelItems(el);
- expect(el.querySelector('.sidebar-sub-level-items').style.display).not.toBe('block');
+ expect(findSubItems().style.display).not.toBe('block');
});
it('shows sub-items', () => {
showSubLevelItems(el);
- expect(el.querySelector('.sidebar-sub-level-items').style.display).toBe('block');
+ expect(findSubItems().style.display).toBe('block');
});
it('shows collapsed only sub-items if icon only sidebar', () => {
- const subItems = el.querySelector('.sidebar-sub-level-items');
+ const subItems = findSubItems();
const sidebar = document.createElement('div');
sidebar.classList.add(SIDEBAR_COLLAPSED_CLASS);
subItems.classList.add('is-fly-out-only');
@@ -231,11 +258,11 @@ describe('Fly out sidebar navigation', () => {
showSubLevelItems(el);
- expect(el.querySelector('.sidebar-sub-level-items').style.display).toBe('block');
+ expect(findSubItems().style.display).toBe('block');
});
it('does not show collapsed only sub-items if icon only sidebar', () => {
- const subItems = el.querySelector('.sidebar-sub-level-items');
+ const subItems = findSubItems();
subItems.classList.add('is-fly-out-only');
showSubLevelItems(el);
@@ -245,9 +272,9 @@ describe('Fly out sidebar navigation', () => {
it('sets transform of sub-items', () => {
const sidebar = document.createElement('div');
- const subItems = el.querySelector('.sidebar-sub-level-items');
+ const subItems = findSubItems();
- sidebar.style.width = '200px';
+ sidebar.style.width = `${OLD_SIDEBAR_WIDTH}px`;
document.body.appendChild(sidebar);
@@ -255,18 +282,20 @@ describe('Fly out sidebar navigation', () => {
showSubLevelItems(el);
expect(subItems.style.transform).toBe(
- `translate3d(200px, ${
+ `translate3d(${OLD_SIDEBAR_WIDTH}px, ${
Math.floor(el.getBoundingClientRect().top) - getHeaderHeight()
- }px, 0px)`,
+ }px, 0)`,
);
});
it('sets is-above when element is above', () => {
- const subItems = el.querySelector('.sidebar-sub-level-items');
+ const subItems = findSubItems();
+ mockBoundingRects();
+
subItems.style.height = `${window.innerHeight + el.offsetHeight}px`;
el.style.top = `${window.innerHeight - el.offsetHeight}px`;
- spyOn(subItems.classList, 'add');
+ jest.spyOn(subItems.classList, 'add');
showSubLevelItems(el);
@@ -313,9 +342,9 @@ describe('Fly out sidebar navigation', () => {
describe('subItemsMouseLeave', () => {
beforeEach(() => {
- el.innerHTML = '<div class="sidebar-sub-level-items" style="position: absolute;"></div>';
+ el.innerHTML = mockSidebarFragment('position: absolute');
- setOpenMenu(el.querySelector('.sidebar-sub-level-items'));
+ setOpenMenu(findSubItems());
});
it('hides subMenu if element is not hovered', () => {
diff --git a/spec/javascripts/lib/utils/browser_spec.js b/spec/frontend_integration/lib/utils/browser_spec.js
index f41fa2503b1..6c72e29076d 100644
--- a/spec/javascripts/lib/utils/browser_spec.js
+++ b/spec/frontend_integration/lib/utils/browser_spec.js
@@ -1,17 +1,18 @@
-/**
- * This file should only contain browser specific specs.
- * If you need to add or update a spec, please see spec/frontend/lib/utils/*.js
- * https://gitlab.com/gitlab-org/gitlab/issues/194242#note_292137135
- * https://gitlab.com/groups/gitlab-org/-/epics/895#what-if-theres-a-karma-spec-which-is-simply-unmovable-to-jest-ie-it-is-dependent-on-a-running-browser-environment
- */
-
import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils';
import * as commonUtils from '~/lib/utils/common_utils';
describe('common_utils browser specific specs', () => {
+ const mockOffsetHeight = (elem, offsetHeight) => {
+ Object.defineProperty(elem, 'offsetHeight', { value: offsetHeight });
+ };
+
+ const mockBoundingClientRect = (elem, rect) => {
+ jest.spyOn(elem, 'getBoundingClientRect').mockReturnValue(rect);
+ };
+
describe('contentTop', () => {
it('does not add height for fileTitle or compareVersionsHeader if screen is too small', () => {
- spyOn(breakpointInstance, 'isDesktop').and.returnValue(false);
+ jest.spyOn(breakpointInstance, 'isDesktop').mockReturnValue(false);
setFixtures(`
<div class="diff-file file-title-flex-parent">
@@ -26,7 +27,7 @@ describe('common_utils browser specific specs', () => {
});
it('adds height for fileTitle and compareVersionsHeader screen is large enough', () => {
- spyOn(breakpointInstance, 'isDesktop').and.returnValue(true);
+ jest.spyOn(breakpointInstance, 'isDesktop').mockReturnValue(true);
setFixtures(`
<div class="diff-file file-title-flex-parent">
@@ -37,6 +38,8 @@ describe('common_utils browser specific specs', () => {
</div>
`);
+ mockOffsetHeight(document.querySelector('.diff-file'), 100);
+ mockOffsetHeight(document.querySelector('.mr-version-controls'), 18);
expect(commonUtils.contentTop()).toBe(18);
});
});
@@ -54,6 +57,17 @@ describe('common_utils browser specific specs', () => {
it('returns true when provided `el` is in viewport', () => {
el.setAttribute('style', `position: absolute; right: ${window.innerWidth + 0.2};`);
+ mockBoundingClientRect(el, {
+ x: 8,
+ y: 8,
+ width: 0,
+ height: 0,
+ top: 8,
+ right: 8,
+ bottom: 8,
+ left: 8,
+ });
+
document.body.appendChild(el);
expect(commonUtils.isInViewport(el)).toBe(true);
@@ -61,6 +75,17 @@ describe('common_utils browser specific specs', () => {
it('returns false when provided `el` is not in viewport', () => {
el.setAttribute('style', 'position: absolute; top: -1000px; left: -1000px;');
+ mockBoundingClientRect(el, {
+ x: -1000,
+ y: -1000,
+ width: 0,
+ height: 0,
+ top: -1000,
+ right: -1000,
+ bottom: -1000,
+ left: -1000,
+ });
+
document.body.appendChild(el);
expect(commonUtils.isInViewport(el)).toBe(false);
diff --git a/spec/graphql/mutations/custom_emoji/destroy_spec.rb b/spec/graphql/mutations/custom_emoji/destroy_spec.rb
new file mode 100644
index 00000000000..4667812cc80
--- /dev/null
+++ b/spec/graphql/mutations/custom_emoji/destroy_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::CustomEmoji::Destroy do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:user) { create(:user) }
+ let_it_be_with_reload(:custom_emoji) { create(:custom_emoji, group: group) }
+
+ let(:args) { { id: custom_emoji.to_global_id } }
+ let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
+
+ context 'field tests' do
+ subject { described_class }
+
+ it { is_expected.to have_graphql_arguments(:id) }
+ it { is_expected.to have_graphql_field(:custom_emoji) }
+ end
+
+ shared_examples 'does not delete custom emoji' do
+ it 'raises exception' do
+ expect { subject }
+ .to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ shared_examples 'deletes custom emoji' do
+ it 'returns deleted custom emoji' do
+ result = subject
+
+ expect(result[:custom_emoji][:name]).to eq(custom_emoji.name)
+ end
+ end
+
+ describe '#resolve' do
+ subject { mutation.resolve(**args) }
+
+ context 'when the user' do
+ context 'has no permissions' do
+ it_behaves_like 'does not delete custom emoji'
+ end
+
+ context 'when the user is developer and not the owner of custom emoji' do
+ before do
+ group.add_developer(user)
+ end
+
+ it_behaves_like 'does not delete custom emoji'
+ end
+ end
+
+ context 'when user' do
+ context 'is maintainer' do
+ before do
+ group.add_maintainer(user)
+ end
+
+ it_behaves_like 'deletes custom emoji'
+ end
+
+ context 'is owner' do
+ before do
+ group.add_owner(user)
+ end
+
+ it_behaves_like 'deletes custom emoji'
+ end
+
+ context 'is developer and creator of the emoji' do
+ before do
+ group.add_developer(user)
+ custom_emoji.update_attribute(:creator, user)
+ end
+
+ it_behaves_like 'deletes custom emoji'
+ end
+ end
+ end
+end
diff --git a/spec/graphql/mutations/customer_relations/organizations/create_spec.rb b/spec/graphql/mutations/customer_relations/organizations/create_spec.rb
new file mode 100644
index 00000000000..ab430b9240b
--- /dev/null
+++ b/spec/graphql/mutations/customer_relations/organizations/create_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::CustomerRelations::Organizations::Create do
+ let_it_be(:user) { create(:user) }
+
+ let(:valid_params) do
+ attributes_for(:organization,
+ group: group,
+ description: 'This company is super important!',
+ default_rate: 1_000
+ )
+ end
+
+ describe 'create organizations mutation' do
+ describe '#resolve' do
+ subject(:resolve_mutation) do
+ described_class.new(object: nil, context: { current_user: user }, field: nil).resolve(
+ **valid_params,
+ group_id: group.to_global_id
+ )
+ end
+
+ context 'when the user does not have permission' do
+ let_it_be(:group) { create(:group) }
+
+ before do
+ group.add_guest(user)
+ end
+
+ it 'raises an error' do
+ expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when the user has permission' do
+ let_it_be(:group) { create(:group) }
+
+ before_all do
+ group.add_reporter(user)
+ end
+
+ context 'when the feature is disabled' do
+ before do
+ stub_feature_flags(customer_relations: false)
+ end
+
+ it 'raises an error' do
+ expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when the params are invalid' do
+ before do
+ valid_params[:name] = nil
+ end
+
+ it 'returns the validation error' do
+ expect(resolve_mutation[:errors]).to eq(["Name can't be blank"])
+ end
+ end
+
+ context 'when the user has permission to create an organization' do
+ it 'creates organization with correct values' do
+ expect(resolve_mutation[:organization]).to have_attributes(valid_params)
+ end
+ end
+ end
+ end
+ end
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_organization) }
+end
diff --git a/spec/graphql/mutations/customer_relations/organizations/update_spec.rb b/spec/graphql/mutations/customer_relations/organizations/update_spec.rb
new file mode 100644
index 00000000000..f5aa6c00301
--- /dev/null
+++ b/spec/graphql/mutations/customer_relations/organizations/update_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::CustomerRelations::Organizations::Update do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:name) { 'GitLab' }
+ let_it_be(:default_rate) { 1000.to_f }
+ let_it_be(:description) { 'VIP' }
+
+ let(:organization) { create(:organization, group: group) }
+ let(:attributes) do
+ {
+ id: organization.to_global_id,
+ name: name,
+ default_rate: default_rate,
+ description: description
+ }
+ end
+
+ describe '#resolve' do
+ subject(:resolve_mutation) do
+ described_class.new(object: nil, context: { current_user: user }, field: nil).resolve(
+ attributes
+ )
+ end
+
+ context 'when the user does not have permission to update an organization' do
+ let_it_be(:group) { create(:group) }
+
+ before do
+ group.add_guest(user)
+ end
+
+ it 'raises an error' do
+ expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when the organization does not exist' do
+ let_it_be(:group) { create(:group) }
+
+ it 'raises an error' do
+ attributes[:id] = 'gid://gitlab/CustomerRelations::Organization/999'
+
+ expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when the user has permission to update an organization' do
+ let_it_be(:group) { create(:group) }
+
+ before_all do
+ group.add_reporter(user)
+ end
+
+ it 'updates the organization with correct values' do
+ expect(resolve_mutation[:organization]).to have_attributes(attributes)
+ end
+
+ context 'when the feature is disabled' do
+ before do
+ stub_feature_flags(customer_relations: false)
+ end
+
+ it 'raises an error' do
+ expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+ end
+ end
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_organization) }
+end
diff --git a/spec/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb b/spec/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb
new file mode 100644
index 00000000000..792e87f0d25
--- /dev/null
+++ b/spec/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::DependencyProxy::ImageTtlGroupPolicy::Update do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be_with_reload(:group) { create(:group) }
+ let_it_be(:user) { create(:user) }
+
+ let(:params) { { group_path: group.full_path } }
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_dependency_proxy) }
+
+ describe '#resolve' do
+ subject { described_class.new(object: group, context: { current_user: user }, field: nil).resolve(**params) }
+
+ shared_examples 'returning a success' do
+ it 'returns the dependency proxy image ttl group policy with no errors' do
+ expect(subject).to eq(
+ dependency_proxy_image_ttl_policy: ttl_policy,
+ errors: []
+ )
+ end
+ end
+
+ shared_examples 'updating the dependency proxy image ttl policy' do
+ it_behaves_like 'updating the dependency proxy image ttl policy attributes',
+ from: { enabled: true, ttl: 90 },
+ to: { enabled: false, ttl: 2 }
+
+ it_behaves_like 'returning a success'
+
+ context 'with invalid params' do
+ let_it_be(:params) { { group_path: group.full_path, enabled: nil } }
+
+ it "doesn't create the dependency proxy image ttl policy" do
+ expect { subject }.not_to change { DependencyProxy::ImageTtlGroupPolicy.count }
+ end
+
+ it 'does not update' do
+ expect { subject }
+ .not_to change { ttl_policy.reload.enabled }
+ end
+
+ it 'returns an error' do
+ expect(subject).to eq(
+ dependency_proxy_image_ttl_policy: nil,
+ errors: ['Enabled is not included in the list']
+ )
+ end
+ end
+ end
+
+ shared_examples 'denying access to dependency proxy image ttl policy' do
+ it 'raises Gitlab::Graphql::Errors::ResourceNotAvailable' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ before do
+ stub_config(dependency_proxy: { enabled: true })
+ end
+
+ context 'with existing dependency proxy image ttl policy' do
+ let_it_be(:ttl_policy) { create(:image_ttl_group_policy, group: group) }
+ let_it_be(:params) do
+ { group_path: group.full_path,
+ enabled: false,
+ ttl: 2 }
+ end
+
+ where(:user_role, :shared_examples_name) do
+ :maintainer | 'updating the dependency proxy image ttl policy'
+ :developer | 'updating the dependency proxy image ttl policy'
+ :reporter | 'denying access to dependency proxy image ttl policy'
+ :guest | 'denying access to dependency proxy image ttl policy'
+ :anonymous | 'denying access to dependency proxy image ttl policy'
+ end
+
+ with_them do
+ before do
+ group.send("add_#{user_role}", user) unless user_role == :anonymous
+ end
+
+ it_behaves_like params[:shared_examples_name]
+ end
+ end
+
+ context 'without existing dependency proxy image ttl policy' do
+ let_it_be(:ttl_policy) { group.dependency_proxy_image_ttl_policy }
+
+ where(:user_role, :shared_examples_name) do
+ :maintainer | 'creating the dependency proxy image ttl policy'
+ :developer | 'creating the dependency proxy image ttl policy'
+ :reporter | 'denying access to dependency proxy image ttl policy'
+ :guest | 'denying access to dependency proxy image ttl policy'
+ :anonymous | 'denying access to dependency proxy image ttl policy'
+ end
+
+ with_them do
+ before do
+ group.send("add_#{user_role}", user) unless user_role == :anonymous
+ end
+
+ it_behaves_like params[:shared_examples_name]
+ end
+ end
+ end
+end
diff --git a/spec/graphql/resolvers/board_list_issues_resolver_spec.rb b/spec/graphql/resolvers/board_list_issues_resolver_spec.rb
index 6ffc8b045e9..26040f4ec1a 100644
--- a/spec/graphql/resolvers/board_list_issues_resolver_spec.rb
+++ b/spec/graphql/resolvers/board_list_issues_resolver_spec.rb
@@ -17,21 +17,38 @@ RSpec.describe Resolvers::BoardListIssuesResolver do
# auth is handled by the parent object
context 'when authorized' do
- let!(:issue1) { create(:issue, project: project, labels: [label], relative_position: 10) }
- let!(:issue2) { create(:issue, project: project, labels: [label, label2], relative_position: 12) }
- let!(:issue3) { create(:issue, project: project, labels: [label, label3], relative_position: 10) }
+ let!(:issue1) { create(:issue, project: project, labels: [label], relative_position: 10, milestone: started_milestone) }
+ let!(:issue2) { create(:issue, project: project, labels: [label, label2], relative_position: 12, milestone: started_milestone) }
+ let!(:issue3) { create(:issue, project: project, labels: [label, label3], relative_position: 10, milestone: future_milestone) }
+ let!(:issue4) { create(:issue, project: project, labels: [label], relative_position: nil) }
- it 'returns the issues in the correct order' do
+ let(:wildcard_started) { 'STARTED' }
+ let(:filters) { { milestone_title: ["started"], milestone_wildcard_id: wildcard_started } }
+
+ it 'raises a mutually exclusive filter error when milstone wildcard and title are provided' do
+ expect do
+ resolve_board_list_issues(args: { filters: filters })
+ end.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
+ end
+
+ it 'returns issues in the correct order with non-nil relative positions', :aggregate_failures do
# by relative_position and then ID
- issues = resolve_board_list_issues
+ result = resolve_board_list_issues
- expect(issues.map(&:id)).to eq [issue3.id, issue1.id, issue2.id]
+ expect(result.map(&:id)).to eq [issue3.id, issue1.id, issue2.id, issue4.id]
+ expect(result.map(&:relative_position)).not_to include(nil)
end
it 'finds only issues matching filters' do
result = resolve_board_list_issues(args: { filters: { label_name: [label.title], not: { label_name: [label2.title] } } })
- expect(result).to match_array([issue1, issue3])
+ expect(result).to match_array([issue1, issue3, issue4])
+ end
+
+ it 'finds only issues filtered by milestone wildcard' do
+ result = resolve_board_list_issues(args: { filters: { milestone_wildcard_id: wildcard_started } })
+
+ expect(result).to match_array([issue1, issue2])
end
it 'finds only issues matching search param' do
@@ -49,7 +66,7 @@ RSpec.describe Resolvers::BoardListIssuesResolver do
it 'accepts assignee wildcard id NONE' do
result = resolve_board_list_issues(args: { filters: { assignee_wildcard_id: 'NONE' } })
- expect(result).to match_array([issue1, issue2, issue3])
+ expect(result).to match_array([issue1, issue2, issue3, issue4])
end
it 'accepts assignee wildcard id ANY' do
@@ -71,6 +88,9 @@ RSpec.describe Resolvers::BoardListIssuesResolver do
let(:board_parent) { user_project }
let(:project) { user_project }
+ let_it_be(:started_milestone) { create(:milestone, project: user_project, title: 'started milestone', start_date: 1.day.ago, due_date: 1.day.from_now) }
+ let_it_be(:future_milestone) { create(:milestone, project: user_project, title: 'future milestone', start_date: 1.day.from_now) }
+
it_behaves_like 'group and project board list issues resolver'
end
@@ -84,11 +104,14 @@ RSpec.describe Resolvers::BoardListIssuesResolver do
let(:board_parent) { group }
let!(:project) { create(:project, :private, group: group) }
+ let_it_be(:started_milestone) { create(:milestone, group: group, title: 'started milestone', start_date: 1.day.ago, due_date: 1.day.from_now) }
+ let_it_be(:future_milestone) { create(:milestone, group: group, title: 'future milestone', start_date: 1.day.from_now) }
+
it_behaves_like 'group and project board list issues resolver'
end
end
def resolve_board_list_issues(args: {}, current_user: user)
- resolve(described_class, obj: list, args: args, ctx: { current_user: current_user })
+ resolve(described_class, obj: list, args: args, ctx: { current_user: current_user }).items
end
end
diff --git a/spec/graphql/resolvers/ci/group_runners_resolver_spec.rb b/spec/graphql/resolvers/ci/group_runners_resolver_spec.rb
new file mode 100644
index 00000000000..89a2437a189
--- /dev/null
+++ b/spec/graphql/resolvers/ci/group_runners_resolver_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::Ci::GroupRunnersResolver do
+ include GraphqlHelpers
+
+ describe '#resolve' do
+ subject { resolve(described_class, obj: obj, ctx: { current_user: user }, args: args) }
+
+ include_context 'runners resolver setup'
+
+ let(:obj) { group }
+ let(:args) { {} }
+
+ # First, we can do a couple of basic real tests to verify common cases. That ensures that the code works.
+ context 'when user cannot see runners' do
+ it 'returns no runners' do
+ expect(subject.items.to_a).to eq([])
+ end
+ end
+
+ context 'with user as group owner' do
+ before do
+ group.add_owner(user)
+ end
+
+ it 'returns all the runners' do
+ expect(subject.items.to_a).to contain_exactly(inactive_project_runner, offline_project_runner, group_runner, subgroup_runner)
+ end
+
+ context 'with membership direct' do
+ let(:args) { { membership: :direct } }
+
+ it 'returns only direct runners' do
+ expect(subject.items.to_a).to contain_exactly(group_runner)
+ end
+ end
+ end
+
+ # Then, we can check specific edge cases for this resolver
+ context 'with obj set to nil' do
+ let(:obj) { nil }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error('Expected group missing')
+ end
+ end
+
+ context 'with obj not set to group' do
+ let(:obj) { build(:project) }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error('Expected group missing')
+ end
+ end
+
+ # Here we have a mocked part. We assume that all possible edge cases are covered in RunnersFinder spec. So we don't need to test them twice.
+ # Only thing we can do is to verify that args from the resolver is correctly transformed to params of the Finder and we return the Finder's result back.
+ describe 'Allowed query arguments' do
+ let(:finder) { instance_double(::Ci::RunnersFinder) }
+ let(:args) do
+ {
+ status: 'active',
+ type: :group_type,
+ tag_list: ['active_runner'],
+ search: 'abc',
+ sort: :contacted_asc,
+ membership: :descendants
+ }
+ end
+
+ let(:expected_params) do
+ {
+ status_status: 'active',
+ type_type: :group_type,
+ tag_name: ['active_runner'],
+ preload: { tag_name: nil },
+ search: 'abc',
+ sort: 'contacted_asc',
+ membership: :descendants,
+ group: group
+ }
+ end
+
+ it 'calls RunnersFinder with expected arguments' do
+ allow(::Ci::RunnersFinder).to receive(:new).with(current_user: user, params: expected_params).once.and_return(finder)
+ allow(finder).to receive(:execute).once.and_return([:execute_return_value])
+
+ expect(subject.items.to_a).to eq([:execute_return_value])
+ end
+ end
+ end
+end
diff --git a/spec/graphql/resolvers/ci/runners_resolver_spec.rb b/spec/graphql/resolvers/ci/runners_resolver_spec.rb
index 5ac15d5729f..bb8dadeca40 100644
--- a/spec/graphql/resolvers/ci/runners_resolver_spec.rb
+++ b/spec/graphql/resolvers/ci/runners_resolver_spec.rb
@@ -5,185 +5,70 @@ require 'spec_helper'
RSpec.describe Resolvers::Ci::RunnersResolver do
include GraphqlHelpers
- let_it_be(:user) { create_default(:user, :admin) }
- let_it_be(:group) { create(:group, :public) }
- let_it_be(:project) { create(:project, :repository, :public) }
-
- let_it_be(:inactive_project_runner) do
- create(:ci_runner, :project, projects: [project], description: 'inactive project runner', token: 'abcdef', active: false, contacted_at: 1.minute.ago, tag_list: %w(project_runner))
- end
-
- let_it_be(:offline_project_runner) do
- create(:ci_runner, :project, projects: [project], description: 'offline project runner', token: 'defghi', contacted_at: 1.day.ago, tag_list: %w(project_runner active_runner))
- end
-
- let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], token: 'mnopqr', description: 'group runner', contacted_at: 1.second.ago) }
- let_it_be(:instance_runner) { create(:ci_runner, :instance, description: 'shared runner', token: 'stuvxz', contacted_at: 2.minutes.ago, tag_list: %w(instance_runner active_runner)) }
-
describe '#resolve' do
- subject { resolve(described_class, ctx: { current_user: user }, args: args).items.to_a }
-
- let(:args) do
- {}
- end
-
- context 'when the user cannot see runners' do
- let(:user) { create(:user) }
-
- it 'returns no runners' do
- is_expected.to be_empty
- end
- end
-
- context 'without sort' do
- it 'returns all the runners' do
- is_expected.to contain_exactly(inactive_project_runner, offline_project_runner, group_runner, instance_runner)
- end
- end
-
- context 'with a sort argument' do
- context "set to :contacted_asc" do
- let(:args) do
- { sort: :contacted_asc }
- end
-
- it { is_expected.to eq([offline_project_runner, instance_runner, inactive_project_runner, group_runner]) }
- end
-
- context "set to :contacted_desc" do
- let(:args) do
- { sort: :contacted_desc }
- end
-
- it { is_expected.to eq([offline_project_runner, instance_runner, inactive_project_runner, group_runner].reverse) }
- end
-
- context "set to :created_at_desc" do
- let(:args) do
- { sort: :created_at_desc }
- end
+ let(:obj) { nil }
+ let(:args) { {} }
- it { is_expected.to eq([instance_runner, group_runner, offline_project_runner, inactive_project_runner]) }
- end
-
- context "set to :created_at_asc" do
- let(:args) do
- { sort: :created_at_asc }
- end
-
- it { is_expected.to eq([instance_runner, group_runner, offline_project_runner, inactive_project_runner].reverse) }
- end
- end
+ subject { resolve(described_class, obj: obj, ctx: { current_user: user }, args: args) }
- context 'when type is filtered' do
- let(:args) do
- { type: runner_type.to_s }
- end
+ include_context 'runners resolver setup'
- context 'to instance runners' do
- let(:runner_type) { :instance_type }
+ # First, we can do a couple of basic real tests to verify common cases. That ensures that the code works.
+ context 'when user cannot see runners' do
+ let(:user) { build(:user) }
- it 'returns the instance runner' do
- is_expected.to contain_exactly(instance_runner)
- end
- end
-
- context 'to group runners' do
- let(:runner_type) { :group_type }
-
- it 'returns the group runner' do
- is_expected.to contain_exactly(group_runner)
- end
- end
-
- context 'to project runners' do
- let(:runner_type) { :project_type }
-
- it 'returns the project runner' do
- is_expected.to contain_exactly(inactive_project_runner, offline_project_runner)
- end
+ it 'returns no runners' do
+ expect(subject.items.to_a).to eq([])
end
end
- context 'when status is filtered' do
- let(:args) do
- { status: runner_status.to_s }
- end
-
- context 'to active runners' do
- let(:runner_status) { :active }
-
- it 'returns the instance and group runners' do
- is_expected.to contain_exactly(offline_project_runner, group_runner, instance_runner)
- end
- end
-
- context 'to offline runners' do
- let(:runner_status) { :offline }
+ context 'when user can see runners' do
+ let(:obj) { nil }
- it 'returns the offline project runner' do
- is_expected.to contain_exactly(offline_project_runner)
- end
+ it 'returns all the runners' do
+ expect(subject.items.to_a).to contain_exactly(inactive_project_runner, offline_project_runner, group_runner, subgroup_runner, instance_runner)
end
end
- context 'when tag list is filtered' do
- let(:args) do
- { tag_list: tag_list }
- end
-
- context 'with "project_runner" tag' do
- let(:tag_list) { ['project_runner'] }
+ # Then, we can check specific edge cases for this resolver
+ context 'with obj not set to nil' do
+ let(:obj) { build(:project) }
- it 'returns the project_runner runners' do
- is_expected.to contain_exactly(offline_project_runner, inactive_project_runner)
- end
- end
-
- context 'with "project_runner" and "active_runner" tags as comma-separated string' do
- let(:tag_list) { ['project_runner,active_runner'] }
-
- it 'returns the offline_project_runner runner' do
- is_expected.to contain_exactly(offline_project_runner)
- end
- end
-
- context 'with "active_runner" and "instance_runner" tags as array' do
- let(:tag_list) { %w[instance_runner active_runner] }
-
- it 'returns the offline_project_runner runner' do
- is_expected.to contain_exactly(instance_runner)
- end
+ it 'raises an error' do
+ expect { subject }.to raise_error(a_string_including('Unexpected parent type'))
end
end
- context 'when text is filtered' do
+ # Here we have a mocked part. We assume that all possible edge cases are covered in RunnersFinder spec. So we don't need to test them twice.
+ # Only thing we can do is to verify that args from the resolver is correctly transformed to params of the Finder and we return the Finder's result back.
+ describe 'Allowed query arguments' do
+ let(:finder) { instance_double(::Ci::RunnersFinder) }
let(:args) do
- { search: search_term }
- end
-
- context 'to "project"' do
- let(:search_term) { 'project' }
-
- it 'returns both project runners' do
- is_expected.to contain_exactly(inactive_project_runner, offline_project_runner)
- end
- end
-
- context 'to "group"' do
- let(:search_term) { 'group' }
-
- it 'returns group runner' do
- is_expected.to contain_exactly(group_runner)
- end
- end
-
- context 'to "defghi"' do
- let(:search_term) { 'defghi' }
-
- it 'returns runners containing term in token' do
- is_expected.to contain_exactly(offline_project_runner)
- end
+ {
+ status: 'active',
+ type: :instance_type,
+ tag_list: ['active_runner'],
+ search: 'abc',
+ sort: :contacted_asc
+ }
+ end
+
+ let(:expected_params) do
+ {
+ status_status: 'active',
+ type_type: :instance_type,
+ tag_name: ['active_runner'],
+ preload: { tag_name: nil },
+ search: 'abc',
+ sort: 'contacted_asc'
+ }
+ end
+
+ it 'calls RunnersFinder with expected arguments' do
+ allow(::Ci::RunnersFinder).to receive(:new).with(current_user: user, params: expected_params).once.and_return(finder)
+ allow(finder).to receive(:execute).once.and_return([:execute_return_value])
+
+ expect(subject.items.to_a).to eq([:execute_return_value])
end
end
end
diff --git a/spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb b/spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb
index 6f6855c8f84..865e892b12d 100644
--- a/spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb
+++ b/spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe ResolvesPipelines do
project.add_developer(current_user)
end
- it { is_expected.to have_graphql_arguments(:status, :ref, :sha) }
+ it { is_expected.to have_graphql_arguments(:status, :ref, :sha, :source) }
it 'finds all pipelines' do
expect(resolve_pipelines).to contain_exactly(pipeline, failed_pipeline, ref_pipeline, sha_pipeline)
@@ -45,6 +45,30 @@ RSpec.describe ResolvesPipelines do
expect(resolve_pipelines(sha: 'deadbeef')).to contain_exactly(sha_pipeline)
end
+ context 'filtering by source' do
+ let_it_be(:source_pipeline) { create(:ci_pipeline, project: project, source: 'web') }
+
+ context 'when `dast_view_scans` feature flag is disabled' do
+ before do
+ stub_feature_flags(dast_view_scans: false)
+ end
+
+ it 'does not filter by source' do
+ expect(resolve_pipelines(source: 'web')).to contain_exactly(pipeline, failed_pipeline, ref_pipeline, sha_pipeline, source_pipeline)
+ end
+ end
+
+ context 'when `dast_view_scans` feature flag is enabled' do
+ it 'does filter by source' do
+ expect(resolve_pipelines(source: 'web')).to contain_exactly(source_pipeline)
+ end
+
+ it 'returns all the pipelines' do
+ expect(resolve_pipelines).to contain_exactly(pipeline, failed_pipeline, ref_pipeline, sha_pipeline, source_pipeline)
+ end
+ end
+ end
+
it 'does not return any pipelines if the user does not have access' do
expect(resolve_pipelines({}, {})).to be_empty
end
diff --git a/spec/graphql/resolvers/group_resolver_spec.rb b/spec/graphql/resolvers/group_resolver_spec.rb
index a03e7854177..ed406d14772 100644
--- a/spec/graphql/resolvers/group_resolver_spec.rb
+++ b/spec/graphql/resolvers/group_resolver_spec.rb
@@ -20,10 +20,15 @@ RSpec.describe Resolvers::GroupResolver do
end
it 'resolves an unknown full_path to nil' do
- result = batch_sync { resolve_group('unknown/project') }
+ result = batch_sync { resolve_group('unknown/group') }
expect(result).to be_nil
end
+
+ it 'treats group full path as case insensitive' do
+ result = batch_sync { resolve_group(group1.full_path.upcase) }
+ expect(result).to eq group1
+ end
end
def resolve_group(full_path)
diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb
index 6e187e57729..e992b2b04ae 100644
--- a/spec/graphql/resolvers/issues_resolver_spec.rb
+++ b/spec/graphql/resolvers/issues_resolver_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe Resolvers::IssuesResolver do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
+ let_it_be(:reporter) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
@@ -19,6 +20,7 @@ RSpec.describe Resolvers::IssuesResolver do
let_it_be(:issue4) { create(:issue) }
let_it_be(:label1) { create(:label, project: project) }
let_it_be(:label2) { create(:label, project: project) }
+ let_it_be(:upvote_award) { create(:award_emoji, :upvote, user: current_user, awardable: issue1) }
specify do
expect(described_class).to have_nullable_graphql_type(Types::IssueType.connection_type)
@@ -27,6 +29,7 @@ RSpec.describe Resolvers::IssuesResolver do
context "with a project" do
before_all do
project.add_developer(current_user)
+ project.add_reporter(reporter)
create(:label_link, label: label1, target: issue1)
create(:label_link, label: label1, target: issue2)
create(:label_link, label: label2, target: issue2)
@@ -198,6 +201,27 @@ RSpec.describe Resolvers::IssuesResolver do
end
end
+ context 'filtering by reaction emoji' do
+ let_it_be(:downvoted_issue) { create(:issue, project: project) }
+ let_it_be(:downvote_award) { create(:award_emoji, :downvote, user: current_user, awardable: downvoted_issue) }
+
+ it 'filters by reaction emoji' do
+ expect(resolve_issues(my_reaction_emoji: upvote_award.name)).to contain_exactly(issue1)
+ end
+
+ it 'filters by reaction emoji wildcard "none"' do
+ expect(resolve_issues(my_reaction_emoji: 'none')).to contain_exactly(issue2)
+ end
+
+ it 'filters by reaction emoji wildcard "any"' do
+ expect(resolve_issues(my_reaction_emoji: 'any')).to contain_exactly(issue1, downvoted_issue)
+ end
+
+ it 'filters by negated reaction emoji' do
+ expect(resolve_issues(not: { my_reaction_emoji: downvote_award.name })).to contain_exactly(issue1, issue2)
+ end
+ end
+
context 'when searching issues' do
it 'returns correct issues' do
expect(resolve_issues(search: 'foo')).to contain_exactly(issue2)
@@ -235,6 +259,14 @@ RSpec.describe Resolvers::IssuesResolver do
it 'returns issues without the specified assignee_id' do
expect(resolve_issues(not: { assignee_id: [assignee.id] })).to contain_exactly(issue1)
end
+
+ context 'when filtering by negated author' do
+ let_it_be(:issue_by_reporter) { create(:issue, author: reporter, project: project, state: :opened) }
+
+ it 'returns issues without the specified author_username' do
+ expect(resolve_issues(not: { author_username: issue1.author.username })).to contain_exactly(issue_by_reporter)
+ end
+ end
end
describe 'sorting' do
@@ -382,6 +414,22 @@ RSpec.describe Resolvers::IssuesResolver do
end
end
end
+
+ context 'when sorting by title' do
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:issue1) { create(:issue, project: project, title: 'foo') }
+ let_it_be(:issue2) { create(:issue, project: project, title: 'bar') }
+ let_it_be(:issue3) { create(:issue, project: project, title: 'baz') }
+ let_it_be(:issue4) { create(:issue, project: project, title: 'Baz 2') }
+
+ it 'sorts issues ascending' do
+ expect(resolve_issues(sort: :title_asc).to_a).to eq [issue2, issue3, issue4, issue1]
+ end
+
+ it 'sorts issues descending' do
+ expect(resolve_issues(sort: :title_desc).to_a).to eq [issue1, issue4, issue3, issue2]
+ end
+ end
end
it 'returns issues user can see' do
diff --git a/spec/graphql/resolvers/merge_requests_resolver_spec.rb b/spec/graphql/resolvers/merge_requests_resolver_spec.rb
index 64ee0d4f9cc..a897acf7eba 100644
--- a/spec/graphql/resolvers/merge_requests_resolver_spec.rb
+++ b/spec/graphql/resolvers/merge_requests_resolver_spec.rb
@@ -294,16 +294,6 @@ RSpec.describe Resolvers::MergeRequestsResolver do
nils_last(mr.metrics.merged_at)
end
- context 'when label filter is given and the optimized_issuable_label_filter feature flag is off' do
- before do
- stub_feature_flags(optimized_issuable_label_filter: false)
- end
-
- it 'does not raise PG::GroupingError' do
- expect { resolve_mr(project, sort: :merged_at_desc, labels: %w[a b]) }.not_to raise_error
- end
- end
-
context 'when sorting by closed at' do
before do
merge_request_1.metrics.update!(latest_closed_at: 10.days.ago)
diff --git a/spec/graphql/resolvers/project_resolver_spec.rb b/spec/graphql/resolvers/project_resolver_spec.rb
index d0661c27b95..cd3fdc788e6 100644
--- a/spec/graphql/resolvers/project_resolver_spec.rb
+++ b/spec/graphql/resolvers/project_resolver_spec.rb
@@ -25,6 +25,11 @@ RSpec.describe Resolvers::ProjectResolver do
expect(result).to be_nil
end
+
+ it 'treats project full path as case insensitive' do
+ result = batch_sync { resolve_project(project1.full_path.upcase) }
+ expect(result).to eq project1
+ end
end
it 'does not increase complexity depending on number of load limits' do
diff --git a/spec/graphql/resolvers/users/groups_resolver_spec.rb b/spec/graphql/resolvers/users/groups_resolver_spec.rb
new file mode 100644
index 00000000000..0fdb6da5ae9
--- /dev/null
+++ b/spec/graphql/resolvers/users/groups_resolver_spec.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::Users::GroupsResolver do
+ include GraphqlHelpers
+ include AdminModeHelper
+
+ describe '#resolve' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:guest_group) { create(:group, name: 'public guest', path: 'public-guest') }
+ let_it_be(:private_maintainer_group) { create(:group, :private, name: 'b private maintainer', path: 'b-private-maintainer') }
+ let_it_be(:public_developer_group) { create(:group, project_creation_level: nil, name: 'c public developer', path: 'c-public-developer') }
+ let_it_be(:public_maintainer_group) { create(:group, name: 'a public maintainer', path: 'a-public-maintainer') }
+
+ subject(:resolved_items) { resolve_groups(args: group_arguments, current_user: current_user, obj: resolver_object) }
+
+ let(:group_arguments) { {} }
+ let(:current_user) { user }
+ let(:resolver_object) { user }
+
+ before_all do
+ guest_group.add_guest(user)
+ private_maintainer_group.add_maintainer(user)
+ public_developer_group.add_developer(user)
+ public_maintainer_group.add_maintainer(user)
+ end
+
+ context 'when paginatable_namespace_drop_down_for_project_creation feature flag is disabled' do
+ before do
+ stub_feature_flags(paginatable_namespace_drop_down_for_project_creation: false)
+ end
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when resolver object is current user' do
+ context 'when permission is :create_projects' do
+ let(:group_arguments) { { permission_scope: :create_projects } }
+
+ specify do
+ is_expected.to match(
+ [
+ public_maintainer_group,
+ private_maintainer_group,
+ public_developer_group
+ ]
+ )
+ end
+ end
+
+ specify do
+ is_expected.to match(
+ [
+ public_maintainer_group,
+ private_maintainer_group,
+ public_developer_group,
+ guest_group
+ ]
+ )
+ end
+
+ context 'when search is provided' do
+ let(:group_arguments) { { search: 'maintainer' } }
+
+ specify do
+ is_expected.to match(
+ [
+ public_maintainer_group,
+ private_maintainer_group
+ ]
+ )
+ end
+ end
+ end
+
+ context 'when resolver object is different from current user' do
+ let(:current_user) { create(:user) }
+
+ it { is_expected.to be_nil }
+
+ context 'when current_user is admin' do
+ let(:current_user) { create(:user, :admin) }
+
+ before do
+ enable_admin_mode!(current_user)
+ end
+
+ specify do
+ is_expected.to match(
+ [
+ public_maintainer_group,
+ private_maintainer_group,
+ public_developer_group,
+ guest_group
+ ]
+ )
+ end
+ end
+ end
+ end
+
+ def resolve_groups(args:, current_user:, obj:)
+ resolve(described_class, args: args, ctx: { current_user: current_user }, obj: obj)&.items
+ end
+end
diff --git a/spec/graphql/types/ci/job_type_spec.rb b/spec/graphql/types/ci/job_type_spec.rb
index 54fe0c4b707..e95a7da4fe5 100644
--- a/spec/graphql/types/ci/job_type_spec.rb
+++ b/spec/graphql/types/ci/job_type_spec.rb
@@ -4,7 +4,6 @@ require 'spec_helper'
RSpec.describe Types::Ci::JobType do
specify { expect(described_class.graphql_name).to eq('CiJob') }
- specify { expect(described_class).to require_graphql_authorizations(:read_commit_status) }
specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Ci::Job) }
it 'exposes the expected fields' do
diff --git a/spec/graphql/types/customer_relations/contact_type_spec.rb b/spec/graphql/types/customer_relations/contact_type_spec.rb
new file mode 100644
index 00000000000..a51ee705fb0
--- /dev/null
+++ b/spec/graphql/types/customer_relations/contact_type_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['CustomerRelationsContact'] do
+ let(:fields) { %i[id organization first_name last_name phone email description created_at updated_at] }
+
+ it { expect(described_class.graphql_name).to eq('CustomerRelationsContact') }
+ it { expect(described_class).to have_graphql_fields(fields) }
+ it { expect(described_class).to require_graphql_authorizations(:read_contact) }
+end
diff --git a/spec/graphql/types/customer_relations/organization_type_spec.rb b/spec/graphql/types/customer_relations/organization_type_spec.rb
new file mode 100644
index 00000000000..2562748477c
--- /dev/null
+++ b/spec/graphql/types/customer_relations/organization_type_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['CustomerRelationsOrganization'] do
+ let(:fields) { %i[id name default_rate description created_at updated_at] }
+
+ it { expect(described_class.graphql_name).to eq('CustomerRelationsOrganization') }
+ it { expect(described_class).to have_graphql_fields(fields) }
+ it { expect(described_class).to require_graphql_authorizations(:read_organization) }
+end
diff --git a/spec/graphql/types/dependency_proxy/blob_type_spec.rb b/spec/graphql/types/dependency_proxy/blob_type_spec.rb
new file mode 100644
index 00000000000..e1c8471975e
--- /dev/null
+++ b/spec/graphql/types/dependency_proxy/blob_type_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['DependencyProxyBlob'] do
+ it 'includes dependency proxy blob fields' do
+ expected_fields = %w[
+ file_name size created_at updated_at
+ ]
+
+ expect(described_class).to include_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/dependency_proxy/group_setting_type_spec.rb b/spec/graphql/types/dependency_proxy/group_setting_type_spec.rb
new file mode 100644
index 00000000000..7c6d7b8aece
--- /dev/null
+++ b/spec/graphql/types/dependency_proxy/group_setting_type_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['DependencyProxySetting'] do
+ it 'includes dependency proxy blob fields' do
+ expected_fields = %w[
+ enabled
+ ]
+
+ expect(described_class).to include_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/dependency_proxy/image_ttl_group_policy_type_spec.rb b/spec/graphql/types/dependency_proxy/image_ttl_group_policy_type_spec.rb
new file mode 100644
index 00000000000..46347e0434f
--- /dev/null
+++ b/spec/graphql/types/dependency_proxy/image_ttl_group_policy_type_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['DependencyProxyImageTtlGroupPolicy'] do
+ it { expect(described_class.graphql_name).to eq('DependencyProxyImageTtlGroupPolicy') }
+
+ it { expect(described_class.description).to eq('Group-level Dependency Proxy TTL policy settings') }
+
+ it { expect(described_class).to require_graphql_authorizations(:read_dependency_proxy) }
+
+ it 'includes dependency proxy image ttl policy fields' do
+ expected_fields = %w[enabled ttl created_at updated_at]
+
+ expect(described_class).to have_graphql_fields(*expected_fields).only
+ end
+end
diff --git a/spec/graphql/types/dependency_proxy/manifest_type_spec.rb b/spec/graphql/types/dependency_proxy/manifest_type_spec.rb
new file mode 100644
index 00000000000..18cc89adfcb
--- /dev/null
+++ b/spec/graphql/types/dependency_proxy/manifest_type_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['DependencyProxyManifest'] do
+ it 'includes dependency proxy manifest fields' do
+ expected_fields = %w[
+ file_name image_name size created_at updated_at digest
+ ]
+
+ expect(described_class).to include_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/group_type_spec.rb b/spec/graphql/types/group_type_spec.rb
index 33250f8e6af..dca2c930eea 100644
--- a/spec/graphql/types/group_type_spec.rb
+++ b/spec/graphql/types/group_type_spec.rb
@@ -18,7 +18,11 @@ 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 shared_runners_setting timelogs
+ packages dependency_proxy_setting dependency_proxy_manifests
+ dependency_proxy_blobs dependency_proxy_image_count
+ dependency_proxy_blob_count dependency_proxy_total_size
+ dependency_proxy_image_prefix dependency_proxy_image_ttl_policy
+ shared_runners_setting timelogs organizations contacts
]
expect(described_class).to include_graphql_fields(*expected_fields)
diff --git a/spec/graphql/types/issue_type_spec.rb b/spec/graphql/types/issue_type_spec.rb
index b0aa11ee5ad..559f347810b 100644
--- a/spec/graphql/types/issue_type_spec.rb
+++ b/spec/graphql/types/issue_type_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe GitlabSchema.types['Issue'] do
it 'has specific fields' do
fields = %i[id iid title description state reference author assignees updated_by participants labels milestone due_date
- confidential discussion_locked upvotes downvotes merge_requests_count user_notes_count user_discussions_count web_path web_url relative_position
+ confidential hidden discussion_locked upvotes downvotes merge_requests_count user_notes_count user_discussions_count web_path web_url relative_position
emails_disabled subscribed time_estimate total_time_spent human_time_estimate human_total_time_spent closed_at created_at updated_at task_completion_status
design_collection alert_management_alert severity current_user_todos moved moved_to
create_note_email timelogs project_id]
@@ -201,4 +201,54 @@ RSpec.describe GitlabSchema.types['Issue'] do
end
end
end
+
+ describe 'hidden', :enable_admin_mode do
+ let_it_be(:admin) { create(:user, :admin)}
+ let_it_be(:banned_user) { create(:user, :banned) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:hidden_issue) { create(:issue, project: project, author: banned_user) }
+ let_it_be(:visible_issue) { create(:issue, project: project, author: user) }
+
+ let(:issue) { hidden_issue }
+ let(:query) do
+ %(
+ query {
+ project(fullPath: "#{project.full_path}") {
+ issue(iid: "#{issue.iid}") {
+ hidden
+ }
+ }
+ }
+ )
+ end
+
+ subject { GitlabSchema.execute(query, context: { current_user: admin }).as_json }
+
+ context 'when `ban_user_feature_flag` is enabled' do
+ context 'when issue is hidden' do
+ it 'returns `true`' do
+ expect(subject.dig('data', 'project', 'issue', 'hidden')).to eq(true)
+ end
+ end
+
+ context 'when issue is visible' do
+ let(:issue) { visible_issue }
+
+ it 'returns `false`' do
+ expect(subject.dig('data', 'project', 'issue', 'hidden')).to eq(false)
+ end
+ end
+ end
+
+ context 'when `ban_user_feature_flag` is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it 'returns `nil`' do
+ expect(subject.dig('data', 'project', 'issue', 'hidden')).to be_nil
+ end
+ end
+ end
end
diff --git a/spec/graphql/types/merge_requests/reviewer_type_spec.rb b/spec/graphql/types/merge_requests/reviewer_type_spec.rb
index 4ede8e5788f..4d357a922f8 100644
--- a/spec/graphql/types/merge_requests/reviewer_type_spec.rb
+++ b/spec/graphql/types/merge_requests/reviewer_type_spec.rb
@@ -33,6 +33,7 @@ RSpec.describe GitlabSchema.types['MergeRequestReviewer'] do
merge_request_interaction
namespace
timelogs
+ groups
]
expect(described_class).to have_graphql_fields(*expected_fields)
diff --git a/spec/graphql/types/project_statistics_type_spec.rb b/spec/graphql/types/project_statistics_type_spec.rb
index 407ce82e73a..f515907b6a8 100644
--- a/spec/graphql/types/project_statistics_type_spec.rb
+++ b/spec/graphql/types/project_statistics_type_spec.rb
@@ -6,6 +6,6 @@ RSpec.describe GitlabSchema.types['ProjectStatistics'] do
it 'has all the required fields' do
expect(described_class).to have_graphql_fields(:storage_size, :repository_size, :lfs_objects_size,
:build_artifacts_size, :packages_size, :commit_count,
- :wiki_size, :snippets_size, :uploads_size)
+ :wiki_size, :snippets_size, :pipeline_artifacts_size, :uploads_size)
end
end
diff --git a/spec/graphql/types/terraform/state_version_type_spec.rb b/spec/graphql/types/terraform/state_version_type_spec.rb
index 18f869e4f1f..b015a2045da 100644
--- a/spec/graphql/types/terraform/state_version_type_spec.rb
+++ b/spec/graphql/types/terraform/state_version_type_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['TerraformStateVersion'] do
+ include GraphqlHelpers
+
it { expect(described_class.graphql_name).to eq('TerraformStateVersion') }
it { expect(described_class).to require_graphql_authorizations(:read_terraform_state) }
@@ -19,4 +21,60 @@ RSpec.describe GitlabSchema.types['TerraformStateVersion'] do
it { expect(described_class.fields['createdAt'].type).to be_non_null }
it { expect(described_class.fields['updatedAt'].type).to be_non_null }
end
+
+ describe 'query' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:terraform_state) { create(:terraform_state, :with_version, :locked, project: project) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ let(:query) do
+ <<~GRAPHQL
+ query {
+ project(fullPath: "#{project.full_path}") {
+ terraformState(name: "#{terraform_state.name}") {
+ latestVersion {
+ id
+ job {
+ name
+ }
+ }
+ }
+ }
+ }
+ GRAPHQL
+ end
+
+ subject(:execute) { GitlabSchema.execute(query, context: { current_user: user }).as_json }
+
+ shared_examples 'returning latest version' do
+ it 'returns latest version of terraform state' do
+ expect(execute.dig('data', 'project', 'terraformState', 'latestVersion', 'id')).to eq(
+ global_id_of(terraform_state.latest_version)
+ )
+ end
+ end
+
+ it_behaves_like 'returning latest version'
+
+ it 'returns job of the latest version' do
+ expect(execute.dig('data', 'project', 'terraformState', 'latestVersion', 'job')).to be_present
+ end
+
+ context 'when user cannot read jobs' do
+ before do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(user, :read_commit_status, terraform_state.latest_version).and_return(false)
+ end
+
+ it_behaves_like 'returning latest version'
+
+ it 'does not return job of the latest version' do
+ expect(execute.dig('data', 'project', 'terraformState', 'latestVersion', 'job')).not_to be_present
+ end
+ end
+ end
end
diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb
index 363ccdf88b7..0bad8c95ba2 100644
--- a/spec/graphql/types/user_type_spec.rb
+++ b/spec/graphql/types/user_type_spec.rb
@@ -38,6 +38,7 @@ RSpec.describe GitlabSchema.types['User'] do
callouts
namespace
timelogs
+ groups
]
expect(described_class).to have_graphql_fields(*expected_fields)
diff --git a/spec/helpers/analytics/cycle_analytics_helper_spec.rb b/spec/helpers/analytics/cycle_analytics_helper_spec.rb
new file mode 100644
index 00000000000..d906646e25c
--- /dev/null
+++ b/spec/helpers/analytics/cycle_analytics_helper_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+require "spec_helper"
+
+RSpec.describe Analytics::CycleAnalyticsHelper do
+ describe '#cycle_analytics_initial_data' do
+ let(:user) { create(:user, name: 'fake user', username: 'fake_user') }
+ let(:image_path_keys) { [:empty_state_svg_path, :no_data_svg_path, :no_access_svg_path] }
+ let(:api_path_keys) { [:milestones_path, :labels_path] }
+ let(:additional_data_keys) { [:full_path, :group_id, :group_path, :project_id, :request_path] }
+ let(:group) { create(:group) }
+
+ subject(:cycle_analytics_data) { helper.cycle_analytics_initial_data(project, group) }
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ context 'when a group is present' do
+ let(:project) { create(:project, group: group) }
+
+ it "sets the correct data keys" do
+ expect(cycle_analytics_data.keys)
+ .to match_array(api_path_keys + image_path_keys + additional_data_keys)
+ end
+
+ it "sets group paths" do
+ expect(cycle_analytics_data)
+ .to include({
+ full_path: project.full_path,
+ group_path: "/#{project.namespace.name}",
+ group_id: project.namespace.id,
+ request_path: "/#{project.full_path}/-/value_stream_analytics",
+ milestones_path: "/groups/#{group.name}/-/milestones.json",
+ labels_path: "/groups/#{group.name}/-/labels.json"
+ })
+ end
+ end
+
+ context 'when a group is not present' do
+ let(:group) { nil }
+ let(:project) { create(:project) }
+
+ it "sets the correct data keys" do
+ expect(cycle_analytics_data.keys)
+ .to match_array(image_path_keys + api_path_keys + additional_data_keys)
+ end
+
+ it "sets project name space paths" do
+ expect(cycle_analytics_data)
+ .to include({
+ full_path: project.full_path,
+ group_path: project.namespace.path,
+ group_id: project.namespace.id,
+ request_path: "/#{project.full_path}/-/value_stream_analytics",
+ milestones_path: "/#{project.full_path}/-/milestones.json",
+ labels_path: "/#{project.full_path}/-/labels.json"
+ })
+ end
+ end
+ end
+end
diff --git a/spec/helpers/application_settings_helper_spec.rb b/spec/helpers/application_settings_helper_spec.rb
index 6d51d85fd64..ef5f6931d02 100644
--- a/spec/helpers/application_settings_helper_spec.rb
+++ b/spec/helpers/application_settings_helper_spec.rb
@@ -284,4 +284,10 @@ RSpec.describe ApplicationSettingsHelper do
end
end
end
+
+ describe '#sidekiq_job_limiter_modes_for_select' do
+ subject { helper.sidekiq_job_limiter_modes_for_select }
+
+ it { is_expected.to eq([%w(Track track), %w(Compress compress)]) }
+ end
end
diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb
index c48d609836d..efcb8125f68 100644
--- a/spec/helpers/blob_helper_spec.rb
+++ b/spec/helpers/blob_helper_spec.rb
@@ -92,6 +92,30 @@ RSpec.describe BlobHelper do
end
end
+ describe "#relative_raw_path" do
+ include FakeBlobHelpers
+
+ let_it_be(:project) { create(:project) }
+
+ before do
+ assign(:project, project)
+ end
+
+ [
+ %w[/file.md /-/raw/main/],
+ %w[/test/file.md /-/raw/main/test/],
+ %w[/another/test/file.md /-/raw/main/another/test/]
+ ].each do |file_path, expected_path|
+ it "pointing from '#{file_path}' to '#{expected_path}'" do
+ blob = fake_blob(path: file_path)
+ assign(:blob, blob)
+ assign(:id, "main#{blob.path}")
+ assign(:path, blob.path)
+
+ expect(helper.parent_dir_raw_path).to eq "/#{project.full_path}#{expected_path}"
+ end
+ end
+ end
context 'viewer related' do
include FakeBlobHelpers
diff --git a/spec/helpers/ci/pipeline_editor_helper_spec.rb b/spec/helpers/ci/pipeline_editor_helper_spec.rb
index 3183a0a2394..874937bc4ce 100644
--- a/spec/helpers/ci/pipeline_editor_helper_spec.rb
+++ b/spec/helpers/ci/pipeline_editor_helper_spec.rb
@@ -42,7 +42,6 @@ RSpec.describe Ci::PipelineEditorHelper do
"ci-config-path": project.ci_config_path_or_default,
"ci-examples-help-page-path" => help_page_path('ci/examples/index'),
"ci-help-page-path" => help_page_path('ci/index'),
- "commit-sha" => project.commit.sha,
"default-branch" => project.default_branch_or_main,
"empty-state-illustration-path" => 'foo',
"initial-branch-name" => nil,
@@ -69,7 +68,6 @@ RSpec.describe Ci::PipelineEditorHelper do
"ci-config-path": project.ci_config_path_or_default,
"ci-examples-help-page-path" => help_page_path('ci/examples/index'),
"ci-help-page-path" => help_page_path('ci/index'),
- "commit-sha" => '',
"default-branch" => project.default_branch_or_main,
"empty-state-illustration-path" => 'foo',
"initial-branch-name" => nil,
@@ -97,10 +95,7 @@ RSpec.describe Ci::PipelineEditorHelper do
end
it 'returns correct values' do
- latest_feature_sha = project.repository.commit('feature').sha
-
expect(pipeline_editor_data['initial-branch-name']).to eq('feature')
- expect(pipeline_editor_data['commit-sha']).to eq(latest_feature_sha)
end
end
end
diff --git a/spec/helpers/ci/runners_helper_spec.rb b/spec/helpers/ci/runners_helper_spec.rb
index 40927d44e24..0f15f8be0a9 100644
--- a/spec/helpers/ci/runners_helper_spec.rb
+++ b/spec/helpers/ci/runners_helper_spec.rb
@@ -88,6 +88,19 @@ RSpec.describe Ci::RunnersHelper do
end
end
+ describe '#group_runners_data_attributes' do
+ let(:group) { create(:group) }
+
+ it 'returns group data to render a runner list' do
+ data = group_runners_data_attributes(group)
+
+ expect(data[:registration_token]).to eq(group.runners_token)
+ expect(data[:group_id]).to eq(group.id)
+ expect(data[:group_full_path]).to eq(group.full_path)
+ expect(data[:runner_install_help_page]).to eq('https://docs.gitlab.com/runner/install/')
+ end
+ end
+
describe '#toggle_shared_runners_settings_data' do
let_it_be(:group) { create(:group) }
diff --git a/spec/helpers/environment_helper_spec.rb b/spec/helpers/environment_helper_spec.rb
index 0eecae32cc1..49937a3b53a 100644
--- a/spec/helpers/environment_helper_spec.rb
+++ b/spec/helpers/environment_helper_spec.rb
@@ -43,7 +43,6 @@ RSpec.describe EnvironmentHelper do
external_url: environment.external_url,
can_update_environment: true,
can_destroy_environment: true,
- can_read_environment: true,
can_stop_environment: true,
can_admin_environment: true,
environment_metrics_path: environment_metrics_path(environment),
diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb
index 42da1cb71f1..825d5236b5d 100644
--- a/spec/helpers/groups_helper_spec.rb
+++ b/spec/helpers/groups_helper_spec.rb
@@ -19,18 +19,6 @@ RSpec.describe GroupsHelper do
end
end
- describe '#group_dependency_proxy_image_prefix' do
- let_it_be(:group) { build_stubbed(:group, path: 'GroupWithUPPERcaseLetters') }
-
- it 'converts uppercase letters to lowercase' do
- expect(group_dependency_proxy_image_prefix(group)).to end_with("/groupwithuppercaseletters#{DependencyProxy::URL_SUFFIX}")
- end
-
- it 'removes the protocol' do
- expect(group_dependency_proxy_image_prefix(group)).not_to include('http')
- end
- end
-
describe '#group_lfs_status' do
let_it_be_with_reload(:group) { create(:group) }
let_it_be_with_reload(:project) { create(:project, namespace_id: group.id) }
@@ -267,61 +255,6 @@ RSpec.describe GroupsHelper do
end
end
- describe '#group_sidebar_links' do
- let_it_be(:group) { create(:group, :public) }
- let_it_be(:user) { create(:user) }
-
- before do
- group.add_owner(user)
- allow(helper).to receive(:current_user) { user }
- allow(helper).to receive(:can?) { |*args| Ability.allowed?(*args) }
- helper.instance_variable_set(:@group, group)
- end
-
- it 'returns all the expected links' do
- links = [
- :overview, :activity, :issues, :labels, :milestones, :merge_requests,
- :runners, :group_members, :settings
- ]
-
- expect(helper.group_sidebar_links).to include(*links)
- end
-
- it 'excludes runners when the user cannot admin the group' do
- expect(helper).to receive(:current_user) { user }
- # TODO Proper policies, such as `read_group_runners, should be implemented per
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/334802
- expect(helper).to receive(:can?).twice.with(user, :admin_group, group) { false }
-
- expect(helper.group_sidebar_links).not_to include(:runners)
- end
-
- it 'excludes runners when the feature "runner_list_group_view_vue_ui" is disabled' do
- stub_feature_flags(runner_list_group_view_vue_ui: false)
-
- expect(helper.group_sidebar_links).not_to include(:runners)
- end
-
- it 'excludes settings when the user can admin the group' do
- expect(helper).to receive(:current_user) { user }
- expect(helper).to receive(:can?).twice.with(user, :admin_group, group) { false }
-
- expect(helper.group_sidebar_links).not_to include(:settings)
- end
-
- it 'excludes cross project features when the user cannot read cross project' do
- cross_project_features = [:activity, :issues, :labels, :milestones,
- :merge_requests]
-
- allow(Ability).to receive(:allowed?).and_call_original
- cross_project_features.each do |feature|
- expect(Ability).to receive(:allowed?).with(user, "read_group_#{feature}".to_sym, group) { false }
- end
-
- expect(helper.group_sidebar_links).not_to include(*cross_project_features)
- end
- end
-
describe '#parent_group_options' do
let_it_be(:current_user) { create(:user) }
let_it_be(:group) { create(:group, name: 'group') }
@@ -442,67 +375,6 @@ RSpec.describe GroupsHelper do
end
end
- describe '#show_invite_banner?' do
- let_it_be(:current_user) { create(:user) }
- let_it_be_with_refind(:group) { create(:group) }
- let_it_be(:subgroup) { create(:group, parent: group) }
- let_it_be(:users) { [current_user, create(:user)] }
-
- before do
- allow(helper).to receive(:current_user) { current_user }
- allow(helper).to receive(:can?).with(current_user, :admin_group, group).and_return(can_admin_group)
- allow(helper).to receive(:can?).with(current_user, :admin_group, subgroup).and_return(can_admin_group)
- users.take(group_members_count).each { |user| group.add_guest(user) }
- end
-
- using RSpec::Parameterized::TableSyntax
-
- where(:can_admin_group, :group_members_count, :expected_result) do
- true | 1 | true
- false | 1 | false
- true | 2 | false
- false | 2 | false
- end
-
- with_them do
- context 'for a parent group' do
- subject { helper.show_invite_banner?(group) }
-
- context 'when the group was just created' do
- before do
- flash[:notice] = "Group #{group.name} was successfully created"
- end
-
- it { is_expected.to be_falsey }
- end
-
- context 'when no flash message' do
- it 'returns the expected result' do
- expect(subject).to eq(expected_result)
- end
- end
- end
-
- context 'for a subgroup' do
- subject { helper.show_invite_banner?(subgroup) }
-
- context 'when the subgroup was just created' do
- before do
- flash[:notice] = "Group #{subgroup.name} was successfully created"
- end
-
- it { is_expected.to be_falsey }
- end
-
- context 'when no flash message' do
- it 'returns the expected result' do
- expect(subject).to eq(expected_result)
- end
- end
- end
- end
- end
-
describe '#render_setting_to_allow_project_access_token_creation?' do
let_it_be(:current_user) { create(:user) }
let_it_be(:parent) { create(:group) }
@@ -541,4 +413,10 @@ RSpec.describe GroupsHelper do
expect(helper.can_admin_group_member?(group)).to be(false)
end
end
+
+ describe '#localized_jobs_to_be_done_choices' do
+ it 'has a translation for all `jobs_to_be_done` values' do
+ expect(localized_jobs_to_be_done_choices.keys).to match_array(NamespaceSetting.jobs_to_be_dones.keys)
+ end
+ end
end
diff --git a/spec/helpers/issuables_description_templates_helper_spec.rb b/spec/helpers/issuables_description_templates_helper_spec.rb
index 638dd201fc8..55649e9087a 100644
--- a/spec/helpers/issuables_description_templates_helper_spec.rb
+++ b/spec/helpers/issuables_description_templates_helper_spec.rb
@@ -56,32 +56,17 @@ RSpec.describe IssuablesDescriptionTemplatesHelper, :clean_gitlab_redis_cache do
let(:templates) do
{
"" => [
- { name: "another_issue_template", id: "another_issue_template" },
- { name: "custom_issue_template", id: "custom_issue_template" }
+ { 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
- it 'returns project templates only' do
+ it 'returns project templates' 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 }
- ]
- }
- end
-
- it 'returns empty array' do
- expect(helper.issuable_templates_names(Issue.new)).to eq([])
- end
- end
-
context 'when there are not templates in the project' do
let(:templates) { {} }
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index ecaee03eeea..3eb3c73cfcc 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -123,7 +123,7 @@ RSpec.describe IssuablesHelper do
end
describe '#issuables_state_counter_text' do
- let(:user) { create(:user) }
+ let_it_be(:user) { create(:user) }
describe 'state text' do
context 'when number of issuables can be generated' do
@@ -159,6 +159,38 @@ RSpec.describe IssuablesHelper do
.to eq('<span>All</span>')
end
end
+
+ context 'when count is over the threshold' do
+ let_it_be(:group) { create(:group) }
+
+ before do
+ allow(helper).to receive(:issuables_count_for_state).and_return(1100)
+ allow(helper).to receive(:parent).and_return(group)
+ stub_const("Gitlab::IssuablesCountForState::THRESHOLD", 1000)
+ end
+
+ context 'when feature flag cached_issues_state_count is disabled' do
+ before do
+ stub_feature_flags(cached_issues_state_count: false)
+ end
+
+ it 'returns complete count' do
+ expect(helper.issuables_state_counter_text(:issues, :opened, true))
+ .to eq('<span>Open</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm">1,100</span>')
+ end
+ end
+
+ context 'when feature flag cached_issues_state_count is enabled' do
+ before do
+ stub_feature_flags(cached_issues_state_count: true)
+ end
+
+ it 'returns truncated count' do
+ expect(helper.issuables_state_counter_text(:issues, :opened, true))
+ .to eq('<span>Open</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm">1.1k</span>')
+ end
+ end
+ end
end
end
@@ -285,7 +317,8 @@ RSpec.describe IssuablesHelper do
initialDescriptionText: 'issue text',
initialTaskStatus: '0 of 0 tasks completed',
issueType: 'issue',
- iid: issue.iid.to_s
+ iid: issue.iid.to_s,
+ isHidden: false
}
expect(helper.issuable_initial_data(issue)).to match(hash_including(expected_data))
end
diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb
index 9cf3808ab72..f5f26d306fb 100644
--- a/spec/helpers/issues_helper_spec.rb
+++ b/spec/helpers/issues_helper_spec.rb
@@ -284,7 +284,7 @@ RSpec.describe IssuesHelper do
iid: issue.iid,
is_issue_author: 'false',
issue_type: 'issue',
- new_issue_path: new_project_issue_path(project),
+ new_issue_path: new_project_issue_path(project, { issue: { description: "Related to \##{issue.iid}.\n\n" } }),
project_path: project.full_path,
report_abuse_path: new_abuse_report_path(user_id: issue.author.id, ref_url: issue_url(issue)),
submit_as_spam_path: mark_as_spam_project_issue_path(project, issue)
@@ -310,21 +310,21 @@ RSpec.describe IssuesHelper do
can_bulk_update: 'true',
can_edit: 'true',
can_import_issues: 'true',
- email: current_user&.notification_email,
+ email: current_user&.notification_email_or_default,
emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'),
empty_state_svg_path: '#',
export_csv_path: export_csv_project_issues_path(project),
- has_project_issues: project_issues(project).exists?.to_s,
+ full_path: project.full_path,
+ has_any_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: '#',
initial_email: project.new_issuable_address(current_user, 'issue'),
+ is_project: 'true',
is_signed_in: current_user.present?.to_s,
- issues_path: project_issues_path(project),
jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'),
markdown_help_path: help_page_path('user/markdown'),
max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes),
new_issue_path: new_project_issue_path(project, issue: { milestone_id: finder.milestones.first.id }),
project_import_jira_path: project_import_jira_path(project),
- project_path: project.full_path,
quick_actions_help_path: help_page_path('user/project/quick_actions'),
reset_path: new_issuable_address_project_path(project, issuable_type: 'issue'),
rss_path: '#',
@@ -332,11 +332,11 @@ RSpec.describe IssuesHelper do
sign_in_path: new_user_session_path
}
- expect(helper.issues_list_data(project, current_user, finder)).to include(expected)
+ expect(helper.project_issues_list_data(project, current_user, finder)).to include(expected)
end
end
- describe '#issues_list_data' do
+ describe '#project_issues_list_data' do
context 'when user is signed in' do
it_behaves_like 'issues list data' do
let(:current_user) { double.as_null_object }
@@ -350,6 +350,33 @@ RSpec.describe IssuesHelper do
end
end
+ describe '#group_issues_list_data' do
+ let(:group) { create(:group) }
+ let(:current_user) { double.as_null_object }
+ let(:issues) { [] }
+
+ it 'returns expected result' do
+ allow(helper).to receive(:current_user).and_return(current_user)
+ allow(helper).to receive(:can?).and_return(true)
+ allow(helper).to receive(:image_path).and_return('#')
+ allow(helper).to receive(:url_for).and_return('#')
+
+ expected = {
+ autocomplete_award_emojis_path: autocomplete_award_emojis_path,
+ calendar_path: '#',
+ empty_state_svg_path: '#',
+ full_path: group.full_path,
+ has_any_issues: issues.to_a.any?.to_s,
+ is_signed_in: current_user.present?.to_s,
+ jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'),
+ rss_path: '#',
+ sign_in_path: new_user_session_path
+ }
+
+ expect(helper.group_issues_list_data(group, current_user, issues)).to include(expected)
+ end
+ end
+
describe '#issue_manual_ordering_class' do
context 'when sorting by relative position' do
before do
@@ -410,4 +437,55 @@ RSpec.describe IssuesHelper do
end
end
end
+
+ describe '#issue_hidden?' do
+ context 'when issue is hidden' do
+ let_it_be(:banned_user) { build(:user, :banned) }
+ let_it_be(:hidden_issue) { build(:issue, author: banned_user) }
+
+ context 'when `ban_user_feature_flag` feature flag is enabled' do
+ it 'returns `true`' do
+ expect(helper.issue_hidden?(hidden_issue)).to eq(true)
+ end
+ end
+
+ context 'when `ban_user_feature_flag` feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it 'returns `false`' do
+ expect(helper.issue_hidden?(hidden_issue)).to eq(false)
+ end
+ end
+ end
+
+ context 'when issue is not hidden' do
+ it 'returns `false`' do
+ expect(helper.issue_hidden?(issue)).to eq(false)
+ end
+ end
+ end
+
+ describe '#hidden_issue_icon' do
+ let_it_be(:banned_user) { build(:user, :banned) }
+ let_it_be(:hidden_issue) { build(:issue, author: banned_user) }
+ let_it_be(:mock_svg) { '<svg></svg>'.html_safe }
+
+ before do
+ allow(helper).to receive(:sprite_icon).and_return(mock_svg)
+ end
+
+ context 'when issue is hidden' do
+ it 'returns icon with tooltip' do
+ expect(helper.hidden_issue_icon(hidden_issue)).to eq("<span class=\"has-tooltip\" title=\"This issue is hidden because its author has been banned\">#{mock_svg}</span>")
+ end
+ end
+
+ context 'when issue is not hidden' do
+ it 'returns `nil`' do
+ expect(helper.hidden_issue_icon(issue)).to be_nil
+ end
+ end
+ end
end
diff --git a/spec/helpers/learn_gitlab_helper_spec.rb b/spec/helpers/learn_gitlab_helper_spec.rb
index cf0d329c36f..1159fd96d59 100644
--- a/spec/helpers/learn_gitlab_helper_spec.rb
+++ b/spec/helpers/learn_gitlab_helper_spec.rb
@@ -53,7 +53,7 @@ RSpec.describe LearnGitlabHelper do
end
end
- describe '.learn_gitlab_experiment_enabled?' do
+ describe '.learn_gitlab_enabled?' do
using RSpec::Parameterized::TableSyntax
let_it_be(:user) { create(:user) }
@@ -61,19 +61,16 @@ RSpec.describe LearnGitlabHelper do
let(:params) { { namespace_id: project.namespace.to_param, project_id: project } }
- subject { helper.learn_gitlab_experiment_enabled?(project) }
+ subject { helper.learn_gitlab_enabled?(project) }
- where(:experiment_a, :experiment_b, :onboarding, :learn_gitlab_available, :result) do
- true | false | true | true | true
- false | true | true | true | true
- false | false | true | true | false
- true | true | true | false | false
- true | true | false | true | false
+ where(:onboarding, :learn_gitlab_available, :result) do
+ true | true | true
+ true | false | false
+ false | true | false
end
with_them do
before do
- stub_experiment_for_subject(learn_gitlab_a: experiment_a, learn_gitlab_b: experiment_b)
allow(OnboardingProgress).to receive(:onboarding?).with(project.namespace).and_return(onboarding)
allow_next(LearnGitlab::Project, user).to receive(:available?).and_return(learn_gitlab_available)
end
@@ -88,10 +85,6 @@ RSpec.describe LearnGitlabHelper do
end
context 'when not signed in' do
- before do
- stub_experiment_for_subject(learn_gitlab_a: true, learn_gitlab_b: true)
- end
-
it { is_expected.to eq(false) }
end
end
@@ -106,41 +99,4 @@ RSpec.describe LearnGitlabHelper do
expect(sections.values.map { |section| section.keys }).to eq([[:svg]] * 3)
end
end
-
- describe '.learn_gitlab_experiment_tracking_category' do
- using RSpec::Parameterized::TableSyntax
-
- let_it_be(:user) { create(:user) }
-
- subject { helper.learn_gitlab_experiment_tracking_category }
-
- where(:experiment_a, :experiment_b, :result) do
- false | false | nil
- false | true | 'Growth::Activation::Experiment::LearnGitLabB'
- true | false | 'Growth::Conversion::Experiment::LearnGitLabA'
- true | true | 'Growth::Conversion::Experiment::LearnGitLabA'
- end
-
- with_them do
- before do
- stub_experiment_for_subject(learn_gitlab_a: experiment_a, learn_gitlab_b: experiment_b)
- end
-
- context 'when signed in' do
- before do
- sign_in(user)
- end
-
- it { is_expected.to eq(result) }
- end
- end
-
- context 'when not signed in' do
- before do
- stub_experiment_for_subject(learn_gitlab_a: true, learn_gitlab_b: true)
- end
-
- it { is_expected.to eq(nil) }
- end
- end
end
diff --git a/spec/helpers/nav/new_dropdown_helper_spec.rb b/spec/helpers/nav/new_dropdown_helper_spec.rb
index 03b9c538225..64f4d5ff797 100644
--- a/spec/helpers/nav/new_dropdown_helper_spec.rb
+++ b/spec/helpers/nav/new_dropdown_helper_spec.rb
@@ -51,7 +51,7 @@ RSpec.describe Nav::NewDropdownHelper do
title: 'Invite members',
href: expected_href,
data: {
- track_event: 'click_link',
+ track_action: 'click_link',
track_label: 'test_tracking_label',
track_property: :invite_members_new_dropdown
}
@@ -99,12 +99,12 @@ RSpec.describe Nav::NewDropdownHelper do
it 'has project menu item' do
expect(subject[:menu_sections]).to eq(
expected_menu_section(
- title: 'GitLab',
+ title: _('GitLab'),
menu_item: ::Gitlab::Nav::TopNavMenuItem.build(
id: 'general_new_project',
title: 'New project/repository',
href: '/projects/new',
- data: { track_event: 'click_link_new_project', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_project_link' }
+ data: { track_action: 'click_link_new_project', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_project_link' }
)
)
)
@@ -117,12 +117,12 @@ RSpec.describe Nav::NewDropdownHelper do
it 'has group menu item' do
expect(subject[:menu_sections]).to eq(
expected_menu_section(
- title: 'GitLab',
+ title: _('GitLab'),
menu_item: ::Gitlab::Nav::TopNavMenuItem.build(
id: 'general_new_group',
title: 'New group',
href: '/groups/new',
- data: { track_event: 'click_link_new_group', track_label: 'plus_menu_dropdown' }
+ data: { track_action: 'click_link_new_group', track_label: 'plus_menu_dropdown' }
)
)
)
@@ -135,12 +135,12 @@ RSpec.describe Nav::NewDropdownHelper do
it 'has new snippet menu item' do
expect(subject[:menu_sections]).to eq(
expected_menu_section(
- title: 'GitLab',
+ title: _('GitLab'),
menu_item: ::Gitlab::Nav::TopNavMenuItem.build(
id: 'general_new_snippet',
title: 'New snippet',
href: '/-/snippets/new',
- data: { track_event: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_snippet_link' }
+ data: { track_action: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_snippet_link' }
)
)
)
@@ -178,7 +178,7 @@ RSpec.describe Nav::NewDropdownHelper do
id: 'new_project',
title: 'New project/repository',
href: "/projects/new?namespace_id=#{group.id}",
- data: { track_event: 'click_link_new_project_group', track_label: 'plus_menu_dropdown' }
+ data: { track_action: 'click_link_new_project_group', track_label: 'plus_menu_dropdown' }
)
)
)
@@ -196,7 +196,7 @@ RSpec.describe Nav::NewDropdownHelper do
id: 'new_subgroup',
title: 'New subgroup',
href: "/groups/new?parent_id=#{group.id}",
- data: { track_event: 'click_link_new_subgroup', track_label: 'plus_menu_dropdown' }
+ data: { track_action: 'click_link_new_subgroup', track_label: 'plus_menu_dropdown' }
)
)
)
@@ -245,7 +245,7 @@ RSpec.describe Nav::NewDropdownHelper do
id: 'new_issue',
title: 'New issue',
href: "/#{project.path_with_namespace}/-/issues/new",
- data: { track_event: 'click_link_new_issue', track_label: 'plus_menu_dropdown', qa_selector: 'new_issue_link' }
+ data: { track_action: 'click_link_new_issue', track_label: 'plus_menu_dropdown', qa_selector: 'new_issue_link' }
)
)
)
@@ -263,7 +263,7 @@ RSpec.describe Nav::NewDropdownHelper do
id: 'new_mr',
title: 'New merge request',
href: "/#{merge_project.path_with_namespace}/-/merge_requests/new",
- data: { track_event: 'click_link_new_mr', track_label: 'plus_menu_dropdown' }
+ data: { track_action: 'click_link_new_mr', track_label: 'plus_menu_dropdown' }
)
)
)
@@ -281,7 +281,7 @@ RSpec.describe Nav::NewDropdownHelper do
id: 'new_snippet',
title: 'New snippet',
href: "/#{project.path_with_namespace}/-/snippets/new",
- data: { track_event: 'click_link_new_snippet_project', track_label: 'plus_menu_dropdown' }
+ data: { track_action: 'click_link_new_snippet_project', track_label: 'plus_menu_dropdown' }
)
)
)
diff --git a/spec/helpers/nav/top_nav_helper_spec.rb b/spec/helpers/nav/top_nav_helper_spec.rb
index 4d6da258536..da7e5d5dce2 100644
--- a/spec/helpers/nav/top_nav_helper_spec.rb
+++ b/spec/helpers/nav/top_nav_helper_spec.rb
@@ -142,7 +142,7 @@ RSpec.describe Nav::TopNavHelper do
expected_primary = ::Gitlab::Nav::TopNavMenuItem.build(
css_class: 'qa-projects-dropdown',
data: {
- track_event: 'click_dropdown',
+ track_action: 'click_dropdown',
track_label: 'projects_dropdown'
},
icon: 'project',
@@ -248,7 +248,7 @@ RSpec.describe Nav::TopNavHelper do
expected_primary = ::Gitlab::Nav::TopNavMenuItem.build(
css_class: 'qa-groups-dropdown',
data: {
- track_event: 'click_dropdown',
+ track_action: 'click_dropdown',
track_label: 'groups_dropdown'
},
icon: 'group',
diff --git a/spec/helpers/notify_helper_spec.rb b/spec/helpers/notify_helper_spec.rb
index e2a7a212b1b..a4193444528 100644
--- a/spec/helpers/notify_helper_spec.rb
+++ b/spec/helpers/notify_helper_spec.rb
@@ -55,4 +55,53 @@ RSpec.describe NotifyHelper do
def reference_link(entity, url)
"<a href=\"#{url}\">#{entity.to_reference}</a>"
end
+
+ describe '#invited_join_url' do
+ let_it_be(:member) { create(:project_member) }
+
+ let(:token) { '_token_' }
+
+ context 'when invite_email_preview_text is enabled', :experiment do
+ before do
+ stub_experiments(invite_email_preview_text: :control)
+ end
+
+ it 'has correct params' do
+ expect(helper.invited_join_url(token, member))
+ .to eq("http://test.host/-/invites/#{token}?experiment_name=invite_email_preview_text&invite_type=initial_email")
+ end
+
+ context 'when invite_email_from is enabled' do
+ before do
+ stub_experiments(invite_email_from: :control)
+ end
+
+ it 'has correct params' do
+ expect(helper.invited_join_url(token, member))
+ .to eq("http://test.host/-/invites/#{token}?experiment_name=invite_email_from&invite_type=initial_email")
+ end
+ end
+ end
+
+ context 'when invite_email_from is enabled' do
+ before do
+ stub_experiments(invite_email_from: :control)
+ end
+
+ it 'has correct params' do
+ expect(helper.invited_join_url(token, member))
+ .to eq("http://test.host/-/invites/#{token}?experiment_name=invite_email_from&invite_type=initial_email")
+ end
+ end
+
+ context 'when invite_email_preview_text is disabled' do
+ before do
+ stub_feature_flags(invite_email_preview_text: false)
+ end
+
+ it 'has correct params' do
+ expect(helper.invited_join_url(token, member)).to eq("http://test.host/-/invites/#{token}?invite_type=initial_email")
+ end
+ end
+ end
end
diff --git a/spec/helpers/packages_helper_spec.rb b/spec/helpers/packages_helper_spec.rb
index bc60c582ff8..06c6cccd488 100644
--- a/spec/helpers/packages_helper_spec.rb
+++ b/spec/helpers/packages_helper_spec.rb
@@ -223,21 +223,41 @@ RSpec.describe PackagesHelper do
describe '#package_details_data' do
let_it_be(:package) { create(:package) }
+ let(:expected_result) do
+ {
+ package_id: package.id,
+ can_delete: 'true',
+ project_name: project.name,
+ group_list_url: ''
+ }
+ end
+
before do
allow(helper).to receive(:current_user) { project.owner }
allow(helper).to receive(:can?) { true }
end
- it 'when use_presenter is true populate the package key' do
- result = helper.package_details_data(project, package, true)
+ context 'in a project without a group' do
+ it 'populates presenter data' do
+ result = helper.package_details_data(project, package)
- expect(result[:package]).not_to be_nil
+ expect(result).to match(hash_including(expected_result))
+ end
end
- it 'when use_presenter is false the package key is nil' do
- result = helper.package_details_data(project, package, false)
+ context 'in a project with a group' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project_with_group) { create(:project, group: group) }
- expect(result[:package]).to be_nil
+ it 'populates presenter data' do
+ result = helper.package_details_data(project_with_group, package)
+ expected = expected_result.merge({
+ group_list_url: group_packages_path(project_with_group.group),
+ project_name: project_with_group.name
+ })
+
+ expect(result).to match(hash_including(expected))
+ end
end
end
end
diff --git a/spec/helpers/profiles_helper_spec.rb b/spec/helpers/profiles_helper_spec.rb
index 2ea832f95dc..c3a3c2a0178 100644
--- a/spec/helpers/profiles_helper_spec.rb
+++ b/spec/helpers/profiles_helper_spec.rb
@@ -13,7 +13,8 @@ RSpec.describe ProfilesHelper do
private_email = user.private_commit_email
emails = [
- ["Use a private email - #{private_email}", Gitlab::PrivateCommitEmail::TOKEN],
+ [s_('Use primary email (%{email})') % { email: user.email }, ''],
+ [s_("Profiles|Use a private email - %{email}").html_safe % { email: private_email }, Gitlab::PrivateCommitEmail::TOKEN],
user.email,
confirmed_email1.email,
confirmed_email2.email
@@ -23,20 +24,6 @@ RSpec.describe ProfilesHelper do
end
end
- describe '#selected_commit_email' do
- let(:user) { create(:user) }
-
- it 'returns main email when commit email attribute is nil' do
- expect(helper.selected_commit_email(user)).to eq(user.email)
- end
-
- it 'returns DB stored commit_email' do
- user.update!(commit_email: Gitlab::PrivateCommitEmail::TOKEN)
-
- expect(helper.selected_commit_email(user)).to eq(Gitlab::PrivateCommitEmail::TOKEN)
- end
- end
-
describe '#email_provider_label' do
it "returns nil for users without external email" do
user = create(:user)
@@ -152,6 +139,22 @@ RSpec.describe ProfilesHelper do
end
end
+ describe '#middle_dot_divider_classes' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:stacking, :breakpoint, :expected) do
+ nil | nil | %w(gl-mb-3 gl-display-inline-block middle-dot-divider)
+ true | nil | %w(gl-mb-3 middle-dot-divider-sm gl-display-block gl-sm-display-inline-block)
+ nil | :sm | %w(gl-mb-3 gl-display-inline-block middle-dot-divider-sm)
+ end
+
+ with_them do
+ it 'returns CSS classes needed to render the middle dot divider' do
+ expect(helper.middle_dot_divider_classes(stacking, breakpoint)).to eq expected
+ end
+ end
+ end
+
def stub_cas_omniauth_provider
provider = OpenStruct.new(
'name' => 'cas3',
diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb
index 4dac4403f70..85b572d3f68 100644
--- a/spec/helpers/projects_helper_spec.rb
+++ b/spec/helpers/projects_helper_spec.rb
@@ -498,7 +498,7 @@ RSpec.describe ProjectsHelper do
context 'user has a configured commit email' do
before do
confirmed_email = create(:email, :confirmed, user: user)
- user.update!(commit_email: confirmed_email)
+ user.update!(commit_email: confirmed_email.email)
end
it 'returns the commit email' do
diff --git a/spec/helpers/recaptcha_helper_spec.rb b/spec/helpers/recaptcha_helper_spec.rb
index e7f9ba5b73a..8ad91a0a217 100644
--- a/spec/helpers/recaptcha_helper_spec.rb
+++ b/spec/helpers/recaptcha_helper_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe RecaptchaHelper, type: :helper do
it 'returns false' do
stub_application_setting(recaptcha_enabled: false)
- expect(helper.show_recaptcha_sign_up?).to be(false)
+ expect(helper.show_recaptcha_sign_up?).to be_falsey
end
end
@@ -22,7 +22,7 @@ RSpec.describe RecaptchaHelper, type: :helper do
it 'returns true' do
stub_application_setting(recaptcha_enabled: true)
- expect(helper.show_recaptcha_sign_up?).to be(true)
+ expect(helper.show_recaptcha_sign_up?).to be_truthy
end
end
end
diff --git a/spec/helpers/routing/pseudonymization_helper_spec.rb b/spec/helpers/routing/pseudonymization_helper_spec.rb
new file mode 100644
index 00000000000..10563502555
--- /dev/null
+++ b/spec/helpers/routing/pseudonymization_helper_spec.rb
@@ -0,0 +1,141 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Routing::PseudonymizationHelper do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:subgroup) { create(:group, parent: group) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:issue) { create(:issue, project: project) }
+
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
+ before do
+ stub_feature_flags(mask_page_urls: true)
+ allow(helper).to receive(:group).and_return(group)
+ allow(helper).to receive(:project).and_return(project)
+ end
+
+ shared_examples 'masked url' do
+ it 'generates masked page url' do
+ expect(helper.masked_page_url).to eq(masked_url)
+ end
+ end
+
+ describe 'when url has params to mask' do
+ context 'with controller for MR' do
+ let(:masked_url) { "http://test.host/namespace:#{group.id}/project:#{project.id}/-/merge_requests/#{merge_request.id}" }
+
+ before do
+ allow(Rails.application.routes).to receive(:recognize_path).and_return({
+ controller: "projects/merge_requests",
+ action: "show",
+ namespace_id: group.name,
+ project_id: project.name,
+ id: merge_request.id.to_s
+ })
+ end
+
+ it_behaves_like 'masked url'
+ end
+
+ context 'with controller for issue' do
+ let(:masked_url) { "http://test.host/namespace:#{group.id}/project:#{project.id}/-/issues/#{issue.id}" }
+
+ before do
+ allow(Rails.application.routes).to receive(:recognize_path).and_return({
+ controller: "projects/issues",
+ action: "show",
+ namespace_id: group.name,
+ project_id: project.name,
+ id: issue.id.to_s
+ })
+ end
+
+ it_behaves_like 'masked url'
+ end
+
+ context 'with controller for groups with subgroups and project' do
+ let(:masked_url) { "http://test.host/namespace:#{subgroup.id}/project:#{project.id}"}
+
+ before do
+ allow(helper).to receive(:group).and_return(subgroup)
+ allow(helper.project).to receive(:namespace).and_return(subgroup)
+ allow(Rails.application.routes).to receive(:recognize_path).and_return({
+ controller: 'projects',
+ action: 'show',
+ namespace_id: subgroup.name,
+ id: project.name
+ })
+ end
+
+ it_behaves_like 'masked url'
+ end
+
+ context 'with controller for groups and subgroups' do
+ let(:masked_url) { "http://test.host/namespace:#{subgroup.id}"}
+
+ before do
+ allow(helper).to receive(:group).and_return(subgroup)
+ allow(Rails.application.routes).to receive(:recognize_path).and_return({
+ controller: 'groups',
+ action: 'show',
+ id: subgroup.name
+ })
+ end
+
+ it_behaves_like 'masked url'
+ end
+
+ context 'with controller for blob with file path' do
+ let(:masked_url) { "http://test.host/namespace:#{group.id}/project:#{project.id}/-/blob/:repository_path" }
+
+ before do
+ allow(Rails.application.routes).to receive(:recognize_path).and_return({
+ controller: 'projects/blob',
+ action: 'show',
+ namespace_id: group.name,
+ project_id: project.name,
+ id: 'master/README.md'
+ })
+ end
+
+ it_behaves_like 'masked url'
+ end
+
+ context 'with non identifiable controller' do
+ let(:masked_url) { "http://test.host/dashboard/issues?assignee_username=root" }
+
+ before do
+ controller.request.path = '/dashboard/issues'
+ controller.request.query_string = 'assignee_username=root'
+ allow(Rails.application.routes).to receive(:recognize_path).and_return({
+ controller: 'dashboard',
+ action: 'issues'
+ })
+ end
+
+ it_behaves_like 'masked url'
+ end
+ end
+
+ describe 'when url has no params to mask' do
+ let(:root_url) { 'http://test.host' }
+
+ context 'returns root url' do
+ it 'masked_page_url' do
+ expect(helper.masked_page_url).to eq(root_url)
+ end
+ end
+ end
+
+ describe 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(mask_page_urls: false)
+ end
+
+ it 'returns nil' do
+ expect(helper.masked_page_url).to be_nil
+ end
+ end
+end
diff --git a/spec/helpers/user_callouts_helper_spec.rb b/spec/helpers/user_callouts_helper_spec.rb
index 5ef1e9d4daf..794ff5ee945 100644
--- a/spec/helpers/user_callouts_helper_spec.rb
+++ b/spec/helpers/user_callouts_helper_spec.rb
@@ -3,7 +3,7 @@
require "spec_helper"
RSpec.describe UserCalloutsHelper do
- let_it_be(:user) { create(:user) }
+ let_it_be(:user, refind: true) { create(:user) }
before do
allow(helper).to receive(:current_user).and_return(user)
@@ -202,4 +202,95 @@ RSpec.describe UserCalloutsHelper do
it { is_expected.to be false }
end
end
+
+ describe '.show_invite_banner?' do
+ let_it_be(:group) { create(:group) }
+
+ subject { helper.show_invite_banner?(group) }
+
+ context 'when user has the admin ability for the group' do
+ before do
+ group.add_owner(user)
+ end
+
+ context 'when the invite_members_banner has not been dismissed' do
+ it { is_expected.to eq(true) }
+
+ context 'when a user has dismissed this banner via cookies already' do
+ before do
+ helper.request.cookies["invite_#{group.id}_#{user.id}"] = 'true'
+ end
+
+ it { is_expected.to eq(false) }
+
+ it 'creates the callout from cookie', :aggregate_failures do
+ expect { subject }.to change { Users::GroupCallout.count }.by(1)
+ expect(Users::GroupCallout.last).to have_attributes(group_id: group.id,
+ feature_name: described_class::INVITE_MEMBERS_BANNER)
+ end
+ end
+
+ context 'when the group was just created' do
+ before do
+ flash[:notice] = "Group #{group.name} was successfully created"
+ end
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'with concerning multiple members' do
+ let_it_be(:user_2) { create(:user) }
+
+ context 'on current group' do
+ before do
+ group.add_guest(user_2)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'on current group that is a subgroup' do
+ let_it_be(:subgroup) { create(:group, parent: group) }
+
+ subject { helper.show_invite_banner?(subgroup) }
+
+ context 'with only one user on parent and this group' do
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when another user is on this group' do
+ before do
+ subgroup.add_guest(user_2)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when another user is on the parent group' do
+ before do
+ group.add_guest(user_2)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
+ end
+ end
+
+ context 'when the invite_members_banner has been dismissed' do
+ before do
+ create(:group_callout,
+ user: user,
+ group: group,
+ feature_name: described_class::INVITE_MEMBERS_BANNER)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
+ context 'when user does not have admin ability for the group' do
+ it { is_expected.to eq(false) }
+ end
+ end
end
diff --git a/spec/initializers/validate_database_config_spec.rb b/spec/initializers/validate_database_config_spec.rb
new file mode 100644
index 00000000000..99e4a4b36ee
--- /dev/null
+++ b/spec/initializers/validate_database_config_spec.rb
@@ -0,0 +1,166 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'validate database config' do
+ include RakeHelpers
+ include StubENV
+
+ let(:rails_configuration) { Rails::Application::Configuration.new(Rails.root) }
+ let(:ar_configurations) { ActiveRecord::DatabaseConfigurations.new(rails_configuration.database_configuration) }
+
+ subject do
+ load Rails.root.join('config/initializers/validate_database_config.rb')
+ end
+
+ before do
+ # The `AS::ConfigurationFile` calls `read` in `def initialize`
+ # thus we cannot use `expect_next_instance_of`
+ # rubocop:disable RSpec/AnyInstanceOf
+ expect_any_instance_of(ActiveSupport::ConfigurationFile)
+ .to receive(:read).with(Rails.root.join('config/database.yml')).and_return(database_yml)
+ # rubocop:enable RSpec/AnyInstanceOf
+
+ allow(Rails.application).to receive(:config).and_return(rails_configuration)
+ allow(ActiveRecord::Base).to receive(:configurations).and_return(ar_configurations)
+ end
+
+ shared_examples 'with SKIP_DATABASE_CONFIG_VALIDATION=true' do
+ before do
+ stub_env('SKIP_DATABASE_CONFIG_VALIDATION', 'true')
+ end
+
+ it 'does not raise exception' do
+ expect { subject }.not_to raise_error
+ end
+ end
+
+ context 'when config/database.yml is valid' do
+ context 'uses legacy syntax' do
+ let(:database_yml) do
+ <<-EOS
+ production:
+ adapter: postgresql
+ encoding: unicode
+ database: gitlabhq_production
+ username: git
+ password: "secure password"
+ host: localhost
+ EOS
+ end
+
+ it 'validates configuration with a warning' do
+ expect(main_object).to receive(:warn).with /uses a deprecated syntax for/
+
+ expect { subject }.not_to raise_error
+ end
+
+ it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true'
+ end
+
+ context 'uses new syntax' do
+ let(:database_yml) do
+ <<-EOS
+ production:
+ main:
+ adapter: postgresql
+ encoding: unicode
+ database: gitlabhq_production
+ username: git
+ password: "secure password"
+ host: localhost
+ EOS
+ end
+
+ it 'validates configuration without errors and warnings' do
+ expect(main_object).not_to receive(:warn)
+
+ expect { subject }.not_to raise_error
+ end
+ end
+ end
+
+ context 'when config/database.yml is invalid' do
+ context 'uses unknown connection name' do
+ let(:database_yml) do
+ <<-EOS
+ production:
+ main:
+ adapter: postgresql
+ encoding: unicode
+ database: gitlabhq_production
+ username: git
+ password: "secure password"
+ host: localhost
+
+ another:
+ adapter: postgresql
+ encoding: unicode
+ database: gitlabhq_production
+ username: git
+ password: "secure password"
+ host: localhost
+ EOS
+ end
+
+ it 'raises exception' do
+ expect { subject }.to raise_error /This installation of GitLab uses unsupported database names/
+ end
+
+ it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true'
+ end
+
+ context 'uses replica configuration' do
+ let(:database_yml) do
+ <<-EOS
+ production:
+ main:
+ adapter: postgresql
+ encoding: unicode
+ database: gitlabhq_production
+ username: git
+ password: "secure password"
+ host: localhost
+ replica: true
+ EOS
+ end
+
+ it 'raises exception' do
+ expect { subject }.to raise_error /with 'replica: true' parameter in/
+ end
+
+ it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true'
+ end
+
+ context 'main is not a first entry' do
+ let(:database_yml) do
+ <<-EOS
+ production:
+ ci:
+ adapter: postgresql
+ encoding: unicode
+ database: gitlabhq_production_ci
+ username: git
+ password: "secure password"
+ host: localhost
+ replica: true
+
+ main:
+ adapter: postgresql
+ encoding: unicode
+ database: gitlabhq_production
+ username: git
+ password: "secure password"
+ host: localhost
+ replica: true
+ EOS
+ end
+
+ it 'raises exception' do
+ expect { subject }.to raise_error /The `main:` database needs to be defined as a first configuration item/
+ end
+
+ it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true'
+ end
+ end
+end
diff --git a/spec/javascripts/.eslintrc.yml b/spec/javascripts/.eslintrc.yml
deleted file mode 100644
index b863156b57c..00000000000
--- a/spec/javascripts/.eslintrc.yml
+++ /dev/null
@@ -1,39 +0,0 @@
----
-env:
- jasmine: true
-extends: plugin:jasmine/recommended
-globals:
- appendLoadFixtures: false
- appendLoadStyleFixtures: false
- appendSetFixtures: false
- appendSetStyleFixtures: false
- getJSONFixture: false
- loadFixtures: false
- loadJSONFixtures: false
- loadStyleFixtures: false
- preloadFixtures: false
- preloadStyleFixtures: false
- readFixtures: false
- sandbox: false
- setFixtures: false
- setStyleFixtures: false
- spyOnDependency: false
- spyOnEvent: false
- ClassSpecHelper: false
-plugins:
- - jasmine
-rules:
- func-names: off
- jasmine/no-suite-dupes:
- - warn
- - branch
- jasmine/no-spec-dupes:
- - warn
- - branch
- prefer-arrow-callback: off
- import/no-unresolved:
- - error
- - ignore:
- - 'fixtures/blob'
- # Temporarily disabled to facilitate an upgrade to eslint-plugin-jasmine
- jasmine/prefer-toHaveBeenCalledWith: off
diff --git a/spec/javascripts/fixtures/.gitignore b/spec/javascripts/fixtures/.gitignore
deleted file mode 100644
index d6b7ef32c84..00000000000
--- a/spec/javascripts/fixtures/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore
diff --git a/spec/javascripts/lib/utils/mock_data.js b/spec/javascripts/lib/utils/mock_data.js
deleted file mode 100644
index f1358986f2a..00000000000
--- a/spec/javascripts/lib/utils/mock_data.js
+++ /dev/null
@@ -1 +0,0 @@
-export * from '../../../frontend/lib/utils/mock_data';
diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js
deleted file mode 100644
index be14d2ee7e7..00000000000
--- a/spec/javascripts/test_bundle.js
+++ /dev/null
@@ -1,145 +0,0 @@
-/* eslint-disable
- jasmine/no-global-setup, no-underscore-dangle, no-console
-*/
-
-import { config as testUtilsConfig } from '@vue/test-utils';
-import jasmineDiff from 'jasmine-diff';
-import $ from 'jquery';
-import 'core-js/features/set-immediate';
-import 'vendor/jasmine-jquery';
-import '~/commons';
-import Vue from 'vue';
-import { getDefaultAdapter } from '~/lib/utils/axios_utils';
-import Translate from '~/vue_shared/translate';
-
-import { FIXTURES_PATH, TEST_HOST } from './test_constants';
-
-// Tech debt issue TBD
-testUtilsConfig.logModifiedComponents = false;
-
-const isHeadlessChrome = /\bHeadlessChrome\//.test(navigator.userAgent);
-Vue.config.devtools = !isHeadlessChrome;
-Vue.config.productionTip = false;
-
-let hasVueWarnings = false;
-Vue.config.warnHandler = (msg, vm, trace) => {
- // The following workaround is necessary, so we are able to use setProps from Vue test utils
- // see https://github.com/vuejs/vue-test-utils/issues/631#issuecomment-421108344
- const currentStack = new Error().stack;
- const isInVueTestUtils = currentStack
- .split('\n')
- .some((line) => line.startsWith(' at VueWrapper.setProps ('));
- if (isInVueTestUtils) {
- return;
- }
-
- hasVueWarnings = true;
- fail(`${msg}${trace}`);
-};
-
-let hasVueErrors = false;
-Vue.config.errorHandler = function (err) {
- hasVueErrors = true;
- fail(err);
-};
-
-Vue.use(Translate);
-
-// enable test fixtures
-jasmine.getFixtures().fixturesPath = FIXTURES_PATH;
-jasmine.getJSONFixtures().fixturesPath = FIXTURES_PATH;
-
-beforeAll(() => {
- jasmine.addMatchers(
- jasmineDiff(jasmine, {
- colors: window.__karma__.config.color,
- inline: window.__karma__.config.color,
- }),
- );
-});
-
-// globalize common libraries
-window.$ = $;
-window.jQuery = window.$;
-
-// stub expected globals
-window.gl = window.gl || {};
-window.gl.TEST_HOST = TEST_HOST;
-window.gon = window.gon || {};
-window.gon.test_env = true;
-window.gon.ee = process.env.IS_EE;
-gon.relative_url_root = '';
-
-let hasUnhandledPromiseRejections = false;
-
-window.addEventListener('unhandledrejection', (event) => {
- hasUnhandledPromiseRejections = true;
- console.error('Unhandled promise rejection:');
- console.error(event.reason.stack || event.reason);
-});
-
-let longRunningTestTimeoutHandle;
-
-beforeEach((done) => {
- longRunningTestTimeoutHandle = setTimeout(() => {
- done.fail('Test is running too long!');
- }, 4000);
- done();
-});
-
-afterEach(() => {
- clearTimeout(longRunningTestTimeoutHandle);
-});
-
-const axiosDefaultAdapter = getDefaultAdapter();
-
-// render all of our tests
-const testContexts = [require.context('spec', true, /_spec$/)];
-
-if (process.env.IS_EE) {
- testContexts.push(require.context('ee_spec', true, /_spec$/));
-}
-
-testContexts.forEach((context) => {
- context.keys().forEach((path) => {
- try {
- context(path);
- } catch (err) {
- console.log(err);
- console.error('[GL SPEC RUNNER ERROR] Unable to load spec: ', path);
- describe('Test bundle', function () {
- it(`includes '${path}'`, function () {
- expect(err).toBeNull();
- });
- });
- }
- });
-});
-
-describe('test errors', () => {
- beforeAll((done) => {
- if (hasUnhandledPromiseRejections || hasVueWarnings || hasVueErrors) {
- setTimeout(done, 1000);
- } else {
- done();
- }
- });
-
- it('has no unhandled Promise rejections', () => {
- expect(hasUnhandledPromiseRejections).toBe(false);
- });
-
- it('has no Vue warnings', () => {
- expect(hasVueWarnings).toBe(false);
- });
-
- it('has no Vue error', () => {
- expect(hasVueErrors).toBe(false);
- });
-
- it('restores axios adapter after mocking', () => {
- if (getDefaultAdapter() !== axiosDefaultAdapter) {
- fail('axios adapter is not restored! Did you forget a restore() on MockAdapter?');
- }
- });
-});
diff --git a/spec/javascripts/test_constants.js b/spec/javascripts/test_constants.js
deleted file mode 100644
index de7b3a0e80c..00000000000
--- a/spec/javascripts/test_constants.js
+++ /dev/null
@@ -1 +0,0 @@
-export * from '../frontend/__helpers__/test_constants';
diff --git a/spec/lib/api/entities/clusters/agent_authorization_spec.rb b/spec/lib/api/entities/clusters/agent_authorization_spec.rb
new file mode 100644
index 00000000000..101a8af4ac4
--- /dev/null
+++ b/spec/lib/api/entities/clusters/agent_authorization_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Entities::Clusters::AgentAuthorization do
+ let_it_be(:authorization) { create(:agent_group_authorization) }
+
+ subject { described_class.new(authorization).as_json }
+
+ it 'includes basic fields' do
+ expect(subject).to include(
+ id: authorization.agent_id,
+ config_project: a_hash_including(id: authorization.agent.project_id),
+ configuration: authorization.config
+ )
+ end
+end
diff --git a/spec/lib/backup/gitaly_backup_spec.rb b/spec/lib/backup/gitaly_backup_spec.rb
index a48a1752eff..7797bd12f0e 100644
--- a/spec/lib/backup/gitaly_backup_spec.rb
+++ b/spec/lib/backup/gitaly_backup_spec.rb
@@ -131,8 +131,19 @@ RSpec.describe Backup::GitalyBackup do
context 'parallel option set' do
let(:parallel) { 3 }
- it 'does not pass parallel option through' do
- expect(Open3).to receive(:popen2).with(ENV, anything, 'restore', '-path', anything).and_call_original
+ it 'passes parallel option through' do
+ expect(Open3).to receive(:popen2).with(ENV, anything, 'restore', '-path', anything, '-parallel', '3').and_call_original
+
+ subject.start(:restore)
+ subject.wait
+ end
+ end
+
+ context 'parallel_storage option set' do
+ let(:parallel_storage) { 3 }
+
+ it 'passes parallel option through' do
+ expect(Open3).to receive(:popen2).with(ENV, anything, 'restore', '-path', anything, '-parallel-storage', '3').and_call_original
subject.start(:restore)
subject.wait
diff --git a/spec/lib/backup/manager_spec.rb b/spec/lib/backup/manager_spec.rb
index 2cc1bf41d18..32eea82cfdf 100644
--- a/spec/lib/backup/manager_spec.rb
+++ b/spec/lib/backup/manager_spec.rb
@@ -432,6 +432,77 @@ RSpec.describe Backup::Manager do
end
end
+ context 'with AWS with server side encryption' do
+ let(:connection) { ::Fog::Storage.new(Gitlab.config.backup.upload.connection.symbolize_keys) }
+ let(:encryption_key) { nil }
+ let(:encryption) { nil }
+ let(:storage_options) { nil }
+
+ before do
+ stub_backup_setting(
+ upload: {
+ connection: {
+ provider: 'AWS',
+ aws_access_key_id: 'AWS_ACCESS_KEY_ID',
+ aws_secret_access_key: 'AWS_SECRET_ACCESS_KEY'
+ },
+ remote_directory: 'directory',
+ multipart_chunk_size: Gitlab.config.backup.upload.multipart_chunk_size,
+ encryption: encryption,
+ encryption_key: encryption_key,
+ storage_options: storage_options,
+ storage_class: nil
+ }
+ )
+
+ connection.directories.create(key: Gitlab.config.backup.upload.remote_directory)
+ end
+
+ context 'with SSE-S3 without using storage_options' do
+ let(:encryption) { 'AES256' }
+
+ it 'sets encryption attributes' do
+ result = subject.upload
+
+ expect(result.key).to be_present
+ expect(result.encryption).to eq('AES256')
+ expect(result.encryption_key).to be_nil
+ expect(result.kms_key_id).to be_nil
+ end
+ end
+
+ context 'with SSE-C (customer-provided keys) options' do
+ let(:encryption) { 'AES256' }
+ let(:encryption_key) { SecureRandom.hex }
+
+ it 'sets encryption attributes' do
+ result = subject.upload
+
+ expect(result.key).to be_present
+ expect(result.encryption).to eq(encryption)
+ expect(result.encryption_key).to eq(encryption_key)
+ expect(result.kms_key_id).to be_nil
+ end
+ end
+
+ context 'with SSE-KMS options' do
+ let(:storage_options) do
+ {
+ server_side_encryption: 'aws:kms',
+ server_side_encryption_kms_key_id: 'arn:aws:kms:12345'
+ }
+ end
+
+ it 'sets encryption attributes' do
+ result = subject.upload
+
+ expect(result.key).to be_present
+ expect(result.encryption).to eq('aws:kms')
+ expect(result.kms_key_id).to eq('arn:aws:kms:12345')
+ end
+ end
+ end
+
context 'with Google provider' do
before do
stub_backup_setting(
diff --git a/spec/lib/banzai/filter/audio_link_filter_spec.rb b/spec/lib/banzai/filter/audio_link_filter_spec.rb
index 4198a50e980..71e069eb29f 100644
--- a/spec/lib/banzai/filter/audio_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/audio_link_filter_spec.rb
@@ -25,18 +25,14 @@ RSpec.describe Banzai::Filter::AudioLinkFilter do
it 'replaces the image tag with an audio tag' do
container = filter(image).children.first
- expect(container.name).to eq 'div'
- expect(container['class']).to eq 'audio-container'
+ expect(container.name).to eq 'span'
+ expect(container['class']).to eq 'media-container audio-container'
- audio, paragraph = container.children
+ audio, link = container.children
expect(audio.name).to eq 'audio'
expect(audio['src']).to eq src
- expect(paragraph.name).to eq 'p'
-
- link = paragraph.children.first
-
expect(link.name).to eq 'a'
expect(link['href']).to eq src
expect(link['target']).to eq '_blank'
@@ -105,15 +101,13 @@ RSpec.describe Banzai::Filter::AudioLinkFilter do
image = %(<img src="#{proxy_src}" data-canonical-src="#{canonical_src}"/>)
container = filter(image).children.first
- expect(container['class']).to eq 'audio-container'
+ expect(container['class']).to eq 'media-container audio-container'
- audio, paragraph = container.children
+ audio, link = container.children
expect(audio['src']).to eq proxy_src
expect(audio['data-canonical-src']).to eq canonical_src
- link = paragraph.children.first
-
expect(link['href']).to eq proxy_src
end
end
diff --git a/spec/lib/banzai/filter/video_link_filter_spec.rb b/spec/lib/banzai/filter/video_link_filter_spec.rb
index ec954aa9163..a0b0ba309f5 100644
--- a/spec/lib/banzai/filter/video_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/video_link_filter_spec.rb
@@ -25,20 +25,16 @@ RSpec.describe Banzai::Filter::VideoLinkFilter do
it 'replaces the image tag with a video tag' do
container = filter(image).children.first
- expect(container.name).to eq 'div'
- expect(container['class']).to eq 'video-container'
+ expect(container.name).to eq 'span'
+ expect(container['class']).to eq 'media-container video-container'
- video, paragraph = container.children
+ video, link = container.children
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'
-
- link = paragraph.children.first
-
expect(link.name).to eq 'a'
expect(link['href']).to eq src
expect(link['target']).to eq '_blank'
@@ -107,15 +103,13 @@ RSpec.describe Banzai::Filter::VideoLinkFilter do
image = %(<img src="#{proxy_src}" data-canonical-src="#{canonical_src}"/>)
container = filter(image).children.first
- expect(container['class']).to eq 'video-container'
+ expect(container['class']).to eq 'media-container video-container'
- video, paragraph = container.children
+ video, link = container.children
expect(video['src']).to eq proxy_src
expect(video['data-canonical-src']).to eq canonical_src
- link = paragraph.children.first
-
expect(link['href']).to eq proxy_src
end
end
diff --git a/spec/lib/banzai/reference_parser/base_parser_spec.rb b/spec/lib/banzai/reference_parser/base_parser_spec.rb
index 095500cdc53..4701caa0667 100644
--- a/spec/lib/banzai/reference_parser/base_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/base_parser_spec.rb
@@ -247,15 +247,15 @@ RSpec.describe Banzai::ReferenceParser::BaseParser do
end
end
- it 'returns referenceable and visible objects, alongside nodes that are referenceable but not visible' do
- expect(subject.gather_references(nodes)).to match(
- visible: contain_exactly(6, 8, 10),
- not_visible: match_array(nodes.select { |n| n.id.even? && n.id <= 5 })
- )
+ it 'returns referenceable and visible objects, alongside all and visible nodes' do
+ referenceable = nodes.select { |n| n.id.even? }
+ visible = nodes.select { |n| [6, 8, 10].include?(n.id) }
+
+ expect_gathered_references(subject.gather_references(nodes), [6, 8, 10], referenceable, visible)
end
it 'is always empty if the input is empty' do
- expect(subject.gather_references([])) .to match(visible: be_empty, not_visible: be_empty)
+ expect_gathered_references(subject.gather_references([]), [], [], [])
end
end
diff --git a/spec/lib/banzai/reference_parser/mentioned_group_parser_spec.rb b/spec/lib/banzai/reference_parser/mentioned_group_parser_spec.rb
index 4610da7cbe6..576e629d271 100644
--- a/spec/lib/banzai/reference_parser/mentioned_group_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/mentioned_group_parser_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedGroupParser do
it 'returns empty array' do
link['data-group'] = project.group.id.to_s
- expect_gathered_references(subject.gather_references([link]), [], 1)
+ expect_gathered_references(subject.gather_references([link]), [], [link], [])
end
end
@@ -30,7 +30,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedGroupParser do
end
it 'returns groups' do
- expect_gathered_references(subject.gather_references([link]), [group], 0)
+ expect_gathered_references(subject.gather_references([link]), [group], [link], [link])
end
end
@@ -38,7 +38,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedGroupParser do
it 'returns an empty Array' do
link['data-group'] = 'test-non-existing'
- expect_gathered_references(subject.gather_references([link]), [], 1)
+ expect_gathered_references(subject.gather_references([link]), [], [link], [])
end
end
end
diff --git a/spec/lib/banzai/reference_parser/mentioned_project_parser_spec.rb b/spec/lib/banzai/reference_parser/mentioned_project_parser_spec.rb
index 7eb58ee40d3..983407addce 100644
--- a/spec/lib/banzai/reference_parser/mentioned_project_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/mentioned_project_parser_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedProjectParser do
it 'returns empty Array' do
link['data-project'] = project.id.to_s
- expect_gathered_references(subject.gather_references([link]), [], 1)
+ expect_gathered_references(subject.gather_references([link]), [], [link], [])
end
end
@@ -30,7 +30,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedProjectParser do
end
it 'returns an Array of referenced projects' do
- expect_gathered_references(subject.gather_references([link]), [project], 0)
+ expect_gathered_references(subject.gather_references([link]), [project], [link], [link])
end
end
@@ -38,7 +38,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedProjectParser do
it 'returns an empty Array' do
link['data-project'] = 'inexisting-project-id'
- expect_gathered_references(subject.gather_references([link]), [], 1)
+ expect_gathered_references(subject.gather_references([link]), [], [link], [])
end
end
end
diff --git a/spec/lib/banzai/reference_parser/mentioned_user_parser_spec.rb b/spec/lib/banzai/reference_parser/mentioned_user_parser_spec.rb
index 4be07866db1..f117d796dad 100644
--- a/spec/lib/banzai/reference_parser/mentioned_user_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/mentioned_user_parser_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedUserParser do
end
it 'returns empty list of users' do
- expect_gathered_references(subject.gather_references([link]), [], 0)
+ expect_gathered_references(subject.gather_references([link]), [], [link], [link])
end
end
end
@@ -35,7 +35,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedUserParser do
end
it 'returns empty list of users' do
- expect_gathered_references(subject.gather_references([link]), [], 0)
+ expect_gathered_references(subject.gather_references([link]), [], [link], [link])
end
end
end
@@ -44,7 +44,7 @@ RSpec.describe Banzai::ReferenceParser::MentionedUserParser do
it 'returns an Array of users' do
link['data-user'] = user.id.to_s
- expect_gathered_references(subject.gather_references([link]), [user], 0)
+ expect_gathered_references(subject.gather_references([link]), [user], [link], [link])
end
end
end
diff --git a/spec/lib/banzai/reference_parser/project_parser_spec.rb b/spec/lib/banzai/reference_parser/project_parser_spec.rb
index 6358a04f12a..2c0b6c417b0 100644
--- a/spec/lib/banzai/reference_parser/project_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/project_parser_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe Banzai::ReferenceParser::ProjectParser do
it 'returns an Array of projects' do
link['data-project'] = project.id.to_s
- expect_gathered_references(subject.gather_references([link]), [project], 0)
+ expect_gathered_references(subject.gather_references([link]), [project], [link], [link])
end
end
@@ -25,7 +25,7 @@ RSpec.describe Banzai::ReferenceParser::ProjectParser do
it 'returns an empty Array' do
link['data-project'] = ''
- expect_gathered_references(subject.gather_references([link]), [], 1)
+ expect_gathered_references(subject.gather_references([link]), [], [link], [])
end
end
@@ -35,7 +35,7 @@ RSpec.describe Banzai::ReferenceParser::ProjectParser do
link['data-project'] = private_project.id.to_s
- expect_gathered_references(subject.gather_references([link]), [], 1)
+ expect_gathered_references(subject.gather_references([link]), [], [link], [])
end
it 'returns an Array when authorized' do
@@ -43,7 +43,7 @@ RSpec.describe Banzai::ReferenceParser::ProjectParser do
link['data-project'] = private_project.id.to_s
- expect_gathered_references(subject.gather_references([link]), [private_project], 0)
+ expect_gathered_references(subject.gather_references([link]), [private_project], [link], [link])
end
end
end
diff --git a/spec/lib/bulk_imports/groups/pipelines/entity_finisher_spec.rb b/spec/lib/bulk_imports/common/pipelines/entity_finisher_spec.rb
index b97aeb435b9..c1a9ea7b7e2 100644
--- a/spec/lib/bulk_imports/groups/pipelines/entity_finisher_spec.rb
+++ b/spec/lib/bulk_imports/common/pipelines/entity_finisher_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::Groups::Pipelines::EntityFinisher do
+RSpec.describe BulkImports::Common::Pipelines::EntityFinisher do
it 'updates the entity status to finished' do
entity = create(:bulk_import_entity, :started)
pipeline_tracker = create(:bulk_import_tracker, entity: entity)
diff --git a/spec/lib/bulk_imports/groups/graphql/get_projects_query_spec.rb b/spec/lib/bulk_imports/groups/graphql/get_projects_query_spec.rb
new file mode 100644
index 00000000000..1a7c5a4993c
--- /dev/null
+++ b/spec/lib/bulk_imports/groups/graphql/get_projects_query_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Groups::Graphql::GetProjectsQuery do
+ describe '#variables' do
+ it 'returns valid variables based on entity information' do
+ tracker = create(:bulk_import_tracker)
+ context = BulkImports::Pipeline::Context.new(tracker)
+
+ 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
+
+ context 'with invalid variables' do
+ it 'raises an error' do
+ expect { GraphQL::Query.new(GitlabSchema, described_class.to_s, variables: 'invalid') }.to raise_error(ArgumentError)
+ end
+ end
+ end
+
+ describe '#data_path' do
+ it 'returns data path' do
+ expected = %w[data group projects 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 projects page_info]
+
+ expect(described_class.page_info_path).to eq(expected)
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/groups/pipelines/project_entities_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/project_entities_pipeline_spec.rb
new file mode 100644
index 00000000000..5b6c93e695f
--- /dev/null
+++ b/spec/lib/bulk_imports/groups/pipelines/project_entities_pipeline_spec.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Groups::Pipelines::ProjectEntitiesPipeline do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:destination_group) { create(:group) }
+
+ let_it_be(:entity) do
+ create(
+ :bulk_import_entity,
+ group: destination_group,
+ destination_namespace: destination_group.full_path
+ )
+ end
+
+ let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) }
+ let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) }
+
+ let(:extracted_data) do
+ BulkImports::Pipeline::ExtractedData.new(data: {
+ 'name' => 'project',
+ 'full_path' => 'group/project'
+ })
+ end
+
+ subject { described_class.new(context) }
+
+ describe '#run' do
+ before do
+ allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor|
+ allow(extractor).to receive(:extract).and_return(extracted_data)
+ end
+
+ destination_group.add_owner(user)
+ end
+
+ it 'creates project entity' do
+ expect { subject.run }.to change(BulkImports::Entity, :count).by(1)
+
+ project_entity = BulkImports::Entity.last
+
+ expect(project_entity.source_type).to eq('project_entity')
+ expect(project_entity.source_full_path).to eq('group/project')
+ expect(project_entity.destination_name).to eq('project')
+ expect(project_entity.destination_namespace).to eq(destination_group.full_path)
+ 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::GetProjectsQuery
+ }
+ )
+ 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/stage_spec.rb b/spec/lib/bulk_imports/groups/stage_spec.rb
index 4398b00e7e9..81c0ffc14d4 100644
--- a/spec/lib/bulk_imports/stage_spec.rb
+++ b/spec/lib/bulk_imports/groups/stage_spec.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
+require 'spec_helper'
-RSpec.describe BulkImports::Stage do
+RSpec.describe BulkImports::Groups::Stage do
let(:pipelines) do
[
[0, BulkImports::Groups::Pipelines::GroupPipeline],
@@ -19,18 +19,21 @@ RSpec.describe BulkImports::Stage do
describe '.pipelines' do
it 'list all the pipelines with their stage number, ordered by stage' do
expect(described_class.pipelines & pipelines).to eq(pipelines)
- expect(described_class.pipelines.last.last).to eq(BulkImports::Groups::Pipelines::EntityFinisher)
+ expect(described_class.pipelines.last.last).to eq(BulkImports::Common::Pipelines::EntityFinisher)
end
- end
- describe '.pipeline_exists?' do
- it 'returns true when the given pipeline name exists in the pipelines list' do
- expect(described_class.pipeline_exists?(BulkImports::Groups::Pipelines::GroupPipeline)).to eq(true)
- expect(described_class.pipeline_exists?('BulkImports::Groups::Pipelines::GroupPipeline')).to eq(true)
+ it 'includes project entities pipeline' do
+ stub_feature_flags(bulk_import_projects: true)
+
+ expect(described_class.pipelines).to include([1, BulkImports::Groups::Pipelines::ProjectEntitiesPipeline])
end
- it 'returns false when the given pipeline name exists in the pipelines list' do
- expect(described_class.pipeline_exists?('BulkImports::Groups::Pipelines::InexistentPipeline')).to eq(false)
+ context 'when bulk_import_projects feature flag is disabled' do
+ it 'does not include project entities pipeline' do
+ stub_feature_flags(bulk_import_projects: false)
+
+ expect(described_class.pipelines.flatten).not_to include(BulkImports::Groups::Pipelines::ProjectEntitiesPipeline)
+ end
end
end
end
diff --git a/spec/lib/bulk_imports/projects/pipelines/project_pipeline_spec.rb b/spec/lib/bulk_imports/projects/pipelines/project_pipeline_spec.rb
new file mode 100644
index 00000000000..c53c0849931
--- /dev/null
+++ b/spec/lib/bulk_imports/projects/pipelines/project_pipeline_spec.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Projects::Pipelines::ProjectPipeline do
+ describe '#run' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:bulk_import) { create(:bulk_import, user: user) }
+
+ let_it_be(:entity) do
+ create(
+ :bulk_import_entity,
+ source_type: :project_entity,
+ bulk_import: bulk_import,
+ source_full_path: 'source/full/path',
+ destination_name: 'My Destination Project',
+ destination_namespace: group.full_path
+ )
+ end
+
+ let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) }
+ let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) }
+
+ let(:project_data) do
+ {
+ 'visibility' => 'private',
+ 'created_at' => 10.days.ago,
+ 'archived' => false,
+ 'shared_runners_enabled' => true,
+ 'container_registry_enabled' => true,
+ 'only_allow_merge_if_pipeline_succeeds' => true,
+ 'only_allow_merge_if_all_discussions_are_resolved' => true,
+ 'request_access_enabled' => true,
+ 'printing_merge_request_link_enabled' => true,
+ 'remove_source_branch_after_merge' => true,
+ 'autoclose_referenced_issues' => true,
+ 'suggestion_commit_message' => 'message',
+ 'wiki_enabled' => true
+ }
+ end
+
+ subject(:project_pipeline) { described_class.new(context) }
+
+ before do
+ allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor|
+ allow(extractor).to receive(:extract).and_return(BulkImports::Pipeline::ExtractedData.new(data: project_data))
+ end
+
+ group.add_owner(user)
+ end
+
+ it 'imports new project into destination group', :aggregate_failures do
+ expect { project_pipeline.run }.to change { Project.count }.by(1)
+
+ project_path = 'my-destination-project'
+ imported_project = Project.find_by_path(project_path)
+
+ expect(imported_project).not_to be_nil
+ expect(imported_project.group).to eq(group)
+ expect(imported_project.suggestion_commit_message).to eq('message')
+ expect(imported_project.archived?).to eq(project_data['archived'])
+ expect(imported_project.shared_runners_enabled?).to eq(project_data['shared_runners_enabled'])
+ expect(imported_project.container_registry_enabled?).to eq(project_data['container_registry_enabled'])
+ expect(imported_project.only_allow_merge_if_pipeline_succeeds?).to eq(project_data['only_allow_merge_if_pipeline_succeeds'])
+ expect(imported_project.only_allow_merge_if_all_discussions_are_resolved?).to eq(project_data['only_allow_merge_if_all_discussions_are_resolved'])
+ expect(imported_project.request_access_enabled?).to eq(project_data['request_access_enabled'])
+ expect(imported_project.printing_merge_request_link_enabled?).to eq(project_data['printing_merge_request_link_enabled'])
+ expect(imported_project.remove_source_branch_after_merge?).to eq(project_data['remove_source_branch_after_merge'])
+ expect(imported_project.autoclose_referenced_issues?).to eq(project_data['autoclose_referenced_issues'])
+ expect(imported_project.wiki_enabled?).to eq(project_data['wiki_enabled'])
+ 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::Projects::Graphql::GetProjectQuery }
+ )
+ end
+
+ it 'has transformers' do
+ expect(described_class.transformers)
+ .to contain_exactly(
+ { klass: BulkImports::Common::Transformers::ProhibitedAttributesTransformer, options: nil },
+ { klass: BulkImports::Projects::Transformers::ProjectAttributesTransformer, options: nil }
+ )
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/projects/stage_spec.rb b/spec/lib/bulk_imports/projects/stage_spec.rb
new file mode 100644
index 00000000000..428812a34ef
--- /dev/null
+++ b/spec/lib/bulk_imports/projects/stage_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Projects::Stage do
+ let(:pipelines) do
+ [
+ [0, BulkImports::Projects::Pipelines::ProjectPipeline],
+ [1, BulkImports::Common::Pipelines::EntityFinisher]
+ ]
+ end
+
+ describe '.pipelines' do
+ it 'list all the pipelines with their stage number, ordered by stage' do
+ expect(described_class.pipelines).to eq(pipelines)
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/projects/transformers/project_attributes_transformer_spec.rb b/spec/lib/bulk_imports/projects/transformers/project_attributes_transformer_spec.rb
new file mode 100644
index 00000000000..822bb9a5605
--- /dev/null
+++ b/spec/lib/bulk_imports/projects/transformers/project_attributes_transformer_spec.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Projects::Transformers::ProjectAttributesTransformer do
+ describe '#transform' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:destination_group) { create(:group) }
+ let_it_be(:project) { create(:project, name: 'My Source Project') }
+ let_it_be(:bulk_import) { create(:bulk_import, user: user) }
+
+ let_it_be(:entity) do
+ create(
+ :bulk_import_entity,
+ source_type: :project_entity,
+ bulk_import: bulk_import,
+ source_full_path: 'source/full/path',
+ destination_name: 'Destination Project Name',
+ destination_namespace: destination_group.full_path
+ )
+ end
+
+ let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) }
+ let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker) }
+
+ let(:data) do
+ {
+ 'name' => 'source_name',
+ 'visibility' => 'private'
+ }
+ end
+
+ subject(:transformed_data) { described_class.new.transform(context, data) }
+
+ it 'transforms name to destination name' do
+ expect(transformed_data[:name]).to eq(entity.destination_name)
+ end
+
+ it 'adds path as parameterized name' do
+ expect(transformed_data[:path]).to eq(entity.destination_name.parameterize)
+ end
+
+ it 'transforms visibility level' do
+ visibility = data['visibility']
+
+ expect(transformed_data).not_to have_key(:visibility)
+ expect(transformed_data[:visibility_level]).to eq(Gitlab::VisibilityLevel.string_options[visibility])
+ end
+
+ it 'adds import type' do
+ expect(transformed_data[:import_type]).to eq(described_class::PROJECT_IMPORT_TYPE)
+ end
+
+ describe 'namespace_id' do
+ context 'when destination namespace is present' do
+ it 'adds namespace_id' do
+ expect(transformed_data[:namespace_id]).to eq(destination_group.id)
+ end
+ end
+
+ context 'when destination namespace is blank' do
+ it 'does not add namespace_id key' do
+ entity = create(
+ :bulk_import_entity,
+ source_type: :project_entity,
+ bulk_import: bulk_import,
+ source_full_path: 'source/full/path',
+ destination_name: 'Destination Project Name',
+ destination_namespace: ''
+ )
+
+ context = double(entity: entity)
+
+ expect(described_class.new.transform(context, data)).not_to have_key(:namespace_id)
+ end
+ end
+ end
+
+ it 'converts all keys to symbols' do
+ expect(transformed_data.keys).to contain_exactly(:name, :path, :import_type, :visibility_level, :namespace_id)
+ end
+ end
+end
diff --git a/spec/lib/error_tracking/collector/dsn_spec.rb b/spec/lib/error_tracking/collector/dsn_spec.rb
new file mode 100644
index 00000000000..af55e6f20ec
--- /dev/null
+++ b/spec/lib/error_tracking/collector/dsn_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ErrorTracking::Collector::Dsn do
+ describe '.build__url' do
+ let(:gitlab) do
+ double(
+ protocol: 'https',
+ https: true,
+ host: 'gitlab.example.com',
+ port: '4567',
+ relative_url_root: nil
+ )
+ end
+
+ subject { described_class.build_url('abcdef1234567890', 778) }
+
+ it 'returns a valid URL' do
+ allow(Settings).to receive(:gitlab).and_return(gitlab)
+ allow(Settings).to receive(:gitlab_on_standard_port?).and_return(false)
+
+ is_expected.to eq('https://abcdef1234567890@gitlab.example.com:4567/api/v4/error_tracking/collector/778')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/action_cable/request_store_callbacks_spec.rb b/spec/lib/gitlab/action_cable/request_store_callbacks_spec.rb
new file mode 100644
index 00000000000..3b73252709c
--- /dev/null
+++ b/spec/lib/gitlab/action_cable/request_store_callbacks_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::ActionCable::RequestStoreCallbacks do
+ describe '.wrapper' do
+ it 'enables RequestStore in the inner block' do
+ expect(RequestStore.active?).to eq(false)
+
+ described_class.wrapper.call(
+ nil,
+ lambda do
+ expect(RequestStore.active?).to eq(true)
+ end
+ )
+
+ expect(RequestStore.active?).to eq(false)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/backfill_projects_with_coverage_spec.rb b/spec/lib/gitlab/background_migration/backfill_projects_with_coverage_spec.rb
new file mode 100644
index 00000000000..49056154744
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_projects_with_coverage_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BackfillProjectsWithCoverage, schema: 20210818185845 do
+ let(:projects) { table(:projects) }
+ let(:project_ci_feature_usages) { table(:project_ci_feature_usages) }
+ let(:ci_pipelines) { table(:ci_pipelines) }
+ let(:ci_daily_build_group_report_results) { table(:ci_daily_build_group_report_results) }
+ let(:group) { table(:namespaces).create!(name: 'user', path: 'user') }
+ let(:project_1) { projects.create!(namespace_id: group.id) }
+ let(:project_2) { projects.create!(namespace_id: group.id) }
+ let(:pipeline_1) { ci_pipelines.create!(project_id: project_1.id, source: 13) }
+ let(:pipeline_2) { ci_pipelines.create!(project_id: project_1.id, source: 13) }
+ let(:pipeline_3) { ci_pipelines.create!(project_id: project_2.id, source: 13) }
+ let(:pipeline_4) { ci_pipelines.create!(project_id: project_2.id, source: 13) }
+
+ subject { described_class.new }
+
+ describe '#perform' do
+ before do
+ ci_daily_build_group_report_results.create!(
+ id: 1,
+ project_id: project_1.id,
+ date: 4.days.ago,
+ last_pipeline_id: pipeline_1.id,
+ ref_path: 'main',
+ group_name: 'rspec',
+ data: { coverage: 95.0 },
+ default_branch: true,
+ group_id: group.id
+ )
+
+ ci_daily_build_group_report_results.create!(
+ id: 2,
+ project_id: project_1.id,
+ date: 3.days.ago,
+ last_pipeline_id: pipeline_2.id,
+ ref_path: 'main',
+ group_name: 'rspec',
+ data: { coverage: 95.0 },
+ default_branch: true,
+ group_id: group.id
+ )
+
+ ci_daily_build_group_report_results.create!(
+ id: 3,
+ project_id: project_2.id,
+ date: 2.days.ago,
+ last_pipeline_id: pipeline_3.id,
+ ref_path: 'main',
+ group_name: 'rspec',
+ data: { coverage: 95.0 },
+ default_branch: true,
+ group_id: group.id
+ )
+
+ ci_daily_build_group_report_results.create!(
+ id: 4,
+ project_id: project_2.id,
+ date: 1.day.ago,
+ last_pipeline_id: pipeline_4.id,
+ ref_path: 'test_branch',
+ group_name: 'rspec',
+ data: { coverage: 95.0 },
+ default_branch: false,
+ group_id: group.id
+ )
+
+ stub_const("#{described_class}::INSERT_DELAY_SECONDS", 0)
+ end
+
+ it 'creates entries per project and default_branch combination in the given range', :aggregate_failures do
+ subject.perform(1, 4, 2)
+
+ entries = project_ci_feature_usages.order('project_id ASC, default_branch DESC')
+
+ expect(entries.count).to eq(3)
+ expect(entries[0]).to have_attributes(project_id: project_1.id, feature: 1, default_branch: true)
+ expect(entries[1]).to have_attributes(project_id: project_2.id, feature: 1, default_branch: true)
+ expect(entries[2]).to have_attributes(project_id: project_2.id, feature: 1, default_branch: false)
+ end
+
+ context 'when an entry for the project and default branch combination already exists' do
+ before do
+ subject.perform(1, 4, 2)
+ end
+
+ it 'does not create a new entry' do
+ expect { subject.perform(1, 4, 2) }.not_to change { project_ci_feature_usages.count }
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/extract_project_topics_into_separate_table_spec.rb b/spec/lib/gitlab/background_migration/extract_project_topics_into_separate_table_spec.rb
new file mode 100644
index 00000000000..a111007a984
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/extract_project_topics_into_separate_table_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::ExtractProjectTopicsIntoSeparateTable, schema: 20210730104800 do
+ it 'correctly extracts project topics into separate table' do
+ namespaces = table(:namespaces)
+ projects = table(:projects)
+ taggings = table(:taggings)
+ tags = table(:tags)
+ project_topics = table(:project_topics)
+ topics = table(:topics)
+
+ namespace = namespaces.create!(name: 'foo', path: 'foo')
+ project = projects.create!(namespace_id: namespace.id)
+ tag_1 = tags.create!(name: 'Topic1')
+ tag_2 = tags.create!(name: 'Topic2')
+ tag_3 = tags.create!(name: 'Topic3')
+ topic_3 = topics.create!(name: 'Topic3')
+ tagging_1 = taggings.create!(taggable_type: 'Project', taggable_id: project.id, context: 'topics', tag_id: tag_1.id)
+ tagging_2 = taggings.create!(taggable_type: 'Project', taggable_id: project.id, context: 'topics', tag_id: tag_2.id)
+ other_tagging = taggings.create!(taggable_type: 'Other', taggable_id: project.id, context: 'topics', tag_id: tag_1.id)
+ tagging_3 = taggings.create!(taggable_type: 'Project', taggable_id: project.id, context: 'topics', tag_id: tag_3.id)
+ tagging_4 = taggings.create!(taggable_type: 'Project', taggable_id: -1, context: 'topics', tag_id: tag_1.id)
+ tagging_5 = taggings.create!(taggable_type: 'Project', taggable_id: project.id, context: 'topics', tag_id: -1)
+
+ subject.perform(tagging_1.id, tagging_5.id)
+
+ # Tagging records
+ expect { tagging_1.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { tagging_2.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { other_tagging.reload }.not_to raise_error(ActiveRecord::RecordNotFound)
+ expect { tagging_3.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { tagging_4.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { tagging_5.reload }.to raise_error(ActiveRecord::RecordNotFound)
+
+ # Topic records
+ topic_1 = topics.find_by(name: 'Topic1')
+ topic_2 = topics.find_by(name: 'Topic2')
+ expect(topics.all).to contain_exactly(topic_1, topic_2, topic_3)
+
+ # ProjectTopic records
+ expect(project_topics.all.map(&:topic_id)).to contain_exactly(topic_1.id, topic_2.id, topic_3.id)
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/migrate_merge_request_diff_commit_users_spec.rb b/spec/lib/gitlab/background_migration/migrate_merge_request_diff_commit_users_spec.rb
index 496ce151032..91e8dcdf880 100644
--- a/spec/lib/gitlab/background_migration/migrate_merge_request_diff_commit_users_spec.rb
+++ b/spec/lib/gitlab/background_migration/migrate_merge_request_diff_commit_users_spec.rb
@@ -91,6 +91,18 @@ RSpec.describe Gitlab::BackgroundMigration::MigrateMergeRequestDiffCommitUsers d
end
describe '#perform' do
+ it 'skips jobs that have already been completed' do
+ Gitlab::Database::BackgroundMigrationJob.create!(
+ class_name: 'MigrateMergeRequestDiffCommitUsers',
+ arguments: [1, 10],
+ status: :succeeded
+ )
+
+ expect(migration).not_to receive(:get_data_to_update)
+
+ migration.perform(1, 10)
+ end
+
it 'migrates the data in the range' do
commits.create!(
merge_request_diff_id: diff.id,
diff --git a/spec/lib/gitlab/background_migration/migrate_pages_metadata_spec.rb b/spec/lib/gitlab/background_migration/migrate_pages_metadata_spec.rb
index 906a6a747c9..815dc2e73e5 100644
--- a/spec/lib/gitlab/background_migration/migrate_pages_metadata_spec.rb
+++ b/spec/lib/gitlab/background_migration/migrate_pages_metadata_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Gitlab::BackgroundMigration::MigratePagesMetadata, schema: 201909
subject(:migrate_pages_metadata) { described_class.new }
- describe '#perform_on_relation' do
+ describe '#perform' do
let(:namespaces) { table(:namespaces) }
let(:builds) { table(:ci_builds) }
let(:pages_metadata) { table(:project_pages_metadata) }
@@ -23,9 +23,9 @@ RSpec.describe Gitlab::BackgroundMigration::MigratePagesMetadata, schema: 201909
not_migrated_no_pages = projects.create!(namespace_id: namespace.id, name: 'Not Migrated No Pages')
project_not_in_relation_scope = projects.create!(namespace_id: namespace.id, name: 'Other')
- projects_relation = projects.where(id: [not_migrated_with_pages, not_migrated_no_pages, migrated])
+ ids = [not_migrated_no_pages.id, not_migrated_with_pages.id, migrated.id]
- migrate_pages_metadata.perform_on_relation(projects_relation)
+ migrate_pages_metadata.perform(ids.min, ids.max)
expect(pages_metadata.find_by_project_id(not_migrated_with_pages.id).deployed).to eq(true)
expect(pages_metadata.find_by_project_id(not_migrated_no_pages.id).deployed).to eq(false)
@@ -33,12 +33,4 @@ RSpec.describe Gitlab::BackgroundMigration::MigratePagesMetadata, schema: 201909
expect(pages_metadata.find_by_project_id(project_not_in_relation_scope.id)).to be_nil
end
end
-
- describe '#perform' do
- it 'creates relation and delegates to #perform_on_relation' do
- expect(migrate_pages_metadata).to receive(:perform_on_relation).with(projects.where(id: 3..5))
-
- migrate_pages_metadata.perform(3, 5)
- end
- end
end
diff --git a/spec/lib/gitlab/background_migration/steal_migrate_merge_request_diff_commit_users_spec.rb b/spec/lib/gitlab/background_migration/steal_migrate_merge_request_diff_commit_users_spec.rb
new file mode 100644
index 00000000000..f2fb2ab6b6e
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/steal_migrate_merge_request_diff_commit_users_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::StealMigrateMergeRequestDiffCommitUsers do
+ let(:migration) { described_class.new }
+
+ describe '#perform' do
+ it 'processes the background migration' do
+ spy = instance_spy(
+ Gitlab::BackgroundMigration::MigrateMergeRequestDiffCommitUsers
+ )
+
+ allow(Gitlab::BackgroundMigration::MigrateMergeRequestDiffCommitUsers)
+ .to receive(:new)
+ .and_return(spy)
+
+ expect(spy).to receive(:perform).with(1, 4)
+ expect(migration).to receive(:schedule_next_job)
+
+ migration.perform(1, 4)
+ end
+ end
+
+ describe '#schedule_next_job' do
+ it 'schedules the next job in ascending order' do
+ Gitlab::Database::BackgroundMigrationJob.create!(
+ class_name: 'MigrateMergeRequestDiffCommitUsers',
+ arguments: [10, 20]
+ )
+
+ Gitlab::Database::BackgroundMigrationJob.create!(
+ class_name: 'MigrateMergeRequestDiffCommitUsers',
+ arguments: [40, 50]
+ )
+
+ expect(BackgroundMigrationWorker)
+ .to receive(:perform_in)
+ .with(5.minutes, 'StealMigrateMergeRequestDiffCommitUsers', [10, 20])
+
+ migration.schedule_next_job
+ end
+
+ it 'does not schedule any new jobs when there are none' do
+ expect(BackgroundMigrationWorker).not_to receive(:perform_in)
+
+ migration.schedule_next_job
+ end
+ end
+end
diff --git a/spec/lib/gitlab/changelog/config_spec.rb b/spec/lib/gitlab/changelog/config_spec.rb
index a464c1e57e5..c410ba4d116 100644
--- a/spec/lib/gitlab/changelog/config_spec.rb
+++ b/spec/lib/gitlab/changelog/config_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Gitlab::Changelog::Config do
+ include ProjectForksHelper
+
let(:project) { build_stubbed(:project) }
describe '.from_git' do
@@ -13,7 +15,7 @@ RSpec.describe Gitlab::Changelog::Config do
expect(described_class)
.to receive(:from_hash)
- .with(project, 'date_format' => '%Y')
+ .with(project, { 'date_format' => '%Y' }, nil)
described_class.from_git(project)
end
@@ -33,12 +35,25 @@ RSpec.describe Gitlab::Changelog::Config do
describe '.from_hash' do
it 'sets the configuration according to a Hash' do
+ user1 = create(:user)
+ user2 = create(:user)
+ user3 = create(:user)
+ group = create(:group, path: 'group')
+ group2 = create(:group, path: 'group-path')
+ group.add_developer(user1)
+ group.add_developer(user2)
+ group2.add_developer(user3)
+
config = described_class.from_hash(
project,
- 'date_format' => 'foo',
- 'template' => 'bar',
- 'categories' => { 'foo' => 'bar' },
- 'tag_regex' => 'foo'
+ {
+ 'date_format' => 'foo',
+ 'template' => 'bar',
+ 'categories' => { 'foo' => 'bar' },
+ 'tag_regex' => 'foo',
+ 'include_groups' => %w[group group-path non-existent-group]
+ },
+ user1
)
expect(config.date_format).to eq('foo')
@@ -47,6 +62,7 @@ RSpec.describe Gitlab::Changelog::Config do
expect(config.categories).to eq({ 'foo' => 'bar' })
expect(config.tag_regex).to eq('foo')
+ expect(config.always_credit_user_ids).to match_array([user1.id, user2.id, user3.id])
end
it 'raises Error when the categories are not a Hash' do
@@ -66,20 +82,33 @@ RSpec.describe Gitlab::Changelog::Config do
end
describe '#contributor?' do
- it 'returns true if a user is a contributor' do
- user = build_stubbed(:author)
+ let(:project) { create(:project, :public, :repository) }
- allow(project.team).to receive(:contributor?).with(user).and_return(true)
-
- expect(described_class.new(project).contributor?(user)).to eq(true)
- end
+ context 'when user is a member of project' do
+ let(:user) { create(:user) }
- it "returns true if a user isn't a contributor" do
- user = build_stubbed(:author)
+ before do
+ project.add_developer(user)
+ end
- allow(project.team).to receive(:contributor?).with(user).and_return(false)
+ it { expect(described_class.new(project).contributor?(user)).to eq(false) }
+ end
- expect(described_class.new(project).contributor?(user)).to eq(false)
+ context 'when user has at least one merge request merged into default_branch' do
+ let(:contributor) { create(:user) }
+ let(:user_without_access) { create(:user) }
+ let(:user_fork) { fork_project(project, contributor, repository: true) }
+
+ before do
+ create(:merge_request, :merged,
+ author: contributor,
+ target_project: project,
+ source_project: user_fork,
+ target_branch: project.default_branch.to_s)
+ end
+
+ it { expect(described_class.new(project).contributor?(contributor)).to eq(true) }
+ it { expect(described_class.new(project).contributor?(user_without_access)).to eq(false) }
end
end
@@ -107,4 +136,55 @@ RSpec.describe Gitlab::Changelog::Config do
expect(config.format_date(time)).to eq('2021-01-05')
end
end
+
+ describe '#always_credit_author?' do
+ let_it_be(:group_member) { create(:user) }
+ let_it_be(:non_group_member) { create(:user) }
+ let_it_be(:group) { create(:group, :private, path: 'group') }
+
+ before do
+ group.add_developer(group_member)
+ end
+
+ context 'when include_groups is defined' do
+ context 'when user generating changelog has access to group' do
+ it 'returns whether author should always be credited' do
+ config = described_class.from_hash(
+ project,
+ { 'include_groups' => ['group'] },
+ group_member
+ )
+
+ expect(config.always_credit_author?(group_member)).to eq(true)
+ expect(config.always_credit_author?(non_group_member)).to eq(false)
+ end
+ end
+
+ context 'when user generating changelog has no access to group' do
+ it 'always returns false' do
+ config = described_class.from_hash(
+ project,
+ { 'include_groups' => ['group'] },
+ non_group_member
+ )
+
+ expect(config.always_credit_author?(group_member)).to eq(false)
+ expect(config.always_credit_author?(non_group_member)).to eq(false)
+ end
+ end
+ end
+
+ context 'when include_groups is not defined' do
+ it 'always returns false' do
+ config = described_class.from_hash(
+ project,
+ {},
+ group_member
+ )
+
+ expect(config.always_credit_author?(group_member)).to eq(false)
+ expect(config.always_credit_author?(non_group_member)).to eq(false)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/changelog/release_spec.rb b/spec/lib/gitlab/changelog/release_spec.rb
index f95244d6750..d8434821640 100644
--- a/spec/lib/gitlab/changelog/release_spec.rb
+++ b/spec/lib/gitlab/changelog/release_spec.rb
@@ -94,6 +94,30 @@ RSpec.describe Gitlab::Changelog::Release do
end
end
+ context 'when the author should always be credited' do
+ it 'includes the author' do
+ allow(config).to receive(:contributor?).with(author).and_return(false)
+ allow(config).to receive(:always_credit_author?).with(author).and_return(true)
+
+ release.add_entry(
+ title: 'Entry title',
+ commit: commit,
+ category: 'fixed',
+ author: author
+ )
+
+ expect(release.to_markdown).to eq(<<~OUT)
+ ## 1.0.0 (2021-01-05)
+
+ ### fixed (1 change)
+
+ - [Entry title](#{commit.to_reference(full: true)}) \
+ by #{author.to_reference(full: true)}
+
+ OUT
+ end
+ end
+
context 'when a category has no entries' do
it "isn't included in the output" do
config.categories['kittens'] = 'Kittens'
diff --git a/spec/lib/gitlab/chat/command_spec.rb b/spec/lib/gitlab/chat/command_spec.rb
index 89c693daaa0..d99c07d1fa3 100644
--- a/spec/lib/gitlab/chat/command_spec.rb
+++ b/spec/lib/gitlab/chat/command_spec.rb
@@ -44,7 +44,7 @@ RSpec.describe Gitlab::Chat::Command do
let(:pipeline) { command.create_pipeline }
before do
- stub_ci_pipeline_yaml_file(gitlab_ci_yaml)
+ stub_ci_pipeline_to_return_yaml_file
project.add_developer(chat_name.user)
end
diff --git a/spec/lib/gitlab/checks/changes_access_spec.rb b/spec/lib/gitlab/checks/changes_access_spec.rb
index 4a74dfcec34..633c4baa931 100644
--- a/spec/lib/gitlab/checks/changes_access_spec.rb
+++ b/spec/lib/gitlab/checks/changes_access_spec.rb
@@ -8,53 +8,35 @@ RSpec.describe Gitlab::Checks::ChangesAccess do
subject { changes_access }
describe '#validate!' do
- shared_examples '#validate!' do
- before do
- allow(project).to receive(:lfs_enabled?).and_return(true)
- end
-
- context 'without failed checks' do
- it "doesn't raise an error" do
- expect { subject.validate! }.not_to raise_error
- end
-
- it 'calls lfs checks' do
- expect_next_instance_of(Gitlab::Checks::LfsCheck) do |instance|
- expect(instance).to receive(:validate!)
- end
+ before do
+ allow(project).to receive(:lfs_enabled?).and_return(true)
+ end
- subject.validate!
- end
+ context 'without failed checks' do
+ it "doesn't raise an error" do
+ expect { subject.validate! }.not_to raise_error
end
- context 'when time limit was reached' do
- it 'raises a TimeoutError' do
- logger = Gitlab::Checks::TimedLogger.new(start_time: timeout.ago, timeout: timeout)
- access = described_class.new(changes,
- project: project,
- user_access: user_access,
- protocol: protocol,
- logger: logger)
-
- expect { access.validate! }.to raise_error(Gitlab::Checks::TimedLogger::TimeoutError)
+ it 'calls lfs checks' do
+ expect_next_instance_of(Gitlab::Checks::LfsCheck) do |instance|
+ expect(instance).to receive(:validate!)
end
- end
- end
- context 'with batched commits enabled' do
- before do
- stub_feature_flags(changes_batch_commits: true)
+ subject.validate!
end
-
- it_behaves_like '#validate!'
end
- context 'with batched commits disabled' do
- before do
- stub_feature_flags(changes_batch_commits: false)
- end
+ context 'when time limit was reached' do
+ it 'raises a TimeoutError' do
+ logger = Gitlab::Checks::TimedLogger.new(start_time: timeout.ago, timeout: timeout)
+ access = described_class.new(changes,
+ project: project,
+ user_access: user_access,
+ protocol: protocol,
+ logger: logger)
- it_behaves_like '#validate!'
+ expect { access.validate! }.to raise_error(Gitlab::Checks::TimedLogger::TimeoutError)
+ end
end
end
@@ -192,6 +174,101 @@ RSpec.describe Gitlab::Checks::ChangesAccess do
end
end
+ describe '#single_change_accesses' do
+ let(:commits_for) { {} }
+ let(:expected_accesses) { [] }
+
+ shared_examples '#single_change_access' do
+ before do
+ commits_for.each do |id, commits|
+ expect(subject)
+ .to receive(:commits_for)
+ .with(id)
+ .and_return(commits)
+ end
+ end
+
+ it 'returns an array of SingleChangeAccess' do
+ # Commits are wrapped in a Gitlab::Lazy and thus need to be resolved
+ # first such that we can directly compare types.
+ actual_accesses = subject.single_change_accesses
+ .each { |access| access.instance_variable_set(:@commits, access.commits.to_a) }
+
+ expect(actual_accesses).to match_array(expected_accesses)
+ end
+ end
+
+ context 'with no changes' do
+ let(:changes) { [] }
+
+ it_behaves_like '#single_change_access'
+ end
+
+ context 'with a single change and no new commits' do
+ let(:commits_for) { { 'new' => [] } }
+ let(:changes) do
+ [
+ { oldrev: 'old', newrev: 'new', ref: 'refs/heads/branch' }
+ ]
+ end
+
+ let(:expected_accesses) do
+ [
+ have_attributes(oldrev: 'old', newrev: 'new', ref: 'refs/heads/branch', commits: [])
+ ]
+ end
+
+ it_behaves_like '#single_change_access'
+ end
+
+ context 'with a single change and new commits' do
+ let(:commits_for) { { 'new' => [create_commit('new', [])] } }
+ let(:changes) do
+ [
+ { oldrev: 'old', newrev: 'new', ref: 'refs/heads/branch' }
+ ]
+ end
+
+ let(:expected_accesses) do
+ [
+ have_attributes(oldrev: 'old', newrev: 'new', ref: 'refs/heads/branch', commits: [create_commit('new', [])])
+ ]
+ end
+
+ it_behaves_like '#single_change_access'
+ end
+
+ context 'with multiple changes' do
+ let(:commits_for) do
+ {
+ 'a' => [create_commit('a', [])],
+ 'c' => [create_commit('c', [])],
+ 'd' => []
+ }
+ end
+
+ let(:changes) do
+ [
+ { newrev: 'a', ref: 'refs/heads/a' },
+ { oldrev: 'b', ref: 'refs/heads/b' },
+ { oldrev: 'a', newrev: 'c', ref: 'refs/heads/c' },
+ { newrev: 'd', ref: 'refs/heads/d' }
+ ]
+ end
+
+ let(:expected_accesses) do
+ [
+ have_attributes(newrev: 'a', ref: 'refs/heads/a', commits: [create_commit('a', [])]),
+ have_attributes(oldrev: 'b', ref: 'refs/heads/b', commits: []),
+ have_attributes(oldrev: 'a', newrev: 'c', ref: 'refs/heads/c', commits: [create_commit('c', [])]),
+ have_attributes(newrev: 'd', ref: 'refs/heads/d', commits: [])
+ ]
+ end
+
+ it_behaves_like '#single_change_access'
+ end
+ end
+
def create_commit(id, parent_ids)
Gitlab::Git::Commit.new(project.repository, {
id: id,
diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb
index 5b47d3a3922..0bb26babfc0 100644
--- a/spec/lib/gitlab/ci/config/entry/job_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb
@@ -169,6 +169,22 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do
it { expect(entry).to be_valid }
end
end
+
+ context 'when rules are used' do
+ let(:config) { { script: 'ls', cache: { key: 'test' }, rules: rules } }
+
+ let(:rules) do
+ [
+ { if: '$CI_PIPELINE_SOURCE == "schedule"', when: 'never' },
+ [
+ { if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' },
+ { if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }
+ ]
+ ]
+ end
+
+ it { expect(entry).to be_valid }
+ end
end
context 'when entry value is not correct' do
@@ -485,6 +501,70 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do
end
end
end
+
+ context 'when invalid rules are used' do
+ let(:config) { { script: 'ls', cache: { key: 'test' }, rules: rules } }
+
+ context 'with rules nested more than max allowed levels' do
+ let(:sample_rule) { { if: '$THIS == "other"', when: 'always' } }
+
+ let(:rules) do
+ [
+ { if: '$THIS == "that"', when: 'always' },
+ [
+ { if: '$SKIP', when: 'never' },
+ [
+ sample_rule,
+ [
+ sample_rule,
+ [
+ sample_rule,
+ [
+ sample_rule,
+ [
+ sample_rule,
+ [
+ sample_rule,
+ [
+ sample_rule,
+ [
+ sample_rule,
+ [
+ sample_rule,
+ [
+ sample_rule,
+ [sample_rule]
+ ]
+ ]
+ ]
+ ]
+ ]
+ ]
+ ]
+ ]
+ ]
+ ]
+ ]
+ ]
+ end
+
+ it { expect(entry).not_to be_valid }
+ end
+
+ context 'with rules with invalid keys' do
+ let(:rules) do
+ [
+ { invalid_key: 'invalid' },
+ [
+ { if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' },
+ { if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }
+ ]
+ ]
+ end
+
+ it { expect(entry).not_to be_valid }
+ end
+ end
end
end
@@ -618,6 +698,29 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do
end
end
end
+
+ context 'when job is using tags' do
+ context 'when limit is reached' do
+ let(:tags) { Array.new(100) { |i| "tag-#{i}" } }
+ let(:config) { { tags: tags, script: 'test' } }
+
+ it 'returns error', :aggregate_failures do
+ expect(entry).not_to be_valid
+ expect(entry.errors)
+ .to include "tags config must be less than the limit of #{Gitlab::Ci::Config::Entry::Tags::TAGS_LIMIT} tags"
+ end
+ end
+
+ context 'when limit is not reached' do
+ let(:config) { { tags: %w[tag1 tag2], script: 'test' } }
+
+ it 'returns a valid entry', :aggregate_failures do
+ expect(entry).to be_valid
+ expect(entry.errors).to be_empty
+ expect(entry.tags).to eq(%w[tag1 tag2])
+ end
+ end
+ end
end
describe '#manual_action?' do
diff --git a/spec/lib/gitlab/ci/config/entry/rules_spec.rb b/spec/lib/gitlab/ci/config/entry/rules_spec.rb
index 91252378541..cfec33003e4 100644
--- a/spec/lib/gitlab/ci/config/entry/rules_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/rules_spec.rb
@@ -53,7 +53,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Rules do
let(:config) do
[
{ if: '$THIS == "that"', when: 'always' },
- [{ if: '$SKIP', when: 'never' }]
+ [{ if: '$SKIP', when: 'never' }, { if: '$THIS == "other"', when: 'always' }]
]
end
@@ -64,11 +64,11 @@ RSpec.describe Gitlab::Ci::Config::Entry::Rules do
let(:config) do
[
{ if: '$THIS == "that"', when: 'always' },
- [{ if: '$SKIP', when: 'never' }, [{ if: '$THIS == "other"', when: 'aways' }]]
+ [{ if: '$SKIP', when: 'never' }, [{ if: '$THIS == "other"', when: 'always' }]]
]
end
- it { is_expected.not_to be_valid }
+ it { is_expected.to be_valid }
end
end
@@ -119,7 +119,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Rules do
context 'with rules nested more than one level' do
let(:first_rule) { { if: '$THIS == "that"', when: 'always' } }
let(:second_rule) { { if: '$SKIP', when: 'never' } }
- let(:third_rule) { { if: '$THIS == "other"', when: 'aways' } }
+ let(:third_rule) { { if: '$THIS == "other"', when: 'always' } }
let(:config) do
[
diff --git a/spec/lib/gitlab/ci/config/entry/tags_spec.rb b/spec/lib/gitlab/ci/config/entry/tags_spec.rb
new file mode 100644
index 00000000000..79317de373b
--- /dev/null
+++ b/spec/lib/gitlab/ci/config/entry/tags_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Config::Entry::Tags do
+ let(:entry) { described_class.new(config) }
+
+ describe 'validation' do
+ context 'when tags config value is correct' do
+ let(:config) { %w[tag1 tag2] }
+
+ describe '#value' do
+ it 'returns tags configuration' do
+ expect(entry.value).to eq config
+ end
+ end
+
+ describe '#valid?' do
+ it 'is valid' do
+ expect(entry).to be_valid
+ end
+ end
+ end
+
+ context 'when entry value is not correct' do
+ describe '#errors' do
+ context 'when tags config is not an array of strings' do
+ let(:config) { [1, 2] }
+
+ it 'reports error' do
+ expect(entry.errors)
+ .to include 'tags config should be an array of strings'
+ end
+ end
+
+ context 'when tags limit is reached' do
+ let(:config) { Array.new(50) {|i| "tag-#{i}" } }
+
+ context 'when ci_build_tags_limit is enabled' do
+ before do
+ stub_feature_flags(ci_build_tags_limit: true)
+ end
+
+ it 'reports error' do
+ expect(entry.errors)
+ .to include "tags config must be less than the limit of #{described_class::TAGS_LIMIT} tags"
+ end
+ end
+
+ context 'when ci_build_tags_limit is disabled' do
+ before do
+ stub_feature_flags(ci_build_tags_limit: false)
+ end
+
+ it 'does not report an error' do
+ expect(entry.errors).to be_empty
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/cron_parser_spec.rb b/spec/lib/gitlab/ci/cron_parser_spec.rb
index 15293429354..4017accb462 100644
--- a/spec/lib/gitlab/ci/cron_parser_spec.rb
+++ b/spec/lib/gitlab/ci/cron_parser_spec.rb
@@ -297,4 +297,65 @@ RSpec.describe Gitlab::Ci::CronParser do
it { is_expected.to eq(true) }
end
end
+
+ describe '.parse_natural', :aggregate_failures do
+ let(:cron_line) { described_class.parse_natural_with_timestamp(time, { unit: 'day', duration: 1 }) }
+ let(:time) { Time.parse('Mon, 30 Aug 2021 06:29:44.067132000 UTC +00:00') }
+ let(:hours) { Fugit::Cron.parse(cron_line).hours }
+ let(:minutes) { Fugit::Cron.parse(cron_line).minutes }
+ let(:weekdays) { Fugit::Cron.parse(cron_line).weekdays.first }
+ let(:months) { Fugit::Cron.parse(cron_line).months }
+
+ context 'when repeat cycle is day' do
+ it 'generates daily cron expression', :aggregate_failures do
+ expect(hours).to include time.hour
+ expect(minutes).to include time.min
+ end
+ end
+
+ context 'when repeat cycle is week' do
+ let(:cron_line) { described_class.parse_natural_with_timestamp(time, { unit: 'week', duration: 1 }) }
+
+ it 'generates weekly cron expression', :aggregate_failures do
+ expect(hours).to include time.hour
+ expect(minutes).to include time.min
+ expect(weekdays).to include time.wday
+ end
+ end
+
+ context 'when repeat cycle is month' do
+ let(:cron_line) { described_class.parse_natural_with_timestamp(time, { unit: 'month', duration: 3 }) }
+
+ it 'generates monthly cron expression', :aggregate_failures do
+ expect(minutes).to include time.min
+ expect(months).to include time.month
+ end
+
+ context 'when an unsupported duration is specified' do
+ subject { described_class.parse_natural_with_timestamp(time, { unit: 'month', duration: 7 }) }
+
+ it 'raises an exception' do
+ expect { subject }.to raise_error(NotImplementedError, 'The cadence {:unit=>"month", :duration=>7} is not supported')
+ end
+ end
+ end
+
+ context 'when repeat cycle is year' do
+ let(:cron_line) { described_class.parse_natural_with_timestamp(time, { unit: 'year', duration: 1 }) }
+
+ it 'generates yearly cron expression', :aggregate_failures do
+ expect(hours).to include time.hour
+ expect(minutes).to include time.min
+ expect(months).to include time.month
+ end
+ end
+
+ context 'when the repeat cycle is not implemented' do
+ subject { described_class.parse_natural_with_timestamp(time, { unit: 'quarterly', duration: 1 }) }
+
+ it 'raises an exception' do
+ expect { subject }.to raise_error(NotImplementedError, 'The cadence unit quarterly is not implemented')
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/parsers/security/common_spec.rb b/spec/lib/gitlab/ci/parsers/security/common_spec.rb
index c6387bf615b..c49673f5a4a 100644
--- a/spec/lib/gitlab/ci/parsers/security/common_spec.rb
+++ b/spec/lib/gitlab/ci/parsers/security/common_spec.rb
@@ -1,7 +1,5 @@
# frozen_string_literal: true
-# TODO remove duplication from spec/lib/gitlab/ci/parsers/security/common_spec.rb and spec/lib/gitlab/ci/parsers/security/common_spec.rb
-# See https://gitlab.com/gitlab-org/gitlab/-/issues/336589
require 'spec_helper'
RSpec.describe Gitlab::Ci::Parsers::Security::Common do
@@ -15,11 +13,18 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do
# The path 'yarn.lock' was initially used by DependencyScanning, it is okay for SAST locations to use it, but this could be made better
let(:location) { ::Gitlab::Ci::Reports::Security::Locations::Sast.new(file_path: 'yarn.lock', start_line: 1, end_line: 1) }
let(:tracking_data) { nil }
+ let(:vulnerability_flags_data) do
+ [
+ ::Gitlab::Ci::Reports::Security::Flag.new(type: 'flagged-as-likely-false-positive', origin: 'post analyzer X', description: 'static string to sink'),
+ ::Gitlab::Ci::Reports::Security::Flag.new(type: 'flagged-as-likely-false-positive', origin: 'post analyzer Y', description: 'integer to sink')
+ ]
+ end
before do
allow_next_instance_of(described_class) do |parser|
allow(parser).to receive(:create_location).and_return(location)
allow(parser).to receive(:tracking_data).and_return(tracking_data)
+ allow(parser).to receive(:create_flags).and_return(vulnerability_flags_data)
end
artifact.each_blob { |blob| described_class.parse!(blob, report, vulnerability_finding_signatures_enabled) }
@@ -233,6 +238,17 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do
end
end
+ describe 'parsing flags' do
+ it 'returns flags object for each finding' do
+ flags = report.findings.first.flags
+
+ expect(flags).to contain_exactly(
+ have_attributes(type: 'flagged-as-likely-false-positive', origin: 'post analyzer X', description: 'static string to sink'),
+ have_attributes(type: 'flagged-as-likely-false-positive', origin: 'post analyzer Y', description: 'integer to sink')
+ )
+ end
+ end
+
describe 'parsing links' do
it 'returns links object for each finding', :aggregate_failures do
links = report.findings.flat_map(&:links)
diff --git a/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb b/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb
index f434ffd12bf..951e0576a58 100644
--- a/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb
+++ b/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb
@@ -6,7 +6,8 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
using RSpec::Parameterized::TableSyntax
where(:report_type, :expected_errors, :valid_data) do
- :sast | ['root is missing required keys: vulnerabilities'] | { 'version' => '10.0.0', 'vulnerabilities' => [] }
+ 'sast' | ['root is missing required keys: vulnerabilities'] | { 'version' => '10.0.0', 'vulnerabilities' => [] }
+ :sast | ['root is missing required keys: vulnerabilities'] | { 'version' => '10.0.0', 'vulnerabilities' => [] }
:secret_detection | ['root is missing required keys: vulnerabilities'] | { 'version' => '10.0.0', 'vulnerabilities' => [] }
end
diff --git a/spec/lib/gitlab/ci/pipeline/chain/build/associations_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/build/associations_spec.rb
index 5fa414f5bd1..32c92724f62 100644
--- a/spec/lib/gitlab/ci/pipeline/chain/build/associations_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/chain/build/associations_spec.rb
@@ -3,10 +3,16 @@
require 'spec_helper'
RSpec.describe Gitlab::Ci::Pipeline::Chain::Build::Associations do
- let(:project) { create(:project, :repository) }
- let(:user) { create(:user, developer_projects: [project]) }
+ let_it_be_with_reload(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user, developer_projects: [project]) }
+
let(:pipeline) { Ci::Pipeline.new }
- let(:step) { described_class.new(pipeline, command) }
+ let(:bridge) { nil }
+
+ let(:variables_attributes) do
+ [{ key: 'first', secret_value: 'world' },
+ { key: 'second', secret_value: 'second_world' }]
+ end
let(:command) do
Gitlab::Ci::Pipeline::Chain::Command.new(
@@ -20,7 +26,26 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Build::Associations do
merge_request: nil,
project: project,
current_user: user,
- bridge: bridge)
+ bridge: bridge,
+ variables_attributes: variables_attributes)
+ end
+
+ let(:step) { described_class.new(pipeline, command) }
+
+ shared_examples 'breaks the chain' do
+ it 'returns true' do
+ step.perform!
+
+ expect(step.break?).to be true
+ end
+ end
+
+ shared_examples 'does not break the chain' do
+ it 'returns false' do
+ step.perform!
+
+ expect(step.break?).to be false
+ end
end
context 'when a bridge is passed in to the pipeline creation' do
@@ -37,26 +62,83 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Build::Associations do
)
end
- it 'never breaks the chain' do
- step.perform!
-
- expect(step.break?).to eq(false)
- end
+ it_behaves_like 'does not break the chain'
end
context 'when a bridge is not passed in to the pipeline creation' do
- let(:bridge) { nil }
-
it 'leaves the source pipeline empty' do
step.perform!
expect(pipeline.source_pipeline).to be_nil
end
- it 'never breaks the chain' do
+ it_behaves_like 'does not break the chain'
+ end
+
+ it 'sets pipeline variables' do
+ step.perform!
+
+ expect(pipeline.variables.map { |var| var.slice(:key, :secret_value) })
+ .to eq variables_attributes.map(&:with_indifferent_access)
+ end
+
+ context 'when project setting restrict_user_defined_variables is enabled' do
+ before do
+ project.update!(restrict_user_defined_variables: true)
+ end
+
+ context 'when user is developer' do
+ it_behaves_like 'breaks the chain'
+
+ it 'returns an error on variables_attributes', :aggregate_failures do
+ step.perform!
+
+ expect(pipeline.errors.full_messages).to eq(['Insufficient permissions to set pipeline variables'])
+ expect(pipeline.variables).to be_empty
+ end
+
+ context 'when variables_attributes is not specified' do
+ let(:variables_attributes) { nil }
+
+ it_behaves_like 'does not break the chain'
+
+ it 'assigns empty variables' do
+ step.perform!
+
+ expect(pipeline.variables).to be_empty
+ end
+ end
+ end
+
+ context 'when user is maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it_behaves_like 'does not break the chain'
+
+ it 'assigns variables_attributes' do
+ step.perform!
+
+ expect(pipeline.variables.map { |var| var.slice(:key, :secret_value) })
+ .to eq variables_attributes.map(&:with_indifferent_access)
+ end
+ end
+ end
+
+ context 'with duplicate pipeline variables' do
+ let(:variables_attributes) do
+ [{ key: 'first', secret_value: 'world' },
+ { key: 'first', secret_value: 'second_world' }]
+ end
+
+ it_behaves_like 'breaks the chain'
+
+ it 'returns an error for variables_attributes' do
step.perform!
- expect(step.break?).to eq(false)
+ expect(pipeline.errors.full_messages).to eq(['Duplicate variable name: first'])
+ expect(pipeline.variables).to be_empty
end
end
end
diff --git a/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb
index 7771289abe6..dca2204f544 100644
--- a/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/chain/build_spec.rb
@@ -8,11 +8,6 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Build do
let(:pipeline) { Ci::Pipeline.new }
- let(:variables_attributes) do
- [{ key: 'first', secret_value: 'world' },
- { key: 'second', secret_value: 'second_world' }]
- end
-
let(:command) do
Gitlab::Ci::Pipeline::Chain::Command.new(
source: :push,
@@ -24,100 +19,26 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Build do
schedule: nil,
merge_request: nil,
project: project,
- current_user: user,
- variables_attributes: variables_attributes)
+ current_user: user)
end
let(:step) { described_class.new(pipeline, command) }
- shared_examples 'builds pipeline' do
- it 'builds a pipeline with the expected attributes' do
- step.perform!
-
- expect(pipeline.sha).not_to be_empty
- expect(pipeline.sha).to eq project.commit.id
- expect(pipeline.ref).to eq 'master'
- expect(pipeline.tag).to be false
- expect(pipeline.user).to eq user
- expect(pipeline.project).to eq project
- end
- end
-
- shared_examples 'breaks the chain' do
- it 'returns true' do
- step.perform!
-
- expect(step.break?).to be true
- end
- end
-
- shared_examples 'does not break the chain' do
- it 'returns false' do
- step.perform!
-
- expect(step.break?).to be false
- end
- end
-
- before do
- stub_ci_pipeline_yaml_file(gitlab_ci_yaml)
- end
-
- it_behaves_like 'does not break the chain'
- it_behaves_like 'builds pipeline'
-
- it 'sets pipeline variables' do
+ it 'does not break the chain' do
step.perform!
- expect(pipeline.variables.map { |var| var.slice(:key, :secret_value) })
- .to eq variables_attributes.map(&:with_indifferent_access)
+ expect(step.break?).to be false
end
- context 'when project setting restrict_user_defined_variables is enabled' do
- before do
- project.update!(restrict_user_defined_variables: true)
- end
-
- context 'when user is developer' do
- it_behaves_like 'breaks the chain'
- it_behaves_like 'builds pipeline'
-
- it 'returns an error on variables_attributes', :aggregate_failures do
- step.perform!
-
- expect(pipeline.errors.full_messages).to eq(['Insufficient permissions to set pipeline variables'])
- expect(pipeline.variables).to be_empty
- end
-
- context 'when variables_attributes is not specified' do
- let(:variables_attributes) { nil }
-
- it_behaves_like 'does not break the chain'
- it_behaves_like 'builds pipeline'
-
- it 'assigns empty variables' do
- step.perform!
-
- expect(pipeline.variables).to be_empty
- end
- end
- end
-
- context 'when user is maintainer' do
- before do
- project.add_maintainer(user)
- end
-
- it_behaves_like 'does not break the chain'
- it_behaves_like 'builds pipeline'
-
- it 'assigns variables_attributes' do
- step.perform!
+ it 'builds a pipeline with the expected attributes' do
+ step.perform!
- expect(pipeline.variables.map { |var| var.slice(:key, :secret_value) })
- .to eq variables_attributes.map(&:with_indifferent_access)
- end
- end
+ expect(pipeline.sha).not_to be_empty
+ expect(pipeline.sha).to eq project.commit.id
+ expect(pipeline.ref).to eq 'master'
+ expect(pipeline.tag).to be false
+ expect(pipeline.user).to eq user
+ expect(pipeline.project).to eq project
end
it 'returns a valid pipeline' do
diff --git a/spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb
index 2727f2603cd..27a5abf988c 100644
--- a/spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb
@@ -44,6 +44,14 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::CancelPendingPipelines do
expect(build_statuses(pipeline)).to contain_exactly('pending')
end
+ it 'cancels the builds with 2 queries to avoid query timeout' do
+ second_query_regex = /WHERE "ci_pipelines"\."id" = \d+ AND \(NOT EXISTS/
+ recorder = ActiveRecord::QueryRecorder.new { perform }
+ second_query = recorder.occurrences.keys.filter { |occ| occ =~ second_query_regex }
+
+ expect(second_query).to be_one
+ end
+
context 'when the previous pipeline has a child pipeline' do
let(:child_pipeline) { create(:ci_pipeline, child_of: prev_pipeline) }
diff --git a/spec/lib/gitlab/ci/pipeline/chain/command_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/command_spec.rb
index c22a0e23794..0d78ce3440a 100644
--- a/spec/lib/gitlab/ci/pipeline/chain/command_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/chain/command_spec.rb
@@ -341,4 +341,40 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Command do
end
end
end
+
+ describe '#observe_step_duration' do
+ context 'when ci_pipeline_creation_step_duration_tracking is enabled' do
+ it 'adds the duration to the step duration histogram' do
+ histogram = double(:histogram)
+ duration = 1.hour
+
+ expect(::Gitlab::Ci::Pipeline::Metrics).to receive(:pipeline_creation_step_duration_histogram)
+ .and_return(histogram)
+ expect(histogram).to receive(:observe)
+ .with({ step: 'Gitlab::Ci::Pipeline::Chain::Build' }, duration.seconds)
+
+ described_class.new.observe_step_duration(
+ Gitlab::Ci::Pipeline::Chain::Build,
+ duration
+ )
+ end
+ end
+
+ context 'when ci_pipeline_creation_step_duration_tracking is disabled' do
+ before do
+ stub_feature_flags(ci_pipeline_creation_step_duration_tracking: false)
+ end
+
+ it 'does nothing' do
+ duration = 1.hour
+
+ expect(::Gitlab::Ci::Pipeline::Metrics).not_to receive(:pipeline_creation_step_duration_histogram)
+
+ described_class.new.observe_step_duration(
+ Gitlab::Ci::Pipeline::Chain::Build,
+ duration
+ )
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb
index 42ec9ab6f5d..e0d656f456e 100644
--- a/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb
@@ -92,6 +92,27 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Config::Content do
expect(pipeline.pipeline_config.content).to eq(config_content_result)
expect(command.config_content).to eq(config_content_result)
end
+
+ context 'when path specifies a refname' do
+ let(:ci_config_path) { 'path/to/.gitlab-ci.yml@another-group/another-repo:refname' }
+ let(:config_content_result) do
+ <<~EOY
+ ---
+ include:
+ - project: another-group/another-repo
+ file: path/to/.gitlab-ci.yml
+ ref: refname
+ EOY
+ end
+
+ it 'builds root config including the path and refname to another repository' do
+ subject.perform!
+
+ expect(pipeline.config_source).to eq 'external_project_source'
+ expect(pipeline.pipeline_config.content).to eq(config_content_result)
+ expect(command.config_content).to eq(config_content_result)
+ end
+ end
end
context 'when config is defined in the default .gitlab-ci.yml' do
diff --git a/spec/lib/gitlab/ci/pipeline/chain/sequence_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/sequence_spec.rb
index 83d47ae6819..e8eb3333b88 100644
--- a/spec/lib/gitlab/ci/pipeline/chain/sequence_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/chain/sequence_spec.rb
@@ -8,8 +8,8 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Sequence do
let(:pipeline) { build_stubbed(:ci_pipeline) }
let(:command) { Gitlab::Ci::Pipeline::Chain::Command.new(project: project) }
- let(:first_step) { spy('first step') }
- let(:second_step) { spy('second step') }
+ let(:first_step) { spy('first step', name: 'FirstStep') }
+ let(:second_step) { spy('second step', name: 'SecondStep') }
let(:sequence) { [first_step, second_step] }
let(:histogram) { spy('prometheus metric') }
@@ -61,6 +61,17 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Sequence do
expect(histogram).to have_received(:observe)
end
+ it 'adds step sequence duration to duration histogram' do
+ expect(command.metrics)
+ .to receive(:pipeline_creation_step_duration_histogram)
+ .twice
+ .and_return(histogram)
+ expect(histogram).to receive(:observe).with({ step: 'FirstStep' }, any_args).ordered
+ expect(histogram).to receive(:observe).with({ step: 'SecondStep' }, any_args).ordered
+
+ subject.build!
+ end
+
it 'records pipeline size by pipeline source in a histogram' do
allow(command.metrics)
.to receive(:pipeline_size_histogram)
diff --git a/spec/lib/gitlab/ci/pipeline/metrics_spec.rb b/spec/lib/gitlab/ci/pipeline/metrics_spec.rb
new file mode 100644
index 00000000000..83b969ff3c4
--- /dev/null
+++ b/spec/lib/gitlab/ci/pipeline/metrics_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Gitlab::Ci::Pipeline::Metrics do
+ describe '.pipeline_creation_step_duration_histogram' do
+ around do |example|
+ described_class.clear_memoization(:pipeline_creation_step_histogram)
+
+ example.run
+
+ described_class.clear_memoization(:pipeline_creation_step_histogram)
+ end
+
+ it 'adds the step to the step duration histogram' do
+ expect(::Gitlab::Metrics).to receive(:histogram)
+ .with(
+ :gitlab_ci_pipeline_creation_step_duration_seconds,
+ 'Duration of each pipeline creation step',
+ { step: nil },
+ [0.01, 0.05, 0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 15.0, 20.0, 50.0, 240.0]
+ )
+
+ described_class.pipeline_creation_step_duration_histogram
+ 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 58938251ca1..0c28515b574 100644
--- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb
@@ -490,12 +490,21 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do
end
context 'when job belongs to a resource group' do
- let(:attributes) { { name: 'rspec', ref: 'master', resource_group_key: 'iOS' } }
+ let(:resource_group) { 'iOS' }
+ let(:attributes) { { name: 'rspec', ref: 'master', resource_group_key: resource_group, environment: 'production' }}
it 'returns a job with resource group' do
expect(subject.resource_group).not_to be_nil
expect(subject.resource_group.key).to eq('iOS')
end
+
+ context 'when resource group has $CI_ENVIRONMENT_NAME in it' do
+ let(:resource_group) { 'test/$CI_ENVIRONMENT_NAME' }
+
+ it 'expands environment name' do
+ expect(subject.resource_group.key).to eq('test/production')
+ end
+ end
end
end
@@ -1140,16 +1149,6 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do
it 'does not have errors' do
expect(subject.errors).to be_empty
end
-
- context 'when ci_same_stage_job_needs FF is disabled' do
- before do
- stub_feature_flags(ci_same_stage_job_needs: false)
- end
-
- it 'has errors' do
- expect(subject.errors).to contain_exactly("'rspec' job needs 'build' job, but 'build' is not in any previous stage")
- end
- end
end
context 'when using 101 needs' do
diff --git a/spec/lib/gitlab/ci/pipeline/seed/pipeline_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/pipeline_spec.rb
index 3424e7d03a3..5d8a9358e10 100644
--- a/spec/lib/gitlab/ci/pipeline/seed/pipeline_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/seed/pipeline_spec.rb
@@ -34,10 +34,6 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Pipeline do
described_class.new(seed_context, stages_attributes)
end
- before do
- stub_feature_flags(ci_same_stage_job_needs: false)
- end
-
describe '#stages' do
it 'returns the stage resources' do
stages = seed.stages
diff --git a/spec/lib/gitlab/ci/reports/security/flag_spec.rb b/spec/lib/gitlab/ci/reports/security/flag_spec.rb
new file mode 100644
index 00000000000..27f83694ac2
--- /dev/null
+++ b/spec/lib/gitlab/ci/reports/security/flag_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Reports::Security::Flag do
+ subject(:security_flag) { described_class.new(type: 'flagged-as-likely-false-positive', origin: 'post analyzer X', description: 'static string to sink') }
+
+ describe '#initialize' do
+ context 'when all params are given' do
+ it 'initializes an instance' do
+ expect { subject }.not_to raise_error
+
+ expect(subject).to have_attributes(
+ type: 'flagged-as-likely-false-positive',
+ origin: 'post analyzer X',
+ description: 'static string to sink'
+ )
+ end
+ end
+
+ describe '#to_hash' do
+ it 'returns expected hash' do
+ expect(security_flag.to_hash).to eq(
+ {
+ flag_type: :false_positive,
+ origin: 'post analyzer X',
+ description: 'static string to sink'
+ }
+ )
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/trace/backoff_spec.rb b/spec/lib/gitlab/ci/trace/backoff_spec.rb
new file mode 100644
index 00000000000..0fb7e81c6c5
--- /dev/null
+++ b/spec/lib/gitlab/ci/trace/backoff_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Trace::Backoff do
+ using RSpec::Parameterized::TableSyntax
+
+ subject(:backoff) { described_class.new(archival_attempts) }
+
+ it 'keeps the MAX_ATTEMPTS limit in sync' do
+ expect(Ci::BuildTraceMetadata::MAX_ATTEMPTS).to eq(5)
+ end
+
+ it 'keeps the Redis TTL limit in sync' do
+ expect(Ci::BuildTraceChunks::RedisBase::CHUNK_REDIS_TTL).to eq(7.days)
+ end
+
+ describe '#value' do
+ where(:archival_attempts, :result) do
+ 1 | 9.6
+ 2 | 19.2
+ 3 | 28.8
+ 4 | 38.4
+ 5 | 48.0
+ end
+
+ with_them do
+ subject { backoff.value }
+
+ it { is_expected.to eq(result.hours) }
+ end
+ end
+
+ describe '#value_with_jitter' do
+ where(:archival_attempts, :min_value, :max_value) do
+ 1 | 9.6 | 13.6
+ 2 | 19.2 | 23.2
+ 3 | 28.8 | 32.8
+ 4 | 38.4 | 42.4
+ 5 | 48.0 | 52.0
+ end
+
+ with_them do
+ subject { backoff.value_with_jitter }
+
+ it { is_expected.to be_in(min_value.hours..max_value.hours) }
+ end
+ end
+
+ it 'all retries are happening under the 7 days limit' do
+ backoff_total = 1.upto(Ci::BuildTraceMetadata::MAX_ATTEMPTS).sum do |attempt|
+ backoff = described_class.new(attempt)
+ expect(backoff).to receive(:rand)
+ .with(described_class::MAX_JITTER_VALUE)
+ .and_return(described_class::MAX_JITTER_VALUE)
+
+ backoff.value_with_jitter
+ end
+
+ expect(backoff_total).to be < Ci::BuildTraceChunks::RedisBase::CHUNK_REDIS_TTL
+ end
+end
diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb
index 69f56871740..1a31b2dad56 100644
--- a/spec/lib/gitlab/ci/trace_spec.rb
+++ b/spec/lib/gitlab/ci/trace_spec.rb
@@ -130,4 +130,18 @@ RSpec.describe Gitlab::Ci::Trace, :clean_gitlab_redis_shared_state, factory_defa
end
end
end
+
+ describe '#can_attempt_archival_now?' do
+ it 'creates the record and returns true' do
+ expect(trace.can_attempt_archival_now?).to be_truthy
+ end
+ end
+
+ describe '#increment_archival_attempts!' do
+ it 'creates the record and increments its value' do
+ expect { trace.increment_archival_attempts! }
+ .to change { build.reload.trace_metadata&.archival_attempts }.from(nil).to(1)
+ .and change { build.reload.trace_metadata&.last_archival_attempt_at }
+ 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
index 01eef673c35..7e4e9602a92 100644
--- a/spec/lib/gitlab/ci/variables/collection/sort_spec.rb
+++ b/spec/lib/gitlab/ci/variables/collection/sort_spec.rb
@@ -5,20 +5,10 @@ require 'rspec-parameterized'
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([]) }
+ 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
+ it 'raises ArgumentError' do
+ expect { subject }.to raise_error(ArgumentError, /Collection object was expected/)
end
end
@@ -182,5 +172,33 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Sort do
expect { subject }.to raise_error(TSort::Cyclic)
end
end
+
+ context 'with overridden variables' do
+ let(:variables) do
+ [
+ { key: 'PROJECT_VAR', value: '$SUBGROUP_VAR' },
+ { key: 'SUBGROUP_VAR', value: '$TOP_LEVEL_GROUP_NAME' },
+ { key: 'SUBGROUP_VAR', value: '$SUB_GROUP_NAME' },
+ { key: 'TOP_LEVEL_GROUP_NAME', value: 'top-level-group' },
+ { key: 'SUB_GROUP_NAME', value: 'vars-in-vars-subgroup' }
+ ]
+ end
+
+ let(:collection) { Gitlab::Ci::Variables::Collection.new(variables) }
+
+ subject do
+ Gitlab::Ci::Variables::Collection::Sort.new(collection).tsort.map { |v| { v[:key] => v.value } }
+ end
+
+ it 'preserves relative order of overridden variables' do
+ is_expected.to eq([
+ { 'TOP_LEVEL_GROUP_NAME' => 'top-level-group' },
+ { 'SUBGROUP_VAR' => '$TOP_LEVEL_GROUP_NAME' },
+ { 'SUB_GROUP_NAME' => 'vars-in-vars-subgroup' },
+ { 'SUBGROUP_VAR' => '$SUB_GROUP_NAME' },
+ { 'PROJECT_VAR' => '$SUBGROUP_VAR' }
+ ])
+ 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 abda27f0d6e..7ba98380986 100644
--- a/spec/lib/gitlab/ci/variables/collection_spec.rb
+++ b/spec/lib/gitlab/ci/variables/collection_spec.rb
@@ -123,17 +123,102 @@ RSpec.describe Gitlab::Ci::Variables::Collection do
end
describe '#[]' do
- variable = { key: 'VAR', value: 'value', public: true, masked: false }
+ subject { Gitlab::Ci::Variables::Collection.new(variables)[var_name] }
- collection = described_class.new([variable])
+ shared_examples 'an array access operator' do
+ context 'for a non-existent variable name' do
+ let(:var_name) { 'UNKNOWN_VAR' }
- it 'returns nil for a non-existent variable name' do
- expect(collection['UNKNOWN_VAR']).to be_nil
+ it 'returns nil' do
+ is_expected.to be_nil
+ end
+ end
+
+ context 'for an existent variable name' do
+ let(:var_name) { 'VAR' }
+
+ it 'returns the last Item' do
+ is_expected.to be_an_instance_of(Gitlab::Ci::Variables::Collection::Item)
+ expect(subject.to_runner_variable).to eq(variables.last)
+ end
+ end
+ end
+
+ context 'with variable key with single entry' do
+ let(:variables) do
+ [
+ { key: 'VAR', value: 'value', public: true, masked: false }
+ ]
+ end
+
+ it_behaves_like 'an array access operator'
+ end
+
+ context 'with variable key with multiple entries' do
+ let(:variables) do
+ [
+ { key: 'VAR', value: 'value', public: true, masked: false },
+ { key: 'VAR', value: 'override value', public: true, masked: false }
+ ]
+ end
+
+ it_behaves_like 'an array access operator'
end
+ end
+
+ describe '#all' do
+ subject { described_class.new(variables).all(var_name) }
- 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)
+ shared_examples 'a method returning all known variables or nil' do
+ context 'for a non-existent variable name' do
+ let(:var_name) { 'UNKNOWN_VAR' }
+
+ it 'returns nil' do
+ is_expected.to be_nil
+ end
+ end
+
+ context 'for an existing variable name' do
+ let(:var_name) { 'VAR' }
+
+ it 'returns all expected Items' do
+ is_expected.to eq(expected_variables.map { |v| Gitlab::Ci::Variables::Collection::Item.fabricate(v) })
+ end
+ end
+ end
+
+ context 'with variable key with single entry' do
+ let(:variables) do
+ [
+ { key: 'VAR', value: 'value', public: true, masked: false }
+ ]
+ end
+
+ it_behaves_like 'a method returning all known variables or nil' do
+ let(:expected_variables) do
+ [
+ { key: 'VAR', value: 'value', public: true, masked: false }
+ ]
+ end
+ end
+ end
+
+ context 'with variable key with multiple entries' do
+ let(:variables) do
+ [
+ { key: 'VAR', value: 'value', public: true, masked: false },
+ { key: 'VAR', value: 'override value', public: true, masked: false }
+ ]
+ end
+
+ it_behaves_like 'a method returning all known variables or nil' do
+ let(:expected_variables) do
+ [
+ { key: 'VAR', value: 'value', public: true, masked: false },
+ { key: 'VAR', value: 'override value', public: true, masked: false }
+ ]
+ 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 49a470f9e01..1591c2e6b60 100644
--- a/spec/lib/gitlab/ci/yaml_processor_spec.rb
+++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb
@@ -590,14 +590,6 @@ module Gitlab
end
it_behaves_like 'has warnings and expected error', /build job: need test is not defined in current or prior stages/
-
- context 'with ci_same_stage_job_needs FF disabled' do
- before do
- stub_feature_flags(ci_same_stage_job_needs: false)
- end
-
- it_behaves_like 'has warnings and expected error', /build job: need test is not defined in prior stages/
- end
end
end
end
@@ -1809,14 +1801,6 @@ module Gitlab
let(:dependencies) { ['deploy'] }
it_behaves_like 'returns errors', 'test1 job: dependency deploy is not defined in current or prior stages'
-
- context 'with ci_same_stage_job_needs FF disabled' do
- before do
- stub_feature_flags(ci_same_stage_job_needs: false)
- end
-
- it_behaves_like 'returns errors', 'test1 job: dependency deploy is not defined in prior stages'
- end
end
context 'when a job depends on another job that references a not-yet defined stage' do
@@ -2053,14 +2037,6 @@ module Gitlab
let(:needs) { ['deploy'] }
it_behaves_like 'returns errors', 'test1 job: need deploy is not defined in current or prior stages'
-
- context 'with ci_same_stage_job_needs FF disabled' do
- before do
- stub_feature_flags(ci_same_stage_job_needs: false)
- end
-
- it_behaves_like 'returns errors', 'test1 job: need deploy is not defined in prior stages'
- end
end
context 'needs and dependencies that are mismatching' do
diff --git a/spec/lib/gitlab/config/loader/yaml_spec.rb b/spec/lib/gitlab/config/loader/yaml_spec.rb
index 731ee12d7f4..be568a8e5f9 100644
--- a/spec/lib/gitlab/config/loader/yaml_spec.rb
+++ b/spec/lib/gitlab/config/loader/yaml_spec.rb
@@ -15,6 +15,24 @@ RSpec.describe Gitlab::Config::Loader::Yaml do
YAML
end
+ context 'when max yaml size and depth are set in ApplicationSetting' do
+ let(:yaml_size) { 2.megabytes }
+ let(:yaml_depth) { 200 }
+
+ before do
+ stub_application_setting(max_yaml_size_bytes: yaml_size, max_yaml_depth: yaml_depth)
+ end
+
+ it 'uses ApplicationSetting values rather than the defaults' do
+ expect(Gitlab::Utils::DeepSize)
+ .to receive(:new)
+ .with(any_args, { max_size: yaml_size, max_depth: yaml_depth })
+ .and_call_original
+
+ loader.load!
+ end
+ end
+
context 'when yaml syntax is correct' do
let(:yml) { 'image: ruby:2.7' }
diff --git a/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb b/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb
index b9e0132badb..8053f5261c0 100644
--- a/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb
@@ -3,7 +3,8 @@
require 'spec_helper'
RSpec.describe Gitlab::CycleAnalytics::StageSummary do
- let(:project) { create(:project, :repository) }
+ let_it_be(:project) { create(:project, :repository) }
+
let(:options) { { from: 1.day.ago } }
let(:args) { { options: options, current_user: user } }
let(:user) { create(:user, :admin) }
@@ -62,6 +63,8 @@ RSpec.describe Gitlab::CycleAnalytics::StageSummary do
end
describe "#commits" do
+ let!(:project) { create(:project, :repository) }
+
subject { stage_summary.second }
context 'when from date is given' do
@@ -132,115 +135,5 @@ RSpec.describe Gitlab::CycleAnalytics::StageSummary do
end
end
- describe "#deploys" do
- subject { stage_summary.third }
-
- context 'when from date is given' do
- before do
- Timecop.freeze(5.days.ago) { create(:deployment, :success, project: project) }
- Timecop.freeze(5.days.from_now) { create(:deployment, :success, project: project) }
- end
-
- it "finds the number of deploys made created after the 'from date'" do
- expect(subject[:value]).to eq('1')
- end
-
- it 'returns the localized title' do
- Gitlab::I18n.with_locale(:ru) do
- expect(subject[:title]).to eq(n_('Deploy', 'Deploys', 1))
- end
- end
- end
-
- it "doesn't find commits from other projects" do
- Timecop.freeze(5.days.from_now) do
- create(:deployment, :success, project: create(:project, :repository))
- end
-
- expect(subject[:value]).to eq('-')
- end
-
- context 'when `to` parameter is given' do
- before do
- Timecop.freeze(5.days.ago) { create(:deployment, :success, project: project) }
- Timecop.freeze(5.days.from_now) { create(:deployment, :success, project: project) }
- end
-
- it "doesn't find any record" do
- options[:to] = Time.now
-
- expect(subject[:value]).to eq('-')
- end
-
- it "finds records created between `from` and `to` range" do
- options[:from] = 10.days.ago
- options[:to] = 10.days.from_now
-
- expect(subject[:value]).to eq('2')
- end
- end
- end
-
- describe '#deployment_frequency' do
- subject { stage_summary.fourth[:value] }
-
- it 'includes the unit: `per day`' do
- expect(stage_summary.fourth[:unit]).to eq _('per day')
- end
-
- before do
- Timecop.freeze(5.days.ago) { create(:deployment, :success, project: project) }
- end
-
- it 'returns 0.0 when there were deploys but the frequency was too low' do
- options[:from] = 30.days.ago
-
- # 1 deployment over 30 days
- # frequency of 0.03, rounded off to 0.0
- expect(subject).to eq('0')
- end
-
- it 'returns `-` when there were no deploys' do
- options[:from] = 4.days.ago
-
- # 0 deployment in the last 4 days
- expect(subject).to eq('-')
- end
-
- context 'when `to` is nil' do
- it 'includes range until now' do
- options[:from] = 6.days.ago
- options[:to] = nil
-
- # 1 deployment over 7 days
- expect(subject).to eq('0.1')
- end
- end
-
- context 'when `to` is given' do
- before do
- Timecop.freeze(5.days.from_now) { create(:deployment, :success, project: project, finished_at: Time.zone.now) }
- end
-
- it 'finds records created between `from` and `to` range' do
- options[:from] = 10.days.ago
- options[:to] = 10.days.from_now
-
- # 2 deployments over 20 days
- expect(subject).to eq('0.1')
- end
-
- context 'when `from` and `to` are within a day' do
- 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
- end
- end
- end
- end
+ it_behaves_like 'deployment metrics examples'
end
diff --git a/spec/lib/gitlab/database/async_indexes/migration_helpers_spec.rb b/spec/lib/gitlab/database/async_indexes/migration_helpers_spec.rb
index ed15951dfb0..eb16a8ccfa5 100644
--- a/spec/lib/gitlab/database/async_indexes/migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/async_indexes/migration_helpers_spec.rb
@@ -150,6 +150,23 @@ RSpec.describe Gitlab::Database::AsyncIndexes::MigrationHelpers do
migration.prepare_async_index(table_name, 'id')
end.not_to change { index_model.where(name: index_name).count }
end
+
+ it 'updates definition if changed' do
+ index = create(:postgres_async_index, table_name: table_name, name: index_name, definition: '...')
+
+ expect do
+ migration.prepare_async_index(table_name, 'id', name: index_name)
+ end.to change { index.reload.definition }
+ end
+
+ it 'does not update definition if not changed' do
+ definition = "CREATE INDEX CONCURRENTLY \"index_#{table_name}_on_id\" ON \"#{table_name}\" (\"id\")"
+ index = create(:postgres_async_index, table_name: table_name, name: index_name, definition: definition)
+
+ expect do
+ migration.prepare_async_index(table_name, 'id', name: index_name)
+ end.not_to change { index.reload.updated_at }
+ end
end
context 'when the async index table does not exist' do
diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb
index 3207e97a639..a1c2634f59c 100644
--- a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb
+++ b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb
@@ -234,6 +234,42 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m
end
end
+ describe '#retry_failed_jobs!' do
+ let(:batched_migration) { create(:batched_background_migration, status: 'failed') }
+
+ subject(:retry_failed_jobs) { batched_migration.retry_failed_jobs! }
+
+ context 'when there are failed migration jobs' do
+ let!(:batched_background_migration_job) { create(:batched_background_migration_job, batched_migration: batched_migration, batch_size: 10, min_value: 6, max_value: 15, status: :failed, attempts: 3) }
+
+ before do
+ allow_next_instance_of(Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchingStrategy) do |batch_class|
+ allow(batch_class).to receive(:next_batch).with(anything, anything, batch_min_value: 6, batch_size: 5).and_return([6, 10])
+ end
+ end
+
+ it 'moves the status of the migration to active' do
+ retry_failed_jobs
+
+ expect(batched_migration.status).to eql 'active'
+ end
+
+ it 'changes the number of attempts to 0' do
+ retry_failed_jobs
+
+ expect(batched_background_migration_job.reload.attempts).to be_zero
+ end
+ end
+
+ context 'when there are no failed migration jobs' do
+ it 'moves the status of the migration to active' do
+ retry_failed_jobs
+
+ expect(batched_migration.status).to eql 'active'
+ end
+ end
+ end
+
describe '#job_class_name=' do
it_behaves_like 'an attr_writer that demodulizes assigned class names', :job_class_name
end
diff --git a/spec/lib/gitlab/database/connection_spec.rb b/spec/lib/gitlab/database/connection_spec.rb
index 5e0e6039afc..7f94d7af4a9 100644
--- a/spec/lib/gitlab/database/connection_spec.rb
+++ b/spec/lib/gitlab/database/connection_spec.rb
@@ -5,29 +5,14 @@ require 'spec_helper'
RSpec.describe Gitlab::Database::Connection do
let(:connection) { described_class.new }
- describe '#default_pool_size' do
- before do
- allow(Gitlab::Runtime).to receive(:max_threads).and_return(7)
- end
-
- it 'returns the max thread size plus a fixed headroom of 10' do
- expect(connection.default_pool_size).to eq(17)
- end
-
- it 'returns the max thread size plus a DB_POOL_HEADROOM if this env var is present' do
- stub_env('DB_POOL_HEADROOM', '7')
-
- expect(connection.default_pool_size).to eq(14)
- end
- end
-
describe '#config' do
it 'returns a HashWithIndifferentAccess' do
expect(connection.config).to be_an_instance_of(HashWithIndifferentAccess)
end
it 'returns a default pool size' do
- expect(connection.config).to include(pool: connection.default_pool_size)
+ expect(connection.config)
+ .to include(pool: Gitlab::Database.default_pool_size)
end
it 'does not cache its results' do
@@ -43,7 +28,7 @@ RSpec.describe Gitlab::Database::Connection do
it 'returns the default pool size' do
expect(connection).to receive(:config).and_return({ pool: nil })
- expect(connection.pool_size).to eq(connection.default_pool_size)
+ expect(connection.pool_size).to eq(Gitlab::Database.default_pool_size)
end
end
@@ -129,7 +114,7 @@ RSpec.describe Gitlab::Database::Connection do
describe '#db_config_with_default_pool_size' do
it 'returns db_config with our default pool size' do
- allow(connection).to receive(:default_pool_size).and_return(9)
+ allow(Gitlab::Database).to receive(:default_pool_size).and_return(9)
expect(connection.db_config_with_default_pool_size.pool).to eq(9)
end
@@ -143,7 +128,7 @@ RSpec.describe Gitlab::Database::Connection do
describe '#disable_prepared_statements' do
around do |example|
- original_config = ::Gitlab::Database.main.config
+ original_config = connection.scope.connection.pool.db_config
example.run
@@ -162,6 +147,12 @@ RSpec.describe Gitlab::Database::Connection do
expect(connection.scope.connection.prepared_statements).to eq(false)
end
+ it 'retains the connection name' do
+ connection.disable_prepared_statements
+
+ expect(connection.scope.connection_db_config.name).to eq('main')
+ end
+
context 'with dynamic connection pool size' do
before do
connection.scope.establish_connection(connection.config.merge(pool: 7))
@@ -393,34 +384,28 @@ RSpec.describe Gitlab::Database::Connection do
end
describe '#cached_column_exists?' do
- it 'only retrieves data once' do
- expect(connection.scope.connection)
- .to receive(:columns)
- .once.and_call_original
-
- 2.times do
- expect(connection.cached_column_exists?(:projects, :id)).to be_truthy
- expect(connection.cached_column_exists?(:projects, :bogus_column)).to be_falsey
+ it 'only retrieves the data from the schema cache' do
+ queries = ActiveRecord::QueryRecorder.new do
+ 2.times do
+ expect(connection.cached_column_exists?(:projects, :id)).to be_truthy
+ expect(connection.cached_column_exists?(:projects, :bogus_column)).to be_falsey
+ end
end
+
+ expect(queries.count).to eq(0)
end
end
describe '#cached_table_exists?' do
- it 'only retrieves data once per table' do
- expect(connection.scope.connection)
- .to receive(:data_source_exists?)
- .with(:projects)
- .once.and_call_original
-
- expect(connection.scope.connection)
- .to receive(:data_source_exists?)
- .with(:bogus_table_name)
- .once.and_call_original
-
- 2.times do
- expect(connection.cached_table_exists?(:projects)).to be_truthy
- expect(connection.cached_table_exists?(:bogus_table_name)).to be_falsey
+ it 'only retrieves the data from the schema cache' do
+ queries = ActiveRecord::QueryRecorder.new do
+ 2.times do
+ expect(connection.cached_table_exists?(:projects)).to be_truthy
+ expect(connection.cached_table_exists?(:bogus_table_name)).to be_falsey
+ end
end
+
+ expect(queries.count).to eq(0)
end
it 'returns false when database does not exist' do
@@ -433,16 +418,14 @@ RSpec.describe Gitlab::Database::Connection do
end
describe '#exists?' do
- it 'returns true if `ActiveRecord::Base.connection` succeeds' do
- expect(connection.scope).to receive(:connection)
-
+ it 'returns true if the database exists' do
expect(connection.exists?).to be(true)
end
- it 'returns false if `ActiveRecord::Base.connection` fails' do
- expect(connection.scope).to receive(:connection) do
- raise ActiveRecord::NoDatabaseError, 'broken'
- end
+ it "returns false if the database doesn't exist" do
+ expect(connection.scope.connection.schema_cache)
+ .to receive(:database_version)
+ .and_raise(ActiveRecord::NoDatabaseError)
expect(connection.exists?).to be(false)
end
diff --git a/spec/lib/gitlab/database/load_balancing/action_cable_callbacks_spec.rb b/spec/lib/gitlab/database/load_balancing/action_cable_callbacks_spec.rb
new file mode 100644
index 00000000000..ebbbafb855f
--- /dev/null
+++ b/spec/lib/gitlab/database/load_balancing/action_cable_callbacks_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::LoadBalancing::ActionCableCallbacks, :request_store do
+ describe '.wrapper' do
+ it 'uses primary and then releases the connection and clears the session' do
+ expect(Gitlab::Database::LoadBalancing).to receive_message_chain(:proxy, :load_balancer, :release_host)
+ expect(Gitlab::Database::LoadBalancing::Session).to receive(:clear_session)
+
+ described_class.wrapper.call(
+ nil,
+ lambda do
+ expect(Gitlab::Database::LoadBalancing::Session.current.use_primary?).to eq(true)
+ end
+ )
+ end
+
+ context 'with an exception' do
+ it 'releases the connection and clears the session' do
+ expect(Gitlab::Database::LoadBalancing).to receive_message_chain(:proxy, :load_balancer, :release_host)
+ expect(Gitlab::Database::LoadBalancing::Session).to receive(:clear_session)
+
+ expect do
+ described_class.wrapper.call(nil, lambda { raise 'test_exception' })
+ end.to raise_error('test_exception')
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/load_balancing/configuration_spec.rb b/spec/lib/gitlab/database/load_balancing/configuration_spec.rb
new file mode 100644
index 00000000000..6621e6276a5
--- /dev/null
+++ b/spec/lib/gitlab/database/load_balancing/configuration_spec.rb
@@ -0,0 +1,175 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::LoadBalancing::Configuration do
+ let(:model) do
+ config = ActiveRecord::DatabaseConfigurations::HashConfig
+ .new('main', 'test', configuration_hash)
+
+ double(:model, connection_db_config: config)
+ end
+
+ describe '.for_model' do
+ context 'when load balancing is not configured' do
+ let(:configuration_hash) { {} }
+
+ it 'uses the default settings' do
+ config = described_class.for_model(model)
+
+ expect(config.hosts).to eq([])
+ expect(config.max_replication_difference).to eq(8.megabytes)
+ expect(config.max_replication_lag_time).to eq(60.0)
+ expect(config.replica_check_interval).to eq(60.0)
+ expect(config.service_discovery).to eq(
+ nameserver: 'localhost',
+ port: 8600,
+ record: nil,
+ record_type: 'A',
+ interval: 60,
+ disconnect_timeout: 120,
+ use_tcp: false
+ )
+ expect(config.pool_size).to eq(Gitlab::Database.default_pool_size)
+ end
+ end
+
+ context 'when load balancing is configured' do
+ let(:configuration_hash) do
+ {
+ pool: 4,
+ load_balancing: {
+ max_replication_difference: 1,
+ max_replication_lag_time: 2,
+ replica_check_interval: 3,
+ hosts: %w[foo bar],
+ discover: {
+ 'record' => 'foo.example.com'
+ }
+ }
+ }
+ end
+
+ it 'uses the custom configuration settings' do
+ config = described_class.for_model(model)
+
+ expect(config.hosts).to eq(%w[foo bar])
+ expect(config.max_replication_difference).to eq(1)
+ expect(config.max_replication_lag_time).to eq(2.0)
+ expect(config.replica_check_interval).to eq(3.0)
+ expect(config.service_discovery).to eq(
+ nameserver: 'localhost',
+ port: 8600,
+ record: 'foo.example.com',
+ record_type: 'A',
+ interval: 60,
+ disconnect_timeout: 120,
+ use_tcp: false
+ )
+ expect(config.pool_size).to eq(4)
+ end
+ end
+
+ context 'when the load balancing configuration uses strings as the keys' do
+ let(:configuration_hash) do
+ {
+ pool: 4,
+ load_balancing: {
+ 'max_replication_difference' => 1,
+ 'max_replication_lag_time' => 2,
+ 'replica_check_interval' => 3,
+ 'hosts' => %w[foo bar],
+ 'discover' => {
+ 'record' => 'foo.example.com'
+ }
+ }
+ }
+ end
+
+ it 'uses the custom configuration settings' do
+ config = described_class.for_model(model)
+
+ expect(config.hosts).to eq(%w[foo bar])
+ expect(config.max_replication_difference).to eq(1)
+ expect(config.max_replication_lag_time).to eq(2.0)
+ expect(config.replica_check_interval).to eq(3.0)
+ expect(config.service_discovery).to eq(
+ nameserver: 'localhost',
+ port: 8600,
+ record: 'foo.example.com',
+ record_type: 'A',
+ interval: 60,
+ disconnect_timeout: 120,
+ use_tcp: false
+ )
+ expect(config.pool_size).to eq(4)
+ end
+ end
+ end
+
+ describe '#load_balancing_enabled?' do
+ it 'returns true when hosts are configured' do
+ config = described_class.new(ActiveRecord::Base, %w[foo bar])
+
+ expect(config.load_balancing_enabled?).to eq(true)
+ end
+
+ it 'returns true when a service discovery record is configured' do
+ config = described_class.new(ActiveRecord::Base)
+ config.service_discovery[:record] = 'foo'
+
+ expect(config.load_balancing_enabled?).to eq(true)
+ end
+
+ it 'returns false when no hosts are configured and service discovery is disabled' do
+ config = described_class.new(ActiveRecord::Base)
+
+ expect(config.load_balancing_enabled?).to eq(false)
+ end
+ end
+
+ describe '#service_discovery_enabled?' do
+ it 'returns true when a record is configured' do
+ config = described_class.new(ActiveRecord::Base)
+ config.service_discovery[:record] = 'foo'
+
+ expect(config.service_discovery_enabled?).to eq(true)
+ end
+
+ it 'returns false when no record is configured' do
+ config = described_class.new(ActiveRecord::Base)
+
+ expect(config.service_discovery_enabled?).to eq(false)
+ end
+ end
+
+ describe '#pool_size' do
+ context 'when a custom pool size is used' do
+ let(:configuration_hash) { { pool: 4 } }
+
+ it 'always reads the value from the model configuration' do
+ config = described_class.new(model)
+
+ expect(config.pool_size).to eq(4)
+
+ # We can't modify `configuration_hash` as it's only used to populate the
+ # internal hash used by ActiveRecord; instead of it being used as-is.
+ allow(model.connection_db_config)
+ .to receive(:configuration_hash)
+ .and_return({ pool: 42 })
+
+ expect(config.pool_size).to eq(42)
+ end
+ end
+
+ context 'when the pool size is nil' do
+ let(:configuration_hash) { {} }
+
+ it 'returns the default pool size' do
+ config = described_class.new(model)
+
+ expect(config.pool_size).to eq(Gitlab::Database.default_pool_size)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/load_balancing/connection_proxy_spec.rb b/spec/lib/gitlab/database/load_balancing/connection_proxy_spec.rb
index 0ca99ec9acf..ba2f9485066 100644
--- a/spec/lib/gitlab/database/load_balancing/connection_proxy_spec.rb
+++ b/spec/lib/gitlab/database/load_balancing/connection_proxy_spec.rb
@@ -3,7 +3,12 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do
- let(:proxy) { described_class.new }
+ let(:proxy) do
+ config = Gitlab::Database::LoadBalancing::Configuration
+ .new(ActiveRecord::Base)
+
+ described_class.new(Gitlab::Database::LoadBalancing::LoadBalancer.new(config))
+ end
describe '#select' do
it 'performs a read' do
@@ -35,9 +40,15 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do
describe 'using a SELECT FOR UPDATE query' do
it 'runs the query on the primary and sticks to it' do
arel = double(:arel, locked: true)
+ session = Gitlab::Database::LoadBalancing::Session.new
+
+ allow(Gitlab::Database::LoadBalancing::Session).to receive(:current)
+ .and_return(session)
+
+ expect(session).to receive(:write!)
expect(proxy).to receive(:write_using_load_balancer)
- .with(:select_all, arel, 'foo', [], sticky: true)
+ .with(:select_all, arel, 'foo', [])
proxy.select_all(arel, 'foo')
end
@@ -58,8 +69,13 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do
Gitlab::Database::LoadBalancing::ConnectionProxy::STICKY_WRITES.each do |name|
describe "#{name}" do
it 'runs the query on the primary and sticks to it' do
- expect(proxy).to receive(:write_using_load_balancer)
- .with(name, 'foo', sticky: true)
+ session = Gitlab::Database::LoadBalancing::Session.new
+
+ allow(Gitlab::Database::LoadBalancing::Session).to receive(:current)
+ .and_return(session)
+
+ expect(session).to receive(:write!)
+ expect(proxy).to receive(:write_using_load_balancer).with(name, 'foo')
proxy.send(name, 'foo')
end
@@ -108,7 +124,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do
# We have an extra test for #transaction here to make sure that nested queries
# are also sent to a primary.
describe '#transaction' do
- let(:session) { double(:session) }
+ let(:session) { Gitlab::Database::LoadBalancing::Session.new }
before do
allow(Gitlab::Database::LoadBalancing::Session).to receive(:current)
@@ -192,7 +208,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do
proxy.foo('foo')
end
- it 'properly forwards trailing hash arguments' do
+ it 'properly forwards keyword arguments' do
allow(proxy.load_balancer).to receive(:read_write)
expect(proxy).to receive(:write_using_load_balancer).and_call_original
@@ -217,7 +233,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do
proxy.foo('foo')
end
- it 'properly forwards trailing hash arguments' do
+ it 'properly forwards keyword arguments' do
allow(proxy.load_balancer).to receive(:read)
expect(proxy).to receive(:read_using_load_balancer).and_call_original
@@ -297,20 +313,12 @@ RSpec.describe Gitlab::Database::LoadBalancing::ConnectionProxy do
.and_return(session)
end
- it 'uses but does not stick to the primary when sticking is disabled' do
+ it 'uses but does not stick to the primary' do
expect(proxy.load_balancer).to receive(:read_write).and_yield(connection)
expect(connection).to receive(:foo).with('foo')
expect(session).not_to receive(:write!)
proxy.write_using_load_balancer(:foo, 'foo')
end
-
- it 'sticks to the primary when sticking is enabled' do
- expect(proxy.load_balancer).to receive(:read_write).and_yield(connection)
- expect(connection).to receive(:foo).with('foo')
- expect(session).to receive(:write!)
-
- proxy.write_using_load_balancer(:foo, 'foo', sticky: true)
- end
end
end
diff --git a/spec/lib/gitlab/database/load_balancing/host_list_spec.rb b/spec/lib/gitlab/database/load_balancing/host_list_spec.rb
index ad4ca18d5e6..9bb8116c434 100644
--- a/spec/lib/gitlab/database/load_balancing/host_list_spec.rb
+++ b/spec/lib/gitlab/database/load_balancing/host_list_spec.rb
@@ -4,7 +4,12 @@ require 'spec_helper'
RSpec.describe Gitlab::Database::LoadBalancing::HostList do
let(:db_host) { ActiveRecord::Base.connection_pool.db_config.host }
- let(:load_balancer) { double(:load_balancer) }
+ let(:load_balancer) do
+ Gitlab::Database::LoadBalancing::LoadBalancer.new(
+ Gitlab::Database::LoadBalancing::Configuration.new(ActiveRecord::Base)
+ )
+ end
+
let(:host_count) { 2 }
let(:hosts) { Array.new(host_count) { Gitlab::Database::LoadBalancing::Host.new(db_host, load_balancer, port: 5432) } }
let(:host_list) { described_class.new(hosts) }
diff --git a/spec/lib/gitlab/database/load_balancing/host_spec.rb b/spec/lib/gitlab/database/load_balancing/host_spec.rb
index f42ac8be1bb..e2011692228 100644
--- a/spec/lib/gitlab/database/load_balancing/host_spec.rb
+++ b/spec/lib/gitlab/database/load_balancing/host_spec.rb
@@ -3,7 +3,10 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::LoadBalancing::Host do
- let(:load_balancer) { Gitlab::Database::LoadBalancing::LoadBalancer.new }
+ let(:load_balancer) do
+ Gitlab::Database::LoadBalancing::LoadBalancer
+ .new(Gitlab::Database::LoadBalancing::Configuration.new(ActiveRecord::Base))
+ end
let(:host) do
Gitlab::Database::LoadBalancing::Host.new('localhost', load_balancer)
@@ -274,7 +277,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::Host do
end
it 'returns false when the data is not recent enough' do
- diff = Gitlab::Database::LoadBalancing.max_replication_difference * 2
+ diff = load_balancer.configuration.max_replication_difference * 2
expect(host)
.to receive(:query_and_release)
diff --git a/spec/lib/gitlab/database/load_balancing/load_balancer_spec.rb b/spec/lib/gitlab/database/load_balancing/load_balancer_spec.rb
index c647f5a8f5d..86fae14b961 100644
--- a/spec/lib/gitlab/database/load_balancing/load_balancer_spec.rb
+++ b/spec/lib/gitlab/database/load_balancing/load_balancer_spec.rb
@@ -5,7 +5,12 @@ require 'spec_helper'
RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do
let(:conflict_error) { Class.new(RuntimeError) }
let(:db_host) { ActiveRecord::Base.connection_pool.db_config.host }
- let(:lb) { described_class.new([db_host, db_host]) }
+ let(:config) do
+ Gitlab::Database::LoadBalancing::Configuration
+ .new(ActiveRecord::Base, [db_host, db_host])
+ end
+
+ let(:lb) { described_class.new(config) }
let(:request_cache) { lb.send(:request_cache) }
before do
@@ -41,6 +46,19 @@ RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do
top_error
end
+ describe '#initialize' do
+ it 'ignores the hosts when the primary_only option is enabled' do
+ config = Gitlab::Database::LoadBalancing::Configuration
+ .new(ActiveRecord::Base, [db_host])
+ lb = described_class.new(config, primary_only: true)
+ hosts = lb.host_list.hosts
+
+ expect(hosts.length).to eq(1)
+ expect(hosts.first)
+ .to be_instance_of(Gitlab::Database::LoadBalancing::PrimaryHost)
+ end
+ end
+
describe '#read' do
it 'yields a connection for a read' do
connection = double(:connection)
@@ -121,6 +139,19 @@ RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do
expect { |b| lb.read(&b) }
.to yield_with_args(ActiveRecord::Base.retrieve_connection)
end
+
+ it 'uses the primary when the primary_only option is enabled' do
+ config = Gitlab::Database::LoadBalancing::Configuration
+ .new(ActiveRecord::Base)
+ lb = described_class.new(config, primary_only: true)
+
+ # When no hosts are configured, we don't want to produce any warnings, as
+ # they aren't useful/too noisy.
+ expect(Gitlab::Database::LoadBalancing::Logger).not_to receive(:warn)
+
+ expect { |b| lb.read(&b) }
+ .to yield_with_args(ActiveRecord::Base.retrieve_connection)
+ end
end
describe '#read_write' do
@@ -152,8 +183,11 @@ RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do
end
it 'does not create conflicts with other load balancers when caching hosts' do
- lb1 = described_class.new([db_host, db_host], ActiveRecord::Base)
- lb2 = described_class.new([db_host, db_host], Ci::CiDatabaseRecord)
+ ci_config = Gitlab::Database::LoadBalancing::Configuration
+ .new(Ci::CiDatabaseRecord, [db_host, db_host])
+
+ lb1 = described_class.new(config)
+ lb2 = described_class.new(ci_config)
host1 = lb1.host
host2 = lb2.host
@@ -283,6 +317,12 @@ RSpec.describe Gitlab::Database::LoadBalancing::LoadBalancer, :request_store do
expect(lb.connection_error?(error)).to eq(false)
end
+
+ it 'returns false for ActiveRecord errors without a cause' do
+ error = ActiveRecord::RecordNotUnique.new
+
+ expect(lb.connection_error?(error)).to eq(false)
+ end
end
describe '#serialization_failure?' do
diff --git a/spec/lib/gitlab/database/load_balancing/primary_host_spec.rb b/spec/lib/gitlab/database/load_balancing/primary_host_spec.rb
new file mode 100644
index 00000000000..a0e63a7ee4e
--- /dev/null
+++ b/spec/lib/gitlab/database/load_balancing/primary_host_spec.rb
@@ -0,0 +1,126 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::LoadBalancing::PrimaryHost do
+ let(:load_balancer) do
+ Gitlab::Database::LoadBalancing::LoadBalancer.new(
+ Gitlab::Database::LoadBalancing::Configuration.new(ActiveRecord::Base)
+ )
+ end
+
+ let(:host) { Gitlab::Database::LoadBalancing::PrimaryHost.new(load_balancer) }
+
+ describe '#connection' do
+ it 'returns a connection from the pool' do
+ expect(load_balancer.pool).to receive(:connection)
+
+ host.connection
+ end
+ end
+
+ describe '#release_connection' do
+ it 'does nothing' do
+ expect(host.release_connection).to be_nil
+ end
+ end
+
+ describe '#enable_query_cache!' do
+ it 'does nothing' do
+ expect(host.enable_query_cache!).to be_nil
+ end
+ end
+
+ describe '#disable_query_cache!' do
+ it 'does nothing' do
+ expect(host.disable_query_cache!).to be_nil
+ end
+ end
+
+ describe '#query_cache_enabled' do
+ it 'delegates to the primary connection pool' do
+ expect(host.query_cache_enabled)
+ .to eq(load_balancer.pool.query_cache_enabled)
+ end
+ end
+
+ describe '#disconnect!' do
+ it 'does nothing' do
+ expect(host.disconnect!).to be_nil
+ end
+ end
+
+ describe '#offline!' do
+ it 'does nothing' do
+ expect(host.offline!).to be_nil
+ end
+ end
+
+ describe '#online?' do
+ it 'returns true' do
+ expect(host.online?).to eq(true)
+ end
+ end
+
+ describe '#primary_write_location' do
+ it 'returns the write location of the primary' do
+ expect(host.primary_write_location).to be_an_instance_of(String)
+ expect(host.primary_write_location).not_to be_empty
+ end
+ end
+
+ describe '#caught_up?' do
+ it 'returns true' do
+ expect(host.caught_up?('foo')).to eq(true)
+ end
+ end
+
+ describe '#database_replica_location' do
+ let(:connection) { double(:connection) }
+
+ it 'returns the write ahead location of the replica', :aggregate_failures do
+ expect(host)
+ .to receive(:query_and_release)
+ .and_return({ 'location' => '0/D525E3A8' })
+
+ expect(host.database_replica_location).to be_an_instance_of(String)
+ end
+
+ it 'returns nil when the database query returned no rows' do
+ expect(host).to receive(:query_and_release).and_return({})
+
+ expect(host.database_replica_location).to be_nil
+ end
+
+ it 'returns nil when the database connection fails' do
+ allow(host).to receive(:connection).and_raise(PG::Error)
+
+ expect(host.database_replica_location).to be_nil
+ end
+ end
+
+ describe '#query_and_release' do
+ it 'executes a SQL query' do
+ results = host.query_and_release('SELECT 10 AS number')
+
+ expect(results).to be_an_instance_of(Hash)
+ expect(results['number'].to_i).to eq(10)
+ end
+
+ it 'releases the connection after running the query' do
+ expect(host)
+ .to receive(:release_connection)
+ .once
+
+ host.query_and_release('SELECT 10 AS number')
+ end
+
+ it 'returns an empty Hash in the event of an error' do
+ expect(host.connection)
+ .to receive(:select_all)
+ .and_raise(RuntimeError, 'kittens')
+
+ expect(host.query_and_release('SELECT 10 AS number')).to eq({})
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/load_balancing/service_discovery_spec.rb b/spec/lib/gitlab/database/load_balancing/service_discovery_spec.rb
index a27341a3324..e9bc465b1c7 100644
--- a/spec/lib/gitlab/database/load_balancing/service_discovery_spec.rb
+++ b/spec/lib/gitlab/database/load_balancing/service_discovery_spec.rb
@@ -3,13 +3,18 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery do
- let(:load_balancer) { Gitlab::Database::LoadBalancing::LoadBalancer.new([]) }
+ let(:load_balancer) do
+ Gitlab::Database::LoadBalancing::LoadBalancer.new(
+ Gitlab::Database::LoadBalancing::Configuration.new(ActiveRecord::Base)
+ )
+ end
+
let(:service) do
described_class.new(
+ load_balancer,
nameserver: 'localhost',
port: 8600,
- record: 'foo',
- load_balancer: load_balancer
+ record: 'foo'
)
end
@@ -26,11 +31,11 @@ RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery do
describe ':record_type' do
subject do
described_class.new(
+ load_balancer,
nameserver: 'localhost',
port: 8600,
record: 'foo',
- record_type: record_type,
- load_balancer: load_balancer
+ record_type: record_type
)
end
@@ -69,18 +74,69 @@ RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery do
end
describe '#perform_service_discovery' do
- it 'reports exceptions to Sentry' do
- error = StandardError.new
+ context 'without any failures' do
+ it 'runs once' do
+ expect(service)
+ .to receive(:refresh_if_necessary).once
+
+ expect(service).not_to receive(:sleep)
+
+ expect(Gitlab::ErrorTracking).not_to receive(:track_exception)
+
+ service.perform_service_discovery
+ end
+ end
+ context 'with failures' do
+ before do
+ allow(Gitlab::ErrorTracking).to receive(:track_exception)
+ allow(service).to receive(:sleep)
+ end
+
+ let(:valid_retry_sleep_duration) { satisfy { |val| described_class::RETRY_DELAY_RANGE.include?(val) } }
+
+ it 'retries service discovery when under the retry limit' do
+ error = StandardError.new
+
+ expect(service)
+ .to receive(:refresh_if_necessary)
+ .and_raise(error).exactly(described_class::MAX_DISCOVERY_RETRIES - 1).times.ordered
+
+ expect(service)
+ .to receive(:sleep).with(valid_retry_sleep_duration)
+ .exactly(described_class::MAX_DISCOVERY_RETRIES - 1).times
+
+ expect(service).to receive(:refresh_if_necessary).and_return(45).ordered
+
+ expect(service.perform_service_discovery).to eq(45)
+ end
+
+ it 'does not retry service discovery after exceeding the limit' do
+ error = StandardError.new
+
+ expect(service)
+ .to receive(:refresh_if_necessary)
+ .and_raise(error).exactly(described_class::MAX_DISCOVERY_RETRIES).times
+
+ expect(service)
+ .to receive(:sleep).with(valid_retry_sleep_duration)
+ .exactly(described_class::MAX_DISCOVERY_RETRIES).times
+
+ service.perform_service_discovery
+ end
- expect(service)
- .to receive(:refresh_if_necessary)
- .and_raise(error)
+ it 'reports exceptions to Sentry' do
+ error = StandardError.new
+
+ expect(service)
+ .to receive(:refresh_if_necessary)
+ .and_raise(error).exactly(described_class::MAX_DISCOVERY_RETRIES).times
- expect(Gitlab::ErrorTracking)
- .to receive(:track_exception)
- .with(error)
+ expect(Gitlab::ErrorTracking)
+ .to receive(:track_exception)
+ .with(error).exactly(described_class::MAX_DISCOVERY_RETRIES).times
- service.perform_service_discovery
+ service.perform_service_discovery
+ end
end
end
@@ -133,7 +189,10 @@ RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery do
let(:address_bar) { described_class::Address.new('bar') }
let(:load_balancer) do
- Gitlab::Database::LoadBalancing::LoadBalancer.new([address_foo])
+ Gitlab::Database::LoadBalancing::LoadBalancer.new(
+ Gitlab::Database::LoadBalancing::Configuration
+ .new(ActiveRecord::Base, [address_foo])
+ )
end
before do
@@ -166,11 +225,11 @@ RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery do
describe '#addresses_from_dns' do
let(:service) do
described_class.new(
+ load_balancer,
nameserver: 'localhost',
port: 8600,
record: 'foo',
- record_type: record_type,
- load_balancer: load_balancer
+ record_type: record_type
)
end
@@ -224,6 +283,16 @@ RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery do
expect(service.addresses_from_dns).to eq([90, addresses])
end
end
+
+ context 'when the resolver returns an empty response' do
+ let(:packet) { double(:packet, answer: []) }
+
+ let(:record_type) { 'A' }
+
+ it 'raises EmptyDnsResponse' do
+ expect { service.addresses_from_dns }.to raise_error(Gitlab::Database::LoadBalancing::ServiceDiscovery::EmptyDnsResponse)
+ end
+ end
end
describe '#new_wait_time_for' do
@@ -246,7 +315,10 @@ RSpec.describe Gitlab::Database::LoadBalancing::ServiceDiscovery do
describe '#addresses_from_load_balancer' do
let(:load_balancer) do
- Gitlab::Database::LoadBalancing::LoadBalancer.new(%w[b a])
+ Gitlab::Database::LoadBalancing::LoadBalancer.new(
+ Gitlab::Database::LoadBalancing::Configuration
+ .new(ActiveRecord::Base, %w[b a])
+ )
end
it 'returns the ordered host names of the load balancer' do
diff --git a/spec/lib/gitlab/database/load_balancing/sidekiq_client_middleware_spec.rb b/spec/lib/gitlab/database/load_balancing/sidekiq_client_middleware_spec.rb
index 54050a87af0..f683ade978a 100644
--- a/spec/lib/gitlab/database/load_balancing/sidekiq_client_middleware_spec.rb
+++ b/spec/lib/gitlab/database/load_balancing/sidekiq_client_middleware_spec.rb
@@ -58,8 +58,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqClientMiddleware do
it 'does not pass database locations', :aggregate_failures do
run_middleware
- expect(job['database_replica_location']).to be_nil
- expect(job['database_write_location']).to be_nil
+ expect(job['wal_locations']).to be_nil
end
include_examples 'job data consistency'
@@ -86,11 +85,13 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqClientMiddleware do
end
it 'passes database_replica_location' do
+ expected_location = { Gitlab::Database::MAIN_DATABASE_NAME.to_sym => location }
+
expect(load_balancer).to receive_message_chain(:host, "database_replica_location").and_return(location)
run_middleware
- expect(job['database_replica_location']).to eq(location)
+ expect(job['wal_locations']).to eq(expected_location)
end
include_examples 'job data consistency'
@@ -102,40 +103,56 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqClientMiddleware do
end
it 'passes primary write location', :aggregate_failures do
+ expected_location = { Gitlab::Database::MAIN_DATABASE_NAME.to_sym => location }
+
expect(load_balancer).to receive(:primary_write_location).and_return(location)
run_middleware
- expect(job['database_write_location']).to eq(location)
+ expect(job['wal_locations']).to eq(expected_location)
end
include_examples 'job data consistency'
end
end
- shared_examples_for 'database location was already provided' do |provided_database_location, other_location|
- shared_examples_for 'does not set database location again' do |use_primary|
- before do
- allow(Gitlab::Database::LoadBalancing::Session.current).to receive(:use_primary?).and_return(use_primary)
- end
+ context 'when worker cannot be constantized' do
+ let(:worker_class) { 'ActionMailer::MailDeliveryJob' }
+ let(:expected_consistency) { :always }
- it 'does not set database locations again' do
- run_middleware
+ include_examples 'does not pass database locations'
+ end
- expect(job[provided_database_location]).to eq(old_location)
- expect(job[other_location]).to be_nil
- end
- end
+ context 'when worker class does not include ApplicationWorker' do
+ let(:worker_class) { ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper }
+ let(:expected_consistency) { :always }
+
+ include_examples 'does not pass database locations'
+ end
+ context 'database wal location was already provided' do
let(:old_location) { '0/D525E3A8' }
let(:new_location) { 'AB/12345' }
- let(:job) { { "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", provided_database_location => old_location } }
+ let(:wal_locations) { { Gitlab::Database::MAIN_DATABASE_NAME.to_sym => old_location } }
+ let(:job) { { "job_id" => "a180b47c-3fd6-41b8-81e9-34da61c3400e", 'wal_locations' => wal_locations } }
before do
allow(load_balancer).to receive(:primary_write_location).and_return(new_location)
allow(load_balancer).to receive(:database_replica_location).and_return(new_location)
end
+ shared_examples_for 'does not set database location again' do |use_primary|
+ before do
+ allow(Gitlab::Database::LoadBalancing::Session.current).to receive(:use_primary?).and_return(use_primary)
+ end
+
+ it 'does not set database locations again' do
+ run_middleware
+
+ expect(job['wal_locations']).to eq(wal_locations)
+ end
+ end
+
context "when write was performed" do
include_examples 'does not set database location again', true
end
@@ -145,28 +162,6 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqClientMiddleware do
end
end
- context 'when worker cannot be constantized' do
- let(:worker_class) { 'ActionMailer::MailDeliveryJob' }
- let(:expected_consistency) { :always }
-
- include_examples 'does not pass database locations'
- end
-
- context 'when worker class does not include ApplicationWorker' do
- let(:worker_class) { ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper }
- let(:expected_consistency) { :always }
-
- include_examples 'does not pass database locations'
- end
-
- context 'database write location was already provided' do
- include_examples 'database location was already provided', 'database_write_location', 'database_replica_location'
- end
-
- context 'database replica location was already provided' do
- include_examples 'database location was already provided', 'database_replica_location', 'database_write_location'
- end
-
context 'when worker data consistency is :always' do
include_context 'data consistency worker class', :always, :load_balancing_for_test_data_consistency_worker
diff --git a/spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb b/spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb
index 14f240cd159..9f23eb0094f 100644
--- a/spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb
+++ b/spec/lib/gitlab/database/load_balancing/sidekiq_server_middleware_spec.rb
@@ -62,9 +62,12 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware do
include_examples 'load balancing strategy', expected_strategy
end
- shared_examples_for 'replica is up to date' do |location, expected_strategy|
+ shared_examples_for 'replica is up to date' do |expected_strategy|
+ let(:location) {'0/D525E3A8' }
+ let(:wal_locations) { { Gitlab::Database::MAIN_DATABASE_NAME.to_sym => location } }
+
it 'does not stick to the primary', :aggregate_failures do
- expect(middleware).to receive(:replica_caught_up?).with(location).and_return(true)
+ expect(load_balancer).to receive(:select_up_to_date_host).with(location).and_return(true)
run_middleware do
expect(Gitlab::Database::LoadBalancing::Session.current.use_primary?).not_to be_truthy
@@ -85,30 +88,40 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware do
include_examples 'stick to the primary', 'primary'
end
- context 'when database replica location is set' do
- let(:job) { { 'job_id' => 'a180b47c-3fd6-41b8-81e9-34da61c3400e', 'database_replica_location' => '0/D525E3A8' } }
+ context 'when database wal location is set' do
+ let(:job) { { 'job_id' => 'a180b47c-3fd6-41b8-81e9-34da61c3400e', 'wal_locations' => wal_locations } }
+
+ before do
+ allow(load_balancer).to receive(:select_up_to_date_host).with(location).and_return(true)
+ end
+
+ it_behaves_like 'replica is up to date', 'replica'
+ end
+
+ context 'when deduplication wal location is set' do
+ let(:job) { { 'job_id' => 'a180b47c-3fd6-41b8-81e9-34da61c3400e', 'dedup_wal_locations' => wal_locations } }
before do
- allow(middleware).to receive(:replica_caught_up?).and_return(true)
+ allow(load_balancer).to receive(:select_up_to_date_host).with(wal_locations[:main]).and_return(true)
end
- it_behaves_like 'replica is up to date', '0/D525E3A8', 'replica'
+ it_behaves_like 'replica is up to date', 'replica'
end
- context 'when database primary location is set' do
+ context 'when legacy wal location is set' do
let(:job) { { 'job_id' => 'a180b47c-3fd6-41b8-81e9-34da61c3400e', 'database_write_location' => '0/D525E3A8' } }
before do
- allow(middleware).to receive(:replica_caught_up?).and_return(true)
+ allow(load_balancer).to receive(:select_up_to_date_host).with('0/D525E3A8').and_return(true)
end
- it_behaves_like 'replica is up to date', '0/D525E3A8', 'replica'
+ it_behaves_like 'replica is up to date', 'replica'
end
context 'when database location is not set' do
let(:job) { { 'job_id' => 'a180b47c-3fd6-41b8-81e9-34da61c3400e' } }
- it_behaves_like 'stick to the primary', 'primary_no_wal'
+ include_examples 'stick to the primary', 'primary_no_wal'
end
end
@@ -167,7 +180,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware do
replication_lag!(false)
end
- it_behaves_like 'replica is up to date', '0/D525E3A8', 'replica_retried'
+ include_examples 'replica is up to date', 'replica_retried'
end
end
end
@@ -178,7 +191,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::SidekiqServerMiddleware do
context 'when replica is not up to date' do
before do
- allow(middleware).to receive(:replica_caught_up?).and_return(false)
+ allow(load_balancer).to receive(:select_up_to_date_host).and_return(false)
end
include_examples 'stick to the primary', 'primary'
diff --git a/spec/lib/gitlab/database/load_balancing_spec.rb b/spec/lib/gitlab/database/load_balancing_spec.rb
index 6ec8e0516f6..f40ad444081 100644
--- a/spec/lib/gitlab/database/load_balancing_spec.rb
+++ b/spec/lib/gitlab/database/load_balancing_spec.rb
@@ -40,106 +40,25 @@ RSpec.describe Gitlab::Database::LoadBalancing do
end
describe '.configuration' do
- it 'returns a Hash' do
- lb_config = { 'hosts' => %w(foo) }
+ it 'returns the configuration for the load balancer' do
+ raw = ActiveRecord::Base.connection_db_config.configuration_hash
+ cfg = described_class.configuration
- original_db_config = Gitlab::Database.main.config
- modified_db_config = original_db_config.merge(load_balancing: lb_config)
- expect(Gitlab::Database.main).to receive(:config).and_return(modified_db_config)
-
- expect(described_class.configuration).to eq(lb_config)
- end
- end
-
- describe '.max_replication_difference' do
- context 'without an explicitly configured value' do
- it 'returns the default value' do
- allow(described_class)
- .to receive(:configuration)
- .and_return({})
-
- expect(described_class.max_replication_difference).to eq(8.megabytes)
- end
- end
-
- context 'with an explicitly configured value' do
- it 'returns the configured value' do
- allow(described_class)
- .to receive(:configuration)
- .and_return({ 'max_replication_difference' => 4 })
-
- expect(described_class.max_replication_difference).to eq(4)
- end
- end
- end
-
- describe '.max_replication_lag_time' do
- context 'without an explicitly configured value' do
- it 'returns the default value' do
- allow(described_class)
- .to receive(:configuration)
- .and_return({})
-
- expect(described_class.max_replication_lag_time).to eq(60)
- end
- end
-
- context 'with an explicitly configured value' do
- it 'returns the configured value' do
- allow(described_class)
- .to receive(:configuration)
- .and_return({ 'max_replication_lag_time' => 4 })
-
- expect(described_class.max_replication_lag_time).to eq(4)
- end
- end
- end
-
- describe '.replica_check_interval' do
- context 'without an explicitly configured value' do
- it 'returns the default value' do
- allow(described_class)
- .to receive(:configuration)
- .and_return({})
-
- expect(described_class.replica_check_interval).to eq(60)
- end
- end
-
- context 'with an explicitly configured value' do
- it 'returns the configured value' do
- allow(described_class)
- .to receive(:configuration)
- .and_return({ 'replica_check_interval' => 4 })
-
- expect(described_class.replica_check_interval).to eq(4)
- end
- end
- end
-
- describe '.hosts' do
- it 'returns a list of hosts' do
- allow(described_class)
- .to receive(:configuration)
- .and_return({ 'hosts' => %w(foo bar baz) })
-
- expect(described_class.hosts).to eq(%w(foo bar baz))
- end
- end
-
- describe '.pool_size' do
- it 'returns a Fixnum' do
- expect(described_class.pool_size).to be_a_kind_of(Integer)
+ # There isn't much to test here as the load balancing settings might not
+ # (and likely aren't) set when running tests.
+ expect(cfg.pool_size).to eq(raw[:pool])
end
end
describe '.enable?' do
before do
- allow(described_class).to receive(:hosts).and_return(%w(foo))
+ allow(described_class.configuration)
+ .to receive(:hosts)
+ .and_return(%w(foo))
end
it 'returns false when no hosts are specified' do
- allow(described_class).to receive(:hosts).and_return([])
+ allow(described_class.configuration).to receive(:hosts).and_return([])
expect(described_class.enable?).to eq(false)
end
@@ -163,10 +82,10 @@ RSpec.describe Gitlab::Database::LoadBalancing do
end
it 'returns true when service discovery is enabled' do
- allow(described_class).to receive(:hosts).and_return([])
+ allow(described_class.configuration).to receive(:hosts).and_return([])
allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(false)
- allow(described_class)
+ allow(described_class.configuration)
.to receive(:service_discovery_enabled?)
.and_return(true)
@@ -175,17 +94,17 @@ RSpec.describe Gitlab::Database::LoadBalancing do
end
describe '.configured?' do
- it 'returns true when Sidekiq is being used' do
- allow(described_class).to receive(:hosts).and_return(%w(foo))
- allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(true)
+ it 'returns true when hosts are configured' do
+ allow(described_class.configuration)
+ .to receive(:hosts)
+ .and_return(%w[foo])
+
expect(described_class.configured?).to eq(true)
end
- it 'returns true when service discovery is enabled in Sidekiq' do
- allow(described_class).to receive(:hosts).and_return([])
- allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(true)
-
- allow(described_class)
+ it 'returns true when service discovery is enabled' do
+ allow(described_class.configuration).to receive(:hosts).and_return([])
+ allow(described_class.configuration)
.to receive(:service_discovery_enabled?)
.and_return(true)
@@ -193,9 +112,8 @@ RSpec.describe Gitlab::Database::LoadBalancing do
end
it 'returns false when neither service discovery nor hosts are configured' do
- allow(described_class).to receive(:hosts).and_return([])
-
- allow(described_class)
+ allow(described_class.configuration).to receive(:hosts).and_return([])
+ allow(described_class.configuration)
.to receive(:service_discovery_enabled?)
.and_return(false)
@@ -204,9 +122,11 @@ RSpec.describe Gitlab::Database::LoadBalancing do
end
describe '.configure_proxy' do
- it 'configures the connection proxy' do
+ before do
allow(ActiveRecord::Base).to receive(:load_balancing_proxy=)
+ end
+ it 'configures the connection proxy' do
described_class.configure_proxy
expect(ActiveRecord::Base).to have_received(:load_balancing_proxy=)
@@ -214,71 +134,24 @@ RSpec.describe Gitlab::Database::LoadBalancing do
end
context 'when service discovery is enabled' do
- let(:service_discovery) { double(Gitlab::Database::LoadBalancing::ServiceDiscovery) }
-
it 'runs initial service discovery when configuring the connection proxy' do
- allow(described_class)
- .to receive(:configuration)
- .and_return('discover' => { 'record' => 'foo' })
-
- expect(Gitlab::Database::LoadBalancing::ServiceDiscovery).to receive(:new).and_return(service_discovery)
- expect(service_discovery).to receive(:perform_service_discovery)
-
- described_class.configure_proxy
- end
- end
- end
+ discover = instance_spy(Gitlab::Database::LoadBalancing::ServiceDiscovery)
- describe '.active_record_models' do
- it 'returns an Array' do
- expect(described_class.active_record_models).to be_an_instance_of(Array)
- end
- end
+ allow(described_class.configuration)
+ .to receive(:service_discovery)
+ .and_return({ record: 'foo' })
- describe '.service_discovery_enabled?' do
- it 'returns true if service discovery is enabled' do
- allow(described_class)
- .to receive(:configuration)
- .and_return('discover' => { 'record' => 'foo' })
-
- expect(described_class.service_discovery_enabled?).to eq(true)
- end
+ expect(Gitlab::Database::LoadBalancing::ServiceDiscovery)
+ .to receive(:new)
+ .with(
+ an_instance_of(Gitlab::Database::LoadBalancing::LoadBalancer),
+ an_instance_of(Hash)
+ )
+ .and_return(discover)
- it 'returns false if service discovery is disabled' do
- expect(described_class.service_discovery_enabled?).to eq(false)
- end
- end
+ expect(discover).to receive(:perform_service_discovery)
- describe '.service_discovery_configuration' do
- context 'when no configuration is provided' do
- it 'returns a default configuration Hash' do
- expect(described_class.service_discovery_configuration).to eq(
- nameserver: 'localhost',
- port: 8600,
- record: nil,
- record_type: 'A',
- interval: 60,
- disconnect_timeout: 120,
- use_tcp: false
- )
- end
- end
-
- context 'when configuration is provided' do
- it 'returns a Hash including the custom configuration' do
- allow(described_class)
- .to receive(:configuration)
- .and_return('discover' => { 'record' => 'foo', 'record_type' => 'SRV' })
-
- expect(described_class.service_discovery_configuration).to eq(
- nameserver: 'localhost',
- port: 8600,
- record: 'foo',
- record_type: 'SRV',
- interval: 60,
- disconnect_timeout: 120,
- use_tcp: false
- )
+ described_class.configure_proxy
end
end
end
@@ -292,15 +165,23 @@ RSpec.describe Gitlab::Database::LoadBalancing do
end
it 'starts service discovery if enabled' do
- allow(described_class)
+ allow(described_class.configuration)
.to receive(:service_discovery_enabled?)
.and_return(true)
instance = double(:instance)
+ config = Gitlab::Database::LoadBalancing::Configuration
+ .new(ActiveRecord::Base)
+ lb = Gitlab::Database::LoadBalancing::LoadBalancer.new(config)
+ proxy = Gitlab::Database::LoadBalancing::ConnectionProxy.new(lb)
+
+ allow(described_class)
+ .to receive(:proxy)
+ .and_return(proxy)
expect(Gitlab::Database::LoadBalancing::ServiceDiscovery)
.to receive(:new)
- .with(an_instance_of(Hash))
+ .with(lb, an_instance_of(Hash))
.and_return(instance)
expect(instance)
@@ -330,7 +211,13 @@ RSpec.describe Gitlab::Database::LoadBalancing do
context 'when the load balancing is configured' do
let(:db_host) { ActiveRecord::Base.connection_pool.db_config.host }
- let(:proxy) { described_class::ConnectionProxy.new([db_host]) }
+ let(:config) do
+ Gitlab::Database::LoadBalancing::Configuration
+ .new(ActiveRecord::Base, [db_host])
+ end
+
+ let(:load_balancer) { described_class::LoadBalancer.new(config) }
+ let(:proxy) { described_class::ConnectionProxy.new(load_balancer) }
context 'when a proxy connection is used' do
it 'returns :unknown' do
@@ -770,6 +657,16 @@ RSpec.describe Gitlab::Database::LoadBalancing do
it 'redirects queries to the right roles' do
roles = []
+ # If we don't run any queries, the pool may be a NullPool. This can
+ # result in some tests reporting a role as `:unknown`, even though the
+ # tests themselves are correct.
+ #
+ # To prevent this from happening we simply run a simple query to
+ # ensure the proper pool type is put in place. The exact query doesn't
+ # matter, provided it actually runs a query and thus creates a proper
+ # connection pool.
+ model.count
+
subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |event|
role = ::Gitlab::Database::LoadBalancing.db_role_for_connection(event.payload[:connection])
roles << role if role.present?
diff --git a/spec/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers_spec.rb
new file mode 100644
index 00000000000..708d1be6e00
--- /dev/null
+++ b/spec/lib/gitlab/database/migration_helpers/loose_foreign_key_helpers_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers do
+ let_it_be(:migration) do
+ ActiveRecord::Migration.new.extend(described_class)
+ end
+
+ let(:model) do
+ Class.new(ApplicationRecord) do
+ self.table_name = 'loose_fk_test_table'
+ end
+ end
+
+ before(:all) do
+ migration.create_table :loose_fk_test_table do |t|
+ t.timestamps
+ end
+ end
+
+ before do
+ 3.times { model.create! }
+ end
+
+ context 'when the record deletion tracker trigger is not installed' do
+ it 'does store record deletions' do
+ model.delete_all
+
+ expect(LooseForeignKeys::DeletedRecord.count).to eq(0)
+ end
+ end
+
+ context 'when the record deletion tracker trigger is installed' do
+ before do
+ migration.track_record_deletions(:loose_fk_test_table)
+ end
+
+ it 'stores the record deletion' do
+ records = model.all
+ record_to_be_deleted = records.last
+
+ record_to_be_deleted.delete
+
+ expect(LooseForeignKeys::DeletedRecord.count).to eq(1)
+ deleted_record = LooseForeignKeys::DeletedRecord.all.first
+
+ expect(deleted_record.deleted_table_primary_key_value).to eq(record_to_be_deleted.id)
+ expect(deleted_record.deleted_table_name).to eq('loose_fk_test_table')
+ end
+
+ it 'stores multiple record deletions' do
+ model.delete_all
+
+ expect(LooseForeignKeys::DeletedRecord.count).to eq(3)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/migration_helpers/v2_spec.rb b/spec/lib/gitlab/database/migration_helpers/v2_spec.rb
index f132ecbf13b..854e97ef897 100644
--- a/spec/lib/gitlab/database/migration_helpers/v2_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers/v2_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe Gitlab::Database::MigrationHelpers::V2 do
include Database::TriggerHelpers
+ include Database::TableSchemaHelpers
let(:migration) do
ActiveRecord::Migration.new.extend(described_class)
@@ -11,6 +12,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do
before do
allow(migration).to receive(:puts)
+
+ allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false)
end
shared_examples_for 'Setting up to rename a column' do
@@ -218,4 +221,105 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do
let(:added_column) { :original }
end
end
+
+ describe '#create_table' do
+ let(:table_name) { :test_table }
+ let(:column_attributes) do
+ [
+ { name: 'id', sql_type: 'bigint', null: false, default: nil },
+ { name: 'created_at', sql_type: 'timestamp with time zone', null: false, default: nil },
+ { name: 'updated_at', sql_type: 'timestamp with time zone', null: false, default: nil },
+ { name: 'some_id', sql_type: 'integer', null: false, default: nil },
+ { name: 'active', sql_type: 'boolean', null: false, default: 'true' },
+ { name: 'name', sql_type: 'text', null: true, default: nil }
+ ]
+ end
+
+ context 'using a limit: attribute on .text' do
+ it 'creates the table as expected' do
+ migration.create_table table_name do |t|
+ t.timestamps_with_timezone
+ t.integer :some_id, null: false
+ t.boolean :active, null: false, default: true
+ t.text :name, limit: 100
+ end
+
+ expect_table_columns_to_match(column_attributes, table_name)
+ expect_check_constraint(table_name, 'check_cda6f69506', 'char_length(name) <= 100')
+ end
+ end
+ end
+
+ describe '#with_lock_retries' do
+ let(:model) do
+ ActiveRecord::Migration.new.extend(described_class)
+ end
+
+ let(:buffer) { StringIO.new }
+ let(:in_memory_logger) { Gitlab::JsonLogger.new(buffer) }
+ let(:env) { { 'DISABLE_LOCK_RETRIES' => 'true' } }
+
+ it 'sets the migration class name in the logs' do
+ model.with_lock_retries(env: env, logger: in_memory_logger) { }
+
+ buffer.rewind
+ expect(buffer.read).to include("\"class\":\"#{model.class}\"")
+ end
+
+ where(raise_on_exhaustion: [true, false])
+
+ with_them do
+ it 'sets raise_on_exhaustion as requested' do
+ with_lock_retries = double
+ expect(Gitlab::Database::WithLockRetries).to receive(:new).and_return(with_lock_retries)
+ expect(with_lock_retries).to receive(:run).with(raise_on_exhaustion: raise_on_exhaustion)
+
+ model.with_lock_retries(env: env, logger: in_memory_logger, raise_on_exhaustion: raise_on_exhaustion) { }
+ end
+ end
+
+ it 'does not raise on exhaustion by default' do
+ with_lock_retries = double
+ expect(Gitlab::Database::WithLockRetries).to receive(:new).and_return(with_lock_retries)
+ expect(with_lock_retries).to receive(:run).with(raise_on_exhaustion: false)
+
+ model.with_lock_retries(env: env, logger: in_memory_logger) { }
+ end
+
+ it 'defaults to disallowing subtransactions' do
+ with_lock_retries = double
+ expect(Gitlab::Database::WithLockRetries).to receive(:new).with(hash_including(allow_savepoints: false)).and_return(with_lock_retries)
+ expect(with_lock_retries).to receive(:run).with(raise_on_exhaustion: false)
+
+ model.with_lock_retries(env: env, logger: in_memory_logger) { }
+ end
+
+ context 'when in transaction' do
+ before do
+ allow(model).to receive(:transaction_open?).and_return(true)
+ end
+
+ context 'when lock retries are enabled' do
+ before do
+ allow(model).to receive(:enable_lock_retries?).and_return(true)
+ end
+
+ it 'does not use Gitlab::Database::WithLockRetries and executes the provided block directly' do
+ expect(Gitlab::Database::WithLockRetries).not_to receive(:new)
+
+ expect(model.with_lock_retries(env: env, logger: in_memory_logger) { :block_result }).to eq(:block_result)
+ end
+ end
+
+ context 'when lock retries are not enabled' do
+ before do
+ allow(model).to receive(:enable_lock_retries?).and_return(false)
+ end
+
+ it 'raises an error' do
+ expect { model.with_lock_retries(env: env, logger: in_memory_logger) { } }.to raise_error /can not be run inside an already open transaction/
+ end
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb
index 9f9aef77de7..006f8a39f9c 100644
--- a/spec/lib/gitlab/database/migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers_spec.rb
@@ -798,13 +798,13 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
# This spec runs without an enclosing transaction (:delete truncation method for db_cleaner)
context 'when the statement_timeout is already disabled', :delete do
before do
- ActiveRecord::Base.connection.execute('SET statement_timeout TO 0')
+ ActiveRecord::Migration.connection.execute('SET statement_timeout TO 0')
end
after do
- # Use ActiveRecord::Base.connection instead of model.execute
+ # Use ActiveRecord::Migration.connection instead of model.execute
# so that this call is not counted below
- ActiveRecord::Base.connection.execute('RESET statement_timeout')
+ ActiveRecord::Migration.connection.execute('RESET statement_timeout')
end
it 'yields control without disabling the timeout or resetting' do
@@ -954,10 +954,11 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
let(:trigger_name) { model.rename_trigger_name(:users, :id, :new) }
let(:user) { create(:user) }
let(:copy_trigger) { double('copy trigger') }
+ let(:connection) { ActiveRecord::Migration.connection }
before do
expect(Gitlab::Database::UnidirectionalCopyTrigger).to receive(:on_table)
- .with(:users).and_return(copy_trigger)
+ .with(:users, connection: connection).and_return(copy_trigger)
end
it 'copies the value to the new column using the type_cast_function', :aggregate_failures do
@@ -1300,11 +1301,13 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
describe '#install_rename_triggers' do
+ let(:connection) { ActiveRecord::Migration.connection }
+
it 'installs the triggers' do
copy_trigger = double('copy trigger')
expect(Gitlab::Database::UnidirectionalCopyTrigger).to receive(:on_table)
- .with(:users).and_return(copy_trigger)
+ .with(:users, connection: connection).and_return(copy_trigger)
expect(copy_trigger).to receive(:create).with(:old, :new, trigger_name: 'foo')
@@ -1313,11 +1316,13 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
describe '#remove_rename_triggers' do
+ let(:connection) { ActiveRecord::Migration.connection }
+
it 'removes the function and trigger' do
copy_trigger = double('copy trigger')
expect(Gitlab::Database::UnidirectionalCopyTrigger).to receive(:on_table)
- .with('bar').and_return(copy_trigger)
+ .with('bar', connection: connection).and_return(copy_trigger)
expect(copy_trigger).to receive(:drop).with('foo')
@@ -1886,6 +1891,61 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
end
+ describe '#restore_conversion_of_integer_to_bigint' do
+ let(:table) { :test_table }
+ let(:column) { :id }
+ let(:tmp_column) { model.convert_to_bigint_column(column) }
+
+ before do
+ model.create_table table, id: false do |t|
+ t.bigint :id, primary_key: true
+ t.bigint :build_id, null: false
+ t.timestamps
+ end
+ end
+
+ context 'when the target table does not exist' do
+ it 'raises an error' do
+ expect { model.restore_conversion_of_integer_to_bigint(:this_table_is_not_real, column) }
+ .to raise_error('Table this_table_is_not_real does not exist')
+ end
+ end
+
+ context 'when the column to migrate does not exist' do
+ it 'raises an error' do
+ expect { model.restore_conversion_of_integer_to_bigint(table, :this_column_is_not_real) }
+ .to raise_error(ArgumentError, "Column this_column_is_not_real does not exist on #{table}")
+ end
+ end
+
+ context 'when a single column is given' do
+ let(:column_to_convert) { 'id' }
+ let(:temporary_column) { model.convert_to_bigint_column(column_to_convert) }
+
+ it 'creates the correct columns and installs the trigger' do
+ expect(model).to receive(:add_column).with(table, temporary_column, :int, default: 0, null: false)
+
+ expect(model).to receive(:install_rename_triggers).with(table, [column_to_convert], [temporary_column])
+
+ model.restore_conversion_of_integer_to_bigint(table, column_to_convert)
+ end
+ end
+
+ context 'when multiple columns are given' do
+ let(:columns_to_convert) { %i[id build_id] }
+ let(:temporary_columns) { columns_to_convert.map { |column| model.convert_to_bigint_column(column) } }
+
+ it 'creates the correct columns and installs the trigger' do
+ expect(model).to receive(:add_column).with(table, temporary_columns[0], :int, default: 0, null: false)
+ expect(model).to receive(:add_column).with(table, temporary_columns[1], :int, default: 0, null: false)
+
+ expect(model).to receive(:install_rename_triggers).with(table, columns_to_convert, temporary_columns)
+
+ model.restore_conversion_of_integer_to_bigint(table, columns_to_convert)
+ end
+ end
+ end
+
describe '#revert_initialize_conversion_of_integer_to_bigint' do
let(:table) { :test_table }
@@ -2139,7 +2199,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
describe '#index_exists_by_name?' do
it 'returns true if an index exists' do
- ActiveRecord::Base.connection.execute(
+ ActiveRecord::Migration.connection.execute(
'CREATE INDEX test_index_for_index_exists ON projects (path);'
)
@@ -2154,7 +2214,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
context 'when an index with a function exists' do
before do
- ActiveRecord::Base.connection.execute(
+ ActiveRecord::Migration.connection.execute(
'CREATE INDEX test_index ON projects (LOWER(path));'
)
end
@@ -2167,15 +2227,15 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
context 'when an index exists for a table with the same name in another schema' do
before do
- ActiveRecord::Base.connection.execute(
+ ActiveRecord::Migration.connection.execute(
'CREATE SCHEMA new_test_schema'
)
- ActiveRecord::Base.connection.execute(
+ ActiveRecord::Migration.connection.execute(
'CREATE TABLE new_test_schema.projects (id integer, name character varying)'
)
- ActiveRecord::Base.connection.execute(
+ ActiveRecord::Migration.connection.execute(
'CREATE INDEX test_index_on_name ON new_test_schema.projects (LOWER(name));'
)
end
@@ -2255,8 +2315,6 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
expect(buffer.read).to include("\"class\":\"#{model.class}\"")
end
- using RSpec::Parameterized::TableSyntax
-
where(raise_on_exhaustion: [true, false])
with_them do
@@ -2276,6 +2334,15 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
model.with_lock_retries(env: env, logger: in_memory_logger) { }
end
+
+ it 'defaults to allowing subtransactions' do
+ with_lock_retries = double
+
+ expect(Gitlab::Database::WithLockRetries).to receive(:new).with(hash_including(allow_savepoints: true)).and_return(with_lock_retries)
+ expect(with_lock_retries).to receive(:run).with(raise_on_exhaustion: false)
+
+ model.with_lock_retries(env: env, logger: in_memory_logger) { }
+ end
end
describe '#backfill_iids' do
@@ -2401,19 +2468,19 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
describe '#check_constraint_exists?' do
before do
- ActiveRecord::Base.connection.execute(
+ ActiveRecord::Migration.connection.execute(
'ALTER TABLE projects ADD CONSTRAINT check_1 CHECK (char_length(path) <= 5) NOT VALID'
)
- ActiveRecord::Base.connection.execute(
+ ActiveRecord::Migration.connection.execute(
'CREATE SCHEMA new_test_schema'
)
- ActiveRecord::Base.connection.execute(
+ ActiveRecord::Migration.connection.execute(
'CREATE TABLE new_test_schema.projects (id integer, name character varying)'
)
- ActiveRecord::Base.connection.execute(
+ ActiveRecord::Migration.connection.execute(
'ALTER TABLE new_test_schema.projects ADD CONSTRAINT check_2 CHECK (char_length(name) <= 5)'
)
end
@@ -2628,6 +2695,10 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
describe '#remove_check_constraint' do
+ before do
+ allow(model).to receive(:transaction_open?).and_return(false)
+ end
+
it 'removes the constraint' do
drop_sql = /ALTER TABLE test_table\s+DROP CONSTRAINT IF EXISTS check_name/
diff --git a/spec/lib/gitlab/database/migration_spec.rb b/spec/lib/gitlab/database/migration_spec.rb
new file mode 100644
index 00000000000..287e738c24e
--- /dev/null
+++ b/spec/lib/gitlab/database/migration_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Migration do
+ describe '.[]' do
+ context 'version: 1.0' do
+ subject { described_class[1.0] }
+
+ it 'inherits from ActiveRecord::Migration[6.1]' do
+ expect(subject.superclass).to eq(ActiveRecord::Migration[6.1])
+ end
+
+ it 'includes migration helpers version 2' do
+ expect(subject.included_modules).to include(Gitlab::Database::MigrationHelpers::V2)
+ end
+
+ it 'includes LockRetriesConcern' do
+ expect(subject.included_modules).to include(Gitlab::Database::Migration::LockRetriesConcern)
+ end
+ end
+
+ context 'unknown version' do
+ it 'raises an error' do
+ expect { described_class[0] }.to raise_error(ArgumentError, /Unknown migration version/)
+ end
+ end
+ end
+
+ describe '.current_version' do
+ it 'includes current ActiveRecord migration class' do
+ # This breaks upon Rails upgrade. In that case, we'll add a new version in Gitlab::Database::Migration::MIGRATION_CLASSES,
+ # bump .current_version and leave existing migrations and already defined versions of Gitlab::Database::Migration
+ # untouched.
+ expect(described_class[described_class.current_version].superclass).to eq(ActiveRecord::Migration::Current)
+ end
+ end
+
+ describe Gitlab::Database::Migration::LockRetriesConcern do
+ subject { class_def.new }
+
+ context 'when not explicitly called' do
+ let(:class_def) do
+ Class.new do
+ include Gitlab::Database::Migration::LockRetriesConcern
+ end
+ end
+
+ it 'does not disable lock retries by default' do
+ expect(subject.enable_lock_retries?).not_to be_truthy
+ end
+ end
+
+ context 'when explicitly disabled' do
+ let(:class_def) do
+ Class.new do
+ include Gitlab::Database::Migration::LockRetriesConcern
+
+ enable_lock_retries!
+ end
+ end
+
+ it 'does not disable lock retries by default' do
+ expect(subject.enable_lock_retries?).to be_truthy
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/migrations/lock_retry_mixin_spec.rb b/spec/lib/gitlab/database/migrations/lock_retry_mixin_spec.rb
new file mode 100644
index 00000000000..076fb9e8215
--- /dev/null
+++ b/spec/lib/gitlab/database/migrations/lock_retry_mixin_spec.rb
@@ -0,0 +1,129 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Migrations::LockRetryMixin do
+ describe Gitlab::Database::Migrations::LockRetryMixin::ActiveRecordMigrationProxyLockRetries do
+ let(:migration) { double }
+ let(:return_value) { double }
+ let(:class_def) do
+ Class.new do
+ include Gitlab::Database::Migrations::LockRetryMixin::ActiveRecordMigrationProxyLockRetries
+
+ attr_reader :migration
+
+ def initialize(migration)
+ @migration = migration
+ end
+ end
+ end
+
+ describe '#enable_lock_retries?' do
+ subject { class_def.new(migration).enable_lock_retries? }
+
+ it 'delegates to #migration' do
+ expect(migration).to receive(:enable_lock_retries?).and_return(return_value)
+
+ result = subject
+
+ expect(result).to eq(return_value)
+ end
+ end
+
+ describe '#migration_class' do
+ subject { class_def.new(migration).migration_class }
+
+ it 'retrieves actual migration class from #migration' do
+ expect(migration).to receive(:class).and_return(return_value)
+
+ result = subject
+
+ expect(result).to eq(return_value)
+ end
+ end
+ end
+
+ describe Gitlab::Database::Migrations::LockRetryMixin::ActiveRecordMigratorLockRetries do
+ let(:class_def) do
+ Class.new do
+ attr_reader :receiver
+
+ def initialize(receiver)
+ @receiver = receiver
+ end
+
+ def ddl_transaction(migration, &block)
+ receiver.ddl_transaction(migration, &block)
+ end
+
+ def use_transaction?(migration)
+ receiver.use_transaction?(migration)
+ end
+ end.prepend(Gitlab::Database::Migrations::LockRetryMixin::ActiveRecordMigratorLockRetries)
+ end
+
+ subject { class_def.new(receiver) }
+
+ before do
+ allow(migration).to receive(:migration_class).and_return('TestClass')
+ allow(receiver).to receive(:ddl_transaction)
+ end
+
+ context 'with transactions disabled' do
+ let(:migration) { double('migration', enable_lock_retries?: false) }
+ let(:receiver) { double('receiver', use_transaction?: false)}
+
+ it 'calls super method' do
+ p = proc { }
+
+ expect(receiver).to receive(:ddl_transaction).with(migration, &p)
+
+ subject.ddl_transaction(migration, &p)
+ end
+ end
+
+ context 'with transactions enabled, but lock retries disabled' do
+ let(:receiver) { double('receiver', use_transaction?: true)}
+ let(:migration) { double('migration', enable_lock_retries?: false) }
+
+ it 'calls super method' do
+ p = proc { }
+
+ expect(receiver).to receive(:ddl_transaction).with(migration, &p)
+
+ subject.ddl_transaction(migration, &p)
+ end
+ end
+
+ context 'with transactions enabled and lock retries enabled' do
+ let(:receiver) { double('receiver', use_transaction?: true)}
+ let(:migration) { double('migration', enable_lock_retries?: true) }
+
+ it 'calls super method' do
+ p = proc { }
+
+ expect(receiver).not_to receive(:ddl_transaction)
+ expect_next_instance_of(Gitlab::Database::WithLockRetries) do |retries|
+ expect(retries).to receive(:run).with(raise_on_exhaustion: false, &p)
+ end
+
+ subject.ddl_transaction(migration, &p)
+ end
+ end
+ end
+
+ describe '.patch!' do
+ subject { described_class.patch! }
+
+ it 'patches MigrationProxy' do
+ expect(ActiveRecord::MigrationProxy).to receive(:prepend).with(Gitlab::Database::Migrations::LockRetryMixin::ActiveRecordMigrationProxyLockRetries)
+
+ subject
+ end
+
+ it 'patches Migrator' do
+ expect(ActiveRecord::Migrator).to receive(:prepend).with(Gitlab::Database::Migrations::LockRetryMixin::ActiveRecordMigratorLockRetries)
+
+ subject
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb b/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb
index c4fbf53d1c2..27ada12b067 100644
--- a/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb
+++ b/spec/lib/gitlab/database/partitioning/monthly_strategy_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do
+ let(:connection) { ActiveRecord::Base.connection }
+
describe '#current_partitions' do
subject { described_class.new(model, partitioning_key).current_partitions }
@@ -11,7 +13,7 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do
let(:table_name) { :partitioned_test }
before do
- ActiveRecord::Base.connection.execute(<<~SQL)
+ connection.execute(<<~SQL)
CREATE TABLE #{table_name}
(id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at))
PARTITION BY RANGE (created_at);
@@ -52,7 +54,7 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do
context 'with existing partitions' do
before do
- ActiveRecord::Base.connection.execute(<<~SQL)
+ connection.execute(<<~SQL)
CREATE TABLE #{model.table_name}
(id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at))
PARTITION BY RANGE (created_at);
@@ -113,7 +115,7 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do
context 'without existing partitions' do
before do
- ActiveRecord::Base.connection.execute(<<~SQL)
+ connection.execute(<<~SQL)
CREATE TABLE #{model.table_name}
(id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at))
PARTITION BY RANGE (created_at);
@@ -159,7 +161,7 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do
context 'with a regular partition but no catchall (MINVALUE, to) partition' do
before do
- ActiveRecord::Base.connection.execute(<<~SQL)
+ connection.execute(<<~SQL)
CREATE TABLE #{model.table_name}
(id serial not null, created_at timestamptz not null, PRIMARY KEY (id, created_at))
PARTITION BY RANGE (created_at);
@@ -248,6 +250,25 @@ RSpec.describe Gitlab::Database::Partitioning::MonthlyStrategy do
Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-05-01', '2020-06-01', partition_name: 'partitioned_test_202005')
)
end
+
+ context 'when the retain_non_empty_partitions is true' do
+ subject { described_class.new(model, partitioning_key, retain_for: 2.months, retain_non_empty_partitions: true).extra_partitions }
+
+ it 'prunes empty partitions' do
+ expect(subject).to contain_exactly(
+ Gitlab::Database::Partitioning::TimePartition.new(model.table_name, nil, '2020-05-01', partition_name: 'partitioned_test_000000'),
+ Gitlab::Database::Partitioning::TimePartition.new(model.table_name, '2020-05-01', '2020-06-01', partition_name: 'partitioned_test_202005')
+ )
+ end
+
+ it 'does not prune non-empty partitions' do
+ connection.execute("INSERT INTO #{table_name} (created_at) VALUES (('2020-05-15'))") # inserting one record into partitioned_test_202005
+
+ expect(subject).to contain_exactly(
+ Gitlab::Database::Partitioning::TimePartition.new(model.table_name, nil, '2020-05-01', partition_name: 'partitioned_test_000000')
+ )
+ end
+ end
end
end
end
diff --git a/spec/lib/gitlab/database/partitioning/multi_database_partition_manager_spec.rb b/spec/lib/gitlab/database/partitioning/multi_database_partition_manager_spec.rb
new file mode 100644
index 00000000000..3c94c1bf4ea
--- /dev/null
+++ b/spec/lib/gitlab/database/partitioning/multi_database_partition_manager_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Partitioning::MultiDatabasePartitionManager, '#sync_partitions' do
+ subject(:sync_partitions) { manager.sync_partitions }
+
+ let(:manager) { described_class.new(models) }
+ let(:models) { [model1, model2] }
+
+ let(:model1) { double('model1', connection: connection1, table_name: 'table1') }
+ let(:model2) { double('model2', connection: connection1, table_name: 'table2') }
+
+ let(:connection1) { double('connection1') }
+ let(:connection2) { double('connection2') }
+
+ let(:target_manager_class) { Gitlab::Database::Partitioning::PartitionManager }
+ let(:target_manager1) { double('partition manager') }
+ let(:target_manager2) { double('partition manager') }
+
+ before do
+ allow(manager).to receive(:connection_name).and_return('name')
+ end
+
+ it 'syncs model partitions, setting up the appropriate connection for each', :aggregate_failures do
+ expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(model1.connection).and_yield.ordered
+ expect(target_manager_class).to receive(:new).with(model1).and_return(target_manager1).ordered
+ expect(target_manager1).to receive(:sync_partitions)
+
+ expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(model2.connection).and_yield.ordered
+ expect(target_manager_class).to receive(:new).with(model2).and_return(target_manager2).ordered
+ expect(target_manager2).to receive(:sync_partitions)
+
+ sync_partitions
+ end
+end
diff --git a/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb b/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb
index 3d60457c3a9..8f1f5b5ba1b 100644
--- a/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb
+++ b/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb
@@ -12,26 +12,18 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do
end
end
- describe '.register' do
- let(:model) { double(partitioning_strategy: nil) }
-
- it 'remembers registered models' do
- expect { described_class.register(model) }.to change { described_class.models }.to include(model)
- end
- end
-
context 'creating partitions (mocked)' do
- subject(:sync_partitions) { described_class.new(models).sync_partitions }
+ subject(:sync_partitions) { described_class.new(model).sync_partitions }
- let(:models) { [model] }
- let(:model) { double(partitioning_strategy: partitioning_strategy, table_name: table) }
+ let(:model) { double(partitioning_strategy: partitioning_strategy, table_name: table, connection: connection) }
let(:partitioning_strategy) { double(missing_partitions: partitions, extra_partitions: []) }
+ let(:connection) { ActiveRecord::Base.connection }
let(:table) { "some_table" }
before do
- allow(ActiveRecord::Base.connection).to receive(:table_exists?).and_call_original
- allow(ActiveRecord::Base.connection).to receive(:table_exists?).with(table).and_return(true)
- allow(ActiveRecord::Base.connection).to receive(:execute).and_call_original
+ allow(connection).to receive(:table_exists?).and_call_original
+ allow(connection).to receive(:table_exists?).with(table).and_return(true)
+ allow(connection).to receive(:execute).and_call_original
stub_exclusive_lease(described_class::MANAGEMENT_LEASE_KEY % table, timeout: described_class::LEASE_TIMEOUT)
end
@@ -44,35 +36,23 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do
end
it 'creates the partition' do
- expect(ActiveRecord::Base.connection).to receive(:execute).with(partitions.first.to_sql)
- expect(ActiveRecord::Base.connection).to receive(:execute).with(partitions.second.to_sql)
+ expect(connection).to receive(:execute).with(partitions.first.to_sql)
+ expect(connection).to receive(:execute).with(partitions.second.to_sql)
sync_partitions
end
- context 'error handling with 2 models' do
- let(:models) do
- [
- double(partitioning_strategy: strategy1, table_name: table),
- double(partitioning_strategy: strategy2, table_name: table)
- ]
- end
-
- let(:strategy1) { double('strategy1', missing_partitions: nil, extra_partitions: []) }
- let(:strategy2) { double('strategy2', missing_partitions: partitions, extra_partitions: []) }
+ context 'when an error occurs during partition management' do
+ it 'does not raise an error' do
+ expect(partitioning_strategy).to receive(:missing_partitions).and_raise('this should never happen (tm)')
- it 'still creates partitions for the second table' do
- expect(strategy1).to receive(:missing_partitions).and_raise('this should never happen (tm)')
- expect(ActiveRecord::Base.connection).to receive(:execute).with(partitions.first.to_sql)
- expect(ActiveRecord::Base.connection).to receive(:execute).with(partitions.second.to_sql)
-
- sync_partitions
+ expect { sync_partitions }.not_to raise_error
end
end
end
context 'creating partitions' do
- subject(:sync_partitions) { described_class.new([my_model]).sync_partitions }
+ subject(:sync_partitions) { described_class.new(my_model).sync_partitions }
let(:connection) { ActiveRecord::Base.connection }
let(:my_model) do
@@ -101,15 +81,15 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do
context 'detaching partitions (mocked)' do
subject(:sync_partitions) { manager.sync_partitions }
- let(:manager) { described_class.new(models) }
- let(:models) { [model] }
- let(:model) { double(partitioning_strategy: partitioning_strategy, table_name: table)}
+ let(:manager) { described_class.new(model) }
+ let(:model) { double(partitioning_strategy: partitioning_strategy, table_name: table, connection: connection) }
let(:partitioning_strategy) { double(extra_partitions: extra_partitions, missing_partitions: []) }
+ let(:connection) { ActiveRecord::Base.connection }
let(:table) { "foo" }
before do
- allow(ActiveRecord::Base.connection).to receive(:table_exists?).and_call_original
- allow(ActiveRecord::Base.connection).to receive(:table_exists?).with(table).and_return(true)
+ allow(connection).to receive(:table_exists?).and_call_original
+ allow(connection).to receive(:table_exists?).with(table).and_return(true)
stub_exclusive_lease(described_class::MANAGEMENT_LEASE_KEY % table, timeout: described_class::LEASE_TIMEOUT)
end
@@ -131,24 +111,6 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do
sync_partitions
end
-
- context 'error handling' do
- let(:models) do
- [
- double(partitioning_strategy: error_strategy, table_name: table),
- model
- ]
- end
-
- let(:error_strategy) { double(extra_partitions: nil, missing_partitions: []) }
-
- it 'still drops partitions for the other model' do
- expect(error_strategy).to receive(:extra_partitions).and_raise('injected error!')
- extra_partitions.each { |p| expect(manager).to receive(:detach_one_partition).with(p) }
-
- sync_partitions
- end
- end
end
context 'with the partition_pruning feature flag disabled' do
@@ -171,7 +133,7 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do
end
end
- subject { described_class.new([my_model]).sync_partitions }
+ subject { described_class.new(my_model).sync_partitions }
let(:connection) { ActiveRecord::Base.connection }
let(:my_model) do
@@ -280,11 +242,11 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do
it 'creates partitions for the future then drops the oldest one after a month' do
# 1 month for the current month, 1 month for the old month that we're retaining data for, headroom
expected_num_partitions = (Gitlab::Database::Partitioning::MonthlyStrategy::HEADROOM + 2.months) / 1.month
- expect { described_class.new([my_model]).sync_partitions }.to change { num_partitions(my_model) }.from(0).to(expected_num_partitions)
+ expect { described_class.new(my_model).sync_partitions }.to change { num_partitions(my_model) }.from(0).to(expected_num_partitions)
travel 1.month
- expect { described_class.new([my_model]).sync_partitions }.to change { has_partition(my_model, 2.months.ago.beginning_of_month) }.from(true).to(false).and(change { num_partitions(my_model) }.by(0))
+ expect { described_class.new(my_model).sync_partitions }.to change { has_partition(my_model, 2.months.ago.beginning_of_month) }.from(true).to(false).and(change { num_partitions(my_model) }.by(0))
end
end
end
diff --git a/spec/lib/gitlab/database/partitioning_migration_helpers/foreign_key_helpers_spec.rb b/spec/lib/gitlab/database/partitioning_migration_helpers/foreign_key_helpers_spec.rb
index a524fe681e9..f0e34476cf2 100644
--- a/spec/lib/gitlab/database/partitioning_migration_helpers/foreign_key_helpers_spec.rb
+++ b/spec/lib/gitlab/database/partitioning_migration_helpers/foreign_key_helpers_spec.rb
@@ -27,6 +27,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::ForeignKeyHelpers
before do
allow(migration).to receive(:puts)
+ allow(migration).to receive(:transaction_open?).and_return(false)
connection.execute(<<~SQL)
CREATE TABLE #{target_table_name} (
@@ -141,5 +142,15 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::ForeignKeyHelpers
.with(source_table_name, target_table_name, options)
end
end
+
+ context 'when run inside a transaction block' do
+ it 'raises an error' do
+ expect(migration).to receive(:transaction_open?).and_return(true)
+
+ expect do
+ migration.add_concurrent_partitioned_foreign_key(source_table_name, target_table_name, column: column_name)
+ end.to raise_error(/can not be run inside a transaction/)
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb b/spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb
index c3edc3a0c87..8ab3816529b 100644
--- a/spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb
+++ b/spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb
@@ -20,6 +20,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::IndexHelpers do
before do
allow(migration).to receive(:puts)
+ allow(migration).to receive(:transaction_open?).and_return(false)
connection.execute(<<~SQL)
CREATE TABLE #{table_name} (
@@ -127,6 +128,16 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::IndexHelpers do
end.to raise_error(ArgumentError, /#{table_name} is not a partitioned table/)
end
end
+
+ context 'when run inside a transaction block' do
+ it 'raises an error' do
+ expect(migration).to receive(:transaction_open?).and_return(true)
+
+ expect do
+ migration.add_concurrent_partitioned_index(table_name, column_name)
+ end.to raise_error(/can not be run inside a transaction/)
+ end
+ end
end
describe '#remove_concurrent_partitioned_index_by_name' do
@@ -182,5 +193,15 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::IndexHelpers do
end.to raise_error(ArgumentError, /#{table_name} is not a partitioned table/)
end
end
+
+ context 'when run inside a transaction block' do
+ it 'raises an error' do
+ expect(migration).to receive(:transaction_open?).and_return(true)
+
+ expect do
+ migration.remove_concurrent_partitioned_index_by_name(table_name, index_name)
+ end.to raise_error(/can not be run inside a transaction/)
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/database/partitioning_spec.rb b/spec/lib/gitlab/database/partitioning_spec.rb
new file mode 100644
index 00000000000..f163b45e01e
--- /dev/null
+++ b/spec/lib/gitlab/database/partitioning_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Partitioning do
+ describe '.sync_partitions' do
+ let(:partition_manager_class) { described_class::MultiDatabasePartitionManager }
+ let(:partition_manager) { double('partition manager') }
+
+ context 'when no partitioned models are given' do
+ it 'calls the partition manager with the registered models' do
+ expect(partition_manager_class).to receive(:new)
+ .with(described_class.registered_models)
+ .and_return(partition_manager)
+
+ expect(partition_manager).to receive(:sync_partitions)
+
+ described_class.sync_partitions
+ end
+ end
+
+ context 'when partitioned models are given' do
+ it 'calls the partition manager with the given models' do
+ models = ['my special model']
+
+ expect(partition_manager_class).to receive(:new)
+ .with(models)
+ .and_return(partition_manager)
+
+ expect(partition_manager).to receive(:sync_partitions)
+
+ described_class.sync_partitions(models)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/postgresql_adapter/dump_schema_versions_mixin_spec.rb b/spec/lib/gitlab/database/postgresql_adapter/dump_schema_versions_mixin_spec.rb
index 40e36bc02e9..8b06f068503 100644
--- a/spec/lib/gitlab/database/postgresql_adapter/dump_schema_versions_mixin_spec.rb
+++ b/spec/lib/gitlab/database/postgresql_adapter/dump_schema_versions_mixin_spec.rb
@@ -26,4 +26,12 @@ RSpec.describe Gitlab::Database::PostgresqlAdapter::DumpSchemaVersionsMixin do
instance.dump_schema_information
end
+
+ it 'does not call touch_all in production' do
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production'))
+
+ expect(Gitlab::Database::SchemaMigrations).not_to receive(:touch_all)
+
+ instance.dump_schema_information
+ end
end
diff --git a/spec/lib/gitlab/database/schema_migrations/context_spec.rb b/spec/lib/gitlab/database/schema_migrations/context_spec.rb
index 1f1943d00a3..a79e6706149 100644
--- a/spec/lib/gitlab/database/schema_migrations/context_spec.rb
+++ b/spec/lib/gitlab/database/schema_migrations/context_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Gitlab::Database::SchemaMigrations::Context do
describe '#schema_directory' do
it 'returns db/schema_migrations' do
- expect(context.schema_directory).to eq(File.join(Rails.root, 'db/schema_migrations'))
+ expect(context.schema_directory).to eq(File.join(Rails.root, described_class.default_schema_migrations_path))
end
context 'CI database' do
@@ -19,7 +19,7 @@ RSpec.describe Gitlab::Database::SchemaMigrations::Context do
it 'returns a directory path that is database specific' do
skip_if_multiple_databases_not_setup
- expect(context.schema_directory).to eq(File.join(Rails.root, 'db/schema_migrations'))
+ expect(context.schema_directory).to eq(File.join(Rails.root, described_class.default_schema_migrations_path))
end
end
@@ -124,8 +124,4 @@ RSpec.describe Gitlab::Database::SchemaMigrations::Context do
end
end
end
-
- def skip_if_multiple_databases_not_setup
- skip 'Skipping because multiple databases not set up' unless Gitlab::Database.has_config?(:ci)
- end
end
diff --git a/spec/lib/gitlab/database/shared_model_spec.rb b/spec/lib/gitlab/database/shared_model_spec.rb
new file mode 100644
index 00000000000..5d616aeb05f
--- /dev/null
+++ b/spec/lib/gitlab/database/shared_model_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::SharedModel do
+ describe 'using an external connection' do
+ let!(:original_connection) { described_class.connection }
+ let(:new_connection) { double('connection') }
+
+ it 'overrides the connection for the duration of the block', :aggregate_failures do
+ expect_original_connection_around do
+ described_class.using_connection(new_connection) do
+ expect(described_class.connection).to be(new_connection)
+ end
+ end
+ end
+
+ it 'does not affect connections in other threads', :aggregate_failures do
+ expect_original_connection_around do
+ described_class.using_connection(new_connection) do
+ expect(described_class.connection).to be(new_connection)
+
+ Thread.new do
+ expect(described_class.connection).not_to be(new_connection)
+ end.join
+ end
+ end
+ end
+
+ context 'when the block raises an error', :aggregate_failures do
+ it 're-raises the error, removing the overridden connection' do
+ expect_original_connection_around do
+ expect do
+ described_class.using_connection(new_connection) do
+ expect(described_class.connection).to be(new_connection)
+
+ raise 'here comes an error!'
+ end
+ end.to raise_error(RuntimeError, 'here comes an error!')
+ end
+ end
+ end
+
+ def expect_original_connection_around
+ # For safety, ensure our original connection is distinct from our double
+ # This should be the case, but in case of something leaking we should verify
+ expect(original_connection).not_to be(new_connection)
+ expect(described_class.connection).to be(original_connection)
+
+ yield
+
+ expect(described_class.connection).to be(original_connection)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/transaction/context_spec.rb b/spec/lib/gitlab/database/transaction/context_spec.rb
index 65d52b4d099..37cfc841d48 100644
--- a/spec/lib/gitlab/database/transaction/context_spec.rb
+++ b/spec/lib/gitlab/database/transaction/context_spec.rb
@@ -62,30 +62,32 @@ RSpec.describe Gitlab::Database::Transaction::Context do
it { expect(data[:queries]).to eq(['SELECT 1', 'SELECT * FROM users']) }
end
- describe '#duration' do
+ describe '#track_backtrace' do
before do
- subject.set_start_time
+ subject.track_backtrace(caller)
end
- it { expect(subject.duration).to be >= 0 }
- end
+ it { expect(data[:backtraces]).to be_a(Array) }
+ it { expect(data[:backtraces]).to all(be_a(Array)) }
+ it { expect(data[:backtraces].length).to eq(1) }
+ it { expect(data[:backtraces][0][0]).to be_a(String) }
- context 'when depth is low' do
- it 'does not log data upon COMMIT' do
- expect(subject).not_to receive(:application_info)
+ it 'appends the backtrace' do
+ subject.track_backtrace(caller)
- subject.commit
+ expect(data[:backtraces].length).to eq(2)
+ expect(subject.backtraces).to be_a(Array)
+ expect(subject.backtraces).to all(be_a(Array))
+ expect(subject.backtraces[1][0]).to be_a(String)
end
+ end
- it 'does not log data upon ROLLBACK' do
- expect(subject).not_to receive(:application_info)
-
- subject.rollback
+ describe '#duration' do
+ before do
+ subject.set_start_time
end
- it '#should_log? returns false' do
- expect(subject.should_log?).to be false
- end
+ it { expect(subject.duration).to be >= 0 }
end
shared_examples 'logs transaction data' do
@@ -116,17 +118,9 @@ RSpec.describe Gitlab::Database::Transaction::Context do
end
end
- context 'when depth exceeds threshold' do
- before do
- subject.set_depth(described_class::LOG_DEPTH_THRESHOLD + 1)
- end
-
- it_behaves_like 'logs transaction data'
- end
-
context 'when savepoints count exceeds threshold' do
before do
- data[:savepoints] = described_class::LOG_SAVEPOINTS_THRESHOLD + 1
+ data[:savepoints] = 1
end
it_behaves_like 'logs transaction data'
diff --git a/spec/lib/gitlab/database/transaction/observer_spec.rb b/spec/lib/gitlab/database/transaction/observer_spec.rb
index 7aa24217dc3..e5cc0106c9b 100644
--- a/spec/lib/gitlab/database/transaction/observer_spec.rb
+++ b/spec/lib/gitlab/database/transaction/observer_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe Gitlab::Database::Transaction::Observer do
User.first
expect(transaction_context).to be_a(::Gitlab::Database::Transaction::Context)
- expect(context.keys).to match_array(%i(start_time depth savepoints queries))
+ expect(context.keys).to match_array(%i(start_time depth savepoints queries backtraces))
expect(context[:depth]).to eq(2)
expect(context[:savepoints]).to eq(1)
expect(context[:queries].length).to eq(1)
@@ -35,6 +35,7 @@ RSpec.describe Gitlab::Database::Transaction::Observer do
expect(context[:depth]).to eq(2)
expect(context[:savepoints]).to eq(1)
expect(context[:releases]).to eq(1)
+ expect(context[:backtraces].length).to eq(1)
end
describe '.extract_sql_command' do
diff --git a/spec/lib/gitlab/database/with_lock_retries_spec.rb b/spec/lib/gitlab/database/with_lock_retries_spec.rb
index 72074f06210..0b960830d89 100644
--- a/spec/lib/gitlab/database/with_lock_retries_spec.rb
+++ b/spec/lib/gitlab/database/with_lock_retries_spec.rb
@@ -5,7 +5,9 @@ require 'spec_helper'
RSpec.describe Gitlab::Database::WithLockRetries do
let(:env) { {} }
let(:logger) { Gitlab::Database::WithLockRetries::NULL_LOGGER }
- let(:subject) { described_class.new(env: env, logger: logger, timing_configuration: timing_configuration) }
+ let(:subject) { described_class.new(env: env, logger: logger, allow_savepoints: allow_savepoints, timing_configuration: timing_configuration) }
+ let(:allow_savepoints) { true }
+ let(:connection) { ActiveRecord::Base.connection }
let(:timing_configuration) do
[
@@ -66,7 +68,7 @@ RSpec.describe Gitlab::Database::WithLockRetries do
WHERE t.relkind = 'r' AND l.mode = 'ExclusiveLock' AND t.relname = '#{Project.table_name}'
"""
- expect(ActiveRecord::Base.connection.execute(check_exclusive_lock_query).to_a).to be_present
+ expect(connection.execute(check_exclusive_lock_query).to_a).to be_present
end
end
@@ -95,8 +97,8 @@ RSpec.describe Gitlab::Database::WithLockRetries do
lock_fiber.resume
end
- ActiveRecord::Base.transaction do
- ActiveRecord::Base.connection.execute("LOCK TABLE #{Project.table_name} in exclusive mode")
+ connection.transaction do
+ connection.execute("LOCK TABLE #{Project.table_name} in exclusive mode")
lock_acquired = true
end
end
@@ -114,7 +116,7 @@ RSpec.describe Gitlab::Database::WithLockRetries do
context 'setting the idle transaction timeout' do
context 'when there is no outer transaction: disable_ddl_transaction! is set in the migration' do
it 'does not disable the idle transaction timeout' do
- allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false)
+ allow(connection).to receive(:transaction_open?).and_return(false)
allow(subject).to receive(:run_block_with_lock_timeout).once.and_raise(ActiveRecord::LockWaitTimeout)
allow(subject).to receive(:run_block_with_lock_timeout).once
@@ -126,7 +128,7 @@ RSpec.describe Gitlab::Database::WithLockRetries do
context 'when there is outer transaction: disable_ddl_transaction! is not set in the migration' do
it 'disables the idle transaction timeout so the code can sleep and retry' do
- allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(true)
+ allow(connection).to receive(:transaction_open?).and_return(true)
n = 0
allow(subject).to receive(:run_block_with_lock_timeout).twice do
@@ -151,7 +153,7 @@ RSpec.describe Gitlab::Database::WithLockRetries do
context 'when there is no outer transaction: disable_ddl_transaction! is set in the migration' do
it 'does not disable the lock_timeout' do
- allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false)
+ allow(connection).to receive(:transaction_open?).and_return(false)
allow(subject).to receive(:run_block_with_lock_timeout).once.and_raise(ActiveRecord::LockWaitTimeout)
expect(subject).not_to receive(:disable_lock_timeout)
@@ -162,7 +164,7 @@ RSpec.describe Gitlab::Database::WithLockRetries do
context 'when there is outer transaction: disable_ddl_transaction! is not set in the migration' do
it 'disables the lock_timeout' do
- allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(true)
+ allow(connection).to receive(:transaction_open?).and_return(true)
allow(subject).to receive(:run_block_with_lock_timeout).once.and_raise(ActiveRecord::LockWaitTimeout)
expect(subject).to receive(:disable_lock_timeout)
@@ -197,8 +199,8 @@ RSpec.describe Gitlab::Database::WithLockRetries do
subject.run(raise_on_exhaustion: true) do
lock_attempts += 1
- ActiveRecord::Base.transaction do
- ActiveRecord::Base.connection.execute("LOCK TABLE #{Project.table_name} in exclusive mode")
+ connection.transaction do
+ connection.execute("LOCK TABLE #{Project.table_name} in exclusive mode")
lock_acquired = true
end
end
@@ -212,11 +214,11 @@ RSpec.describe Gitlab::Database::WithLockRetries do
context 'when statement timeout is reached' do
it 'raises QueryCanceled error' do
lock_acquired = false
- ActiveRecord::Base.connection.execute("SET LOCAL statement_timeout='100ms'")
+ connection.execute("SET LOCAL statement_timeout='100ms'")
expect do
subject.run do
- ActiveRecord::Base.connection.execute("SELECT 1 FROM pg_sleep(0.11)") # 110ms
+ connection.execute("SELECT 1 FROM pg_sleep(0.11)") # 110ms
lock_acquired = true
end
end.to raise_error(ActiveRecord::QueryCanceled)
@@ -229,11 +231,11 @@ RSpec.describe Gitlab::Database::WithLockRetries do
context 'restore local database variables' do
it do
- expect { subject.run {} }.not_to change { ActiveRecord::Base.connection.execute("SHOW lock_timeout").to_a }
+ expect { subject.run {} }.not_to change { connection.execute("SHOW lock_timeout").to_a }
end
it do
- expect { subject.run {} }.not_to change { ActiveRecord::Base.connection.execute("SHOW idle_in_transaction_session_timeout").to_a }
+ expect { subject.run {} }.not_to change { connection.execute("SHOW idle_in_transaction_session_timeout").to_a }
end
end
@@ -241,10 +243,10 @@ RSpec.describe Gitlab::Database::WithLockRetries do
let(:timing_configuration) { [[0.015.seconds, 0.025.seconds], [0.015.seconds, 0.025.seconds]] } # 15ms, 25ms
it 'executes `SET LOCAL lock_timeout` using the configured timeout value in milliseconds' do
- expect(ActiveRecord::Base.connection).to receive(:execute).with("RESET idle_in_transaction_session_timeout; RESET lock_timeout").and_call_original
- expect(ActiveRecord::Base.connection).to receive(:execute).with("SAVEPOINT active_record_1", "TRANSACTION").and_call_original
- expect(ActiveRecord::Base.connection).to receive(:execute).with("SET LOCAL lock_timeout TO '15ms'").and_call_original
- expect(ActiveRecord::Base.connection).to receive(:execute).with("RELEASE SAVEPOINT active_record_1", "TRANSACTION").and_call_original
+ expect(connection).to receive(:execute).with("RESET idle_in_transaction_session_timeout; RESET lock_timeout").and_call_original
+ expect(connection).to receive(:execute).with("SAVEPOINT active_record_1", "TRANSACTION").and_call_original
+ expect(connection).to receive(:execute).with("SET LOCAL lock_timeout TO '15ms'").and_call_original
+ expect(connection).to receive(:execute).with("RELEASE SAVEPOINT active_record_1", "TRANSACTION").and_call_original
subject.run { }
end
@@ -256,4 +258,20 @@ RSpec.describe Gitlab::Database::WithLockRetries do
subject.run { }
end
end
+
+ context 'Stop using subtransactions - allow_savepoints: false' do
+ let(:allow_savepoints) { false }
+
+ it 'prevents running inside already open transaction' do
+ allow(connection).to receive(:transaction_open?).and_return(true)
+
+ expect { subject.run { } }.to raise_error(/should not run inside already open transaction/)
+ end
+
+ it 'does not raise the error if not inside open transaction' do
+ allow(connection).to receive(:transaction_open?).and_return(false)
+
+ expect { subject.run { } }.not_to raise_error
+ end
+ end
end
diff --git a/spec/lib/gitlab/database_importers/work_items/base_type_importer_spec.rb b/spec/lib/gitlab/database_importers/work_items/base_type_importer_spec.rb
new file mode 100644
index 00000000000..8c3d372cc55
--- /dev/null
+++ b/spec/lib/gitlab/database_importers/work_items/base_type_importer_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter do
+ subject { described_class.import }
+
+ it_behaves_like 'work item base types importer'
+end
diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb
index c67b5af5e3c..a9a8d5e6314 100644
--- a/spec/lib/gitlab/database_spec.rb
+++ b/spec/lib/gitlab/database_spec.rb
@@ -15,6 +15,22 @@ RSpec.describe Gitlab::Database do
end
end
+ describe '.default_pool_size' do
+ before do
+ allow(Gitlab::Runtime).to receive(:max_threads).and_return(7)
+ end
+
+ it 'returns the max thread size plus a fixed headroom of 10' do
+ expect(described_class.default_pool_size).to eq(17)
+ end
+
+ it 'returns the max thread size plus a DB_POOL_HEADROOM if this env var is present' do
+ stub_env('DB_POOL_HEADROOM', '7')
+
+ expect(described_class.default_pool_size).to eq(14)
+ end
+ end
+
describe '.has_config?' do
context 'two tier database config' do
before do
@@ -139,23 +155,43 @@ RSpec.describe Gitlab::Database do
it { expect(described_class.nulls_first_order('column', 'DESC')).to eq 'column DESC NULLS FIRST'}
end
- describe '.db_config_name' do
- it 'returns the db_config name for the connection' do
- connection = ActiveRecord::Base.connection
+ describe '.db_config_for_connection' do
+ context 'when the regular connection is used' do
+ it 'returns db_config' do
+ connection = ActiveRecord::Base.retrieve_connection
- expect(described_class.db_config_name(connection)).to be_a(String)
- expect(described_class.db_config_name(connection)).to eq(connection.pool.db_config.name)
+ expect(described_class.db_config_for_connection(connection)).to eq(connection.pool.db_config)
+ end
+ end
+
+ context 'when the connection is LoadBalancing::ConnectionProxy' do
+ it 'returns nil' do
+ lb_config = ::Gitlab::Database::LoadBalancing::Configuration.new(ActiveRecord::Base)
+ lb = ::Gitlab::Database::LoadBalancing::LoadBalancer.new(lb_config)
+ proxy = ::Gitlab::Database::LoadBalancing::ConnectionProxy.new(lb)
+
+ expect(described_class.db_config_for_connection(proxy)).to be_nil
+ end
end
context 'when the pool is a NullPool' do
- it 'returns unknown' do
+ it 'returns nil' do
connection = double(:active_record_connection, pool: ActiveRecord::ConnectionAdapters::NullPool.new)
- expect(described_class.db_config_name(connection)).to eq('unknown')
+ expect(described_class.db_config_for_connection(connection)).to be_nil
end
end
end
+ describe '.db_config_name' do
+ it 'returns the db_config name for the connection' do
+ connection = ActiveRecord::Base.connection
+
+ expect(described_class.db_config_name(connection)).to be_a(String)
+ expect(described_class.db_config_name(connection)).to eq(connection.pool.db_config.name)
+ end
+ end
+
describe '#true_value' do
it 'returns correct value' do
expect(described_class.true_value).to eq "'t'"
diff --git a/spec/lib/gitlab/devise_failure_spec.rb b/spec/lib/gitlab/devise_failure_spec.rb
deleted file mode 100644
index a452de59795..00000000000
--- a/spec/lib/gitlab/devise_failure_spec.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::DeviseFailure do
- let(:env) do
- {
- 'REQUEST_URI' => 'http://test.host/',
- 'HTTP_HOST' => 'test.host',
- 'REQUEST_METHOD' => 'GET',
- 'warden.options' => { scope: :user },
- 'rack.session' => {},
- 'rack.session.options' => {},
- 'rack.input' => "",
- 'warden' => OpenStruct.new(message: nil)
- }
- end
-
- let(:response) { described_class.call(env).to_a }
- let(:request) { ActionDispatch::Request.new(env) }
-
- context 'When redirecting' do
- it 'sets the expire_after key' do
- response
-
- expect(env['rack.session.options']).to have_key(:expire_after)
- end
-
- it 'returns to the default redirect location' do
- expect(response.first).to eq(302)
- expect(request.flash[:alert]).to eq('You need to sign in or sign up before continuing.')
- expect(response.second['Location']).to eq('http://test.host/users/sign_in')
- end
- end
-end
diff --git a/spec/lib/gitlab/diff/highlight_cache_spec.rb b/spec/lib/gitlab/diff/highlight_cache_spec.rb
index 9e94a63ea4b..e643b58ee32 100644
--- a/spec/lib/gitlab/diff/highlight_cache_spec.rb
+++ b/spec/lib/gitlab/diff/highlight_cache_spec.rb
@@ -185,6 +185,15 @@ RSpec.describe Gitlab::Diff::HighlightCache, :clean_gitlab_redis_cache do
expect { cache.send(:write_to_redis_hash, diff_hash) }
.to change { Gitlab::Redis::Cache.with { |r| r.hgetall(cache_key) } }
end
+
+ context 'when diff contains unsupported characters' do
+ let(:diff_hash) { { 'README' => [{ line_code: nil, rich_text: nil, text: [0xff, 0xfe, 0x0, 0x23].pack("c*"), type: "match", index: 0, old_pos: 17, new_pos: 17 }] } }
+
+ it 'does not update the cache' do
+ expect { cache.send(:write_to_redis_hash, diff_hash) }
+ .not_to change { Gitlab::Redis::Cache.with { |r| r.hgetall(cache_key) } }
+ end
+ end
end
describe '#clear' do
diff --git a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb
index 2ef3b324db8..2916e65528f 100644
--- a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb
@@ -353,13 +353,4 @@ RSpec.describe Gitlab::Email::Handler::ServiceDeskHandler do
expect { receiver.execute rescue nil }.not_to change { Issue.count }
end
end
-
- def email_fixture(path)
- fixture_file(path).gsub('project_id', project.project_id.to_s)
- end
-
- def service_desk_fixture(path, slug: nil, key: 'mykey')
- slug ||= project.full_path_slug.to_s
- fixture_file(path).gsub('project_slug', slug).gsub('project_key', key)
- end
end
diff --git a/spec/lib/gitlab/encoding_helper_spec.rb b/spec/lib/gitlab/encoding_helper_spec.rb
index 268ac5dcc21..98170ef437c 100644
--- a/spec/lib/gitlab/encoding_helper_spec.rb
+++ b/spec/lib/gitlab/encoding_helper_spec.rb
@@ -241,7 +241,7 @@ RSpec.describe Gitlab::EncodingHelper do
let(:data) { binary_string }
let(:kwargs) { {} }
- shared_examples 'detects encoding' do
+ context 'detects encoding' do
it { is_expected.to be_a(Hash) }
it 'correctly detects the binary' do
@@ -264,33 +264,5 @@ RSpec.describe Gitlab::EncodingHelper do
end
end
end
-
- context 'cached_encoding_detection is enabled' do
- before do
- stub_feature_flags(cached_encoding_detection: true)
- end
-
- it_behaves_like 'detects encoding'
-
- context 'cache_key is provided' do
- let(:kwargs) do
- { cache_key: %w(foo bar) }
- end
-
- it 'uses that cache_key to serve from the cache' do
- expect(Rails.cache).to receive(:fetch).with([:detect_binary, CharlockHolmes::VERSION, %w(foo bar)], expires_in: 1.week).and_call_original
-
- expect(subject[:type]).to eq(:binary)
- end
- end
- end
-
- context 'cached_encoding_detection is disabled' do
- before do
- stub_feature_flags(cached_encoding_detection: false)
- end
-
- it_behaves_like 'detects encoding'
- end
end
end
diff --git a/spec/lib/gitlab/experimentation/controller_concern_spec.rb b/spec/lib/gitlab/experimentation/controller_concern_spec.rb
index 8535d72a61f..1f7b7b90467 100644
--- a/spec/lib/gitlab/experimentation/controller_concern_spec.rb
+++ b/spec/lib/gitlab/experimentation/controller_concern_spec.rb
@@ -7,10 +7,6 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
before do
stub_const('Gitlab::Experimentation::EXPERIMENTS', {
- backwards_compatible_test_experiment: {
- tracking_category: 'Team',
- use_backwards_compatible_subject_index: true
- },
test_experiment: {
tracking_category: 'Team',
rollout_strategy: rollout_strategy
@@ -23,7 +19,6 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(is_gitlab_com)
- Feature.enable_percentage_of_time(:backwards_compatible_test_experiment_experiment_percentage, enabled_percentage)
Feature.enable_percentage_of_time(:test_experiment_experiment_percentage, enabled_percentage)
end
@@ -124,24 +119,15 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
end
context 'cookie is present' do
- using RSpec::Parameterized::TableSyntax
-
before do
cookies.permanent.signed[:experimentation_subject_id] = 'abcd-1234'
get :index
end
- where(:experiment_key, :index_value) do
- :test_experiment | 'abcd-1234'
- :backwards_compatible_test_experiment | 'abcd1234'
- end
-
- with_them do
- it 'calls Gitlab::Experimentation.in_experiment_group?? with the name of the experiment and the calculated experimentation_subject_index based on the uuid' do
- expect(Gitlab::Experimentation).to receive(:in_experiment_group?).with(experiment_key, subject: index_value)
+ it 'calls Gitlab::Experimentation.in_experiment_group? with the name of the experiment and the calculated experimentation_subject_index based on the uuid' do
+ expect(Gitlab::Experimentation).to receive(:in_experiment_group?).with(:test_experiment, subject: 'abcd-1234')
- check_experiment(experiment_key)
- end
+ check_experiment(:test_experiment)
end
context 'when subject is given' do
diff --git a/spec/lib/gitlab/experimentation/experiment_spec.rb b/spec/lib/gitlab/experimentation/experiment_spec.rb
index 94dbf1d7e4b..d52ab3a8983 100644
--- a/spec/lib/gitlab/experimentation/experiment_spec.rb
+++ b/spec/lib/gitlab/experimentation/experiment_spec.rb
@@ -9,7 +9,6 @@ RSpec.describe Gitlab::Experimentation::Experiment do
let(:params) do
{
tracking_category: 'Category1',
- use_backwards_compatible_subject_index: true,
rollout_strategy: nil
}
end
diff --git a/spec/lib/gitlab/experimentation_spec.rb b/spec/lib/gitlab/experimentation_spec.rb
index c486538a260..c482874b725 100644
--- a/spec/lib/gitlab/experimentation_spec.rb
+++ b/spec/lib/gitlab/experimentation_spec.rb
@@ -7,10 +7,6 @@ RSpec.describe Gitlab::Experimentation do
before do
stub_const('Gitlab::Experimentation::EXPERIMENTS', {
- backwards_compatible_test_experiment: {
- tracking_category: 'Team',
- use_backwards_compatible_subject_index: true
- },
test_experiment: {
tracking_category: 'Team'
},
@@ -22,7 +18,6 @@ RSpec.describe Gitlab::Experimentation do
skip_feature_flags_yaml_validation
skip_default_enabled_yaml_check
- Feature.enable_percentage_of_time(:backwards_compatible_test_experiment_experiment_percentage, enabled_percentage)
Feature.enable_percentage_of_time(:test_experiment_experiment_percentage, enabled_percentage)
allow(Gitlab).to receive(:com?).and_return(true)
end
@@ -65,97 +60,47 @@ RSpec.describe Gitlab::Experimentation do
end
describe '.in_experiment_group?' do
- context 'with new index calculation' do
- let(:enabled_percentage) { 50 }
- let(:experiment_subject) { 'z' } # Zlib.crc32('test_experimentz') % 100 = 33
-
- subject { described_class.in_experiment_group?(:test_experiment, subject: experiment_subject) }
-
- context 'when experiment is active' do
- context 'when subject is part of the experiment' do
- it { is_expected.to eq(true) }
- end
+ let(:enabled_percentage) { 50 }
+ let(:experiment_subject) { 'z' } # Zlib.crc32('test_experimentz') % 100 = 33
- context 'when subject is not part of the experiment' do
- let(:experiment_subject) { 'a' } # Zlib.crc32('test_experimenta') % 100 = 61
+ subject { described_class.in_experiment_group?(:test_experiment, subject: experiment_subject) }
- it { is_expected.to eq(false) }
- end
+ context 'when experiment is active' do
+ context 'when subject is part of the experiment' do
+ it { is_expected.to eq(true) }
+ end
- context 'when subject has a global_id' do
- let(:experiment_subject) { double(:subject, to_global_id: 'z') }
+ context 'when subject is not part of the experiment' do
+ let(:experiment_subject) { 'a' } # Zlib.crc32('test_experimenta') % 100 = 61
- it { is_expected.to eq(true) }
- end
+ it { is_expected.to eq(false) }
+ end
- context 'when subject is nil' do
- let(:experiment_subject) { nil }
+ context 'when subject has a global_id' do
+ let(:experiment_subject) { double(:subject, to_global_id: 'z') }
- it { is_expected.to eq(false) }
- end
+ it { is_expected.to eq(true) }
+ end
- context 'when subject is an empty string' do
- let(:experiment_subject) { '' }
+ context 'when subject is nil' do
+ let(:experiment_subject) { nil }
- it { is_expected.to eq(false) }
- end
+ it { is_expected.to eq(false) }
end
- context 'when experiment is not active' do
- before do
- allow(described_class).to receive(:active?).and_return(false)
- end
+ context 'when subject is an empty string' do
+ let(:experiment_subject) { '' }
it { is_expected.to eq(false) }
end
end
- context 'with backwards compatible index calculation' do
- let(:experiment_subject) { 'abcd' } # Digest::SHA1.hexdigest('abcd').hex % 100 = 7
-
- subject { described_class.in_experiment_group?(:backwards_compatible_test_experiment, subject: experiment_subject) }
-
- context 'when experiment is active' do
- before do
- allow(described_class).to receive(:active?).and_return(true)
- end
-
- context 'when subject is part of the experiment' do
- it { is_expected.to eq(true) }
- end
-
- context 'when subject is not part of the experiment' do
- let(:experiment_subject) { 'abc' } # Digest::SHA1.hexdigest('abc').hex % 100 = 17
-
- it { is_expected.to eq(false) }
- end
-
- context 'when subject has a global_id' do
- let(:experiment_subject) { double(:subject, to_global_id: 'abcd') }
-
- it { is_expected.to eq(true) }
- end
-
- context 'when subject is nil' do
- let(:experiment_subject) { nil }
-
- it { is_expected.to eq(false) }
- end
-
- context 'when subject is an empty string' do
- let(:experiment_subject) { '' }
-
- it { is_expected.to eq(false) }
- end
+ context 'when experiment is not active' do
+ before do
+ allow(described_class).to receive(:active?).and_return(false)
end
- context 'when experiment is not active' do
- before do
- allow(described_class).to receive(:active?).and_return(false)
- end
-
- it { is_expected.to eq(false) }
- end
+ it { is_expected.to eq(false) }
end
end
diff --git a/spec/lib/gitlab/git/commit_spec.rb b/spec/lib/gitlab/git/commit_spec.rb
index f58bab52cfa..f4dba5e8d58 100644
--- a/spec/lib/gitlab/git/commit_spec.rb
+++ b/spec/lib/gitlab/git/commit_spec.rb
@@ -364,19 +364,39 @@ RSpec.describe Gitlab::Git::Commit, :seed_helper do
end
describe '.between' do
- subject do
- commits = described_class.between(repository, SeedRepo::Commit::PARENT_ID, SeedRepo::Commit::ID)
- commits.map { |c| c.id }
+ let(:limit) { nil }
+ let(:commit_ids) { commits.map(&:id) }
+
+ subject(:commits) { described_class.between(repository, from, to, limit: limit) }
+
+ context 'requesting a single commit' do
+ let(:from) { SeedRepo::Commit::PARENT_ID }
+ let(:to) { SeedRepo::Commit::ID }
+
+ it { expect(commit_ids).to contain_exactly(to) }
end
- it { is_expected.to contain_exactly(SeedRepo::Commit::ID) }
+ context 'requesting a commit range' do
+ let(:from) { 'v1.0.0' }
+ let(:to) { 'v1.2.0' }
- context 'between_uses_list_commits FF disabled' do
- before do
- stub_feature_flags(between_uses_list_commits: false)
+ let(:commits_in_range) do
+ %w[
+ 570e7b2abdd848b95f2f578043fc23bd6f6fd24d
+ 5937ac0a7beb003549fc5fd26fc247adbce4a52e
+ eb49186cfa5c4338011f5f590fac11bd66c5c631
+ ]
end
- it { is_expected.to contain_exactly(SeedRepo::Commit::ID) }
+ context 'no limit' do
+ it { expect(commit_ids).to eq(commits_in_range) }
+ end
+
+ context 'limited' do
+ let(:limit) { 2 }
+
+ it { expect(commit_ids).to eq(commits_in_range.last(2)) }
+ end
end
end
diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb
index 29e7a1dce1d..9ecd281cce0 100644
--- a/spec/lib/gitlab/git/repository_spec.rb
+++ b/spec/lib/gitlab/git/repository_spec.rb
@@ -109,6 +109,32 @@ RSpec.describe Gitlab::Git::Repository, :seed_helper do
it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::RefService, :tag_names
end
+ describe '#tags' do
+ subject { repository.tags }
+
+ it 'gets tags from GitalyClient' do
+ expect_next_instance_of(Gitlab::GitalyClient::RefService) do |service|
+ expect(service).to receive(:tags)
+ end
+
+ subject
+ end
+
+ context 'with sorting option' do
+ subject { repository.tags(sort_by: 'name_asc') }
+
+ it 'gets tags from GitalyClient' do
+ expect_next_instance_of(Gitlab::GitalyClient::RefService) do |service|
+ expect(service).to receive(:tags).with(sort_by: 'name_asc')
+ end
+
+ subject
+ end
+ end
+
+ it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::RefService, :tags
+ end
+
describe '#archive_metadata' do
let(:storage_path) { '/tmp' }
let(:cache_key) { File.join(repository.gl_repository, SeedRepo::LastCommit::ID) }
@@ -936,6 +962,159 @@ RSpec.describe Gitlab::Git::Repository, :seed_helper do
end
end
+ describe '#new_blobs' do
+ let(:repository) { mutable_repository }
+ let(:repository_rugged) { mutable_repository_rugged }
+ let(:blob) { create_blob('This is a new blob') }
+ let(:commit) { create_commit('nested/new-blob.txt' => blob) }
+
+ def create_blob(content)
+ repository_rugged.write(content, :blob)
+ end
+
+ def create_commit(blobs)
+ author = { name: 'Test User', email: 'mail@example.com', time: Time.now }
+
+ index = repository_rugged.index
+ blobs.each do |path, oid|
+ index.add(path: path, oid: oid, mode: 0100644)
+ end
+
+ Rugged::Commit.create(repository_rugged,
+ author: author,
+ committer: author,
+ message: "Message",
+ parents: [],
+ tree: index.write_tree(repository_rugged))
+ end
+
+ subject { repository.new_blobs(newrevs).to_a }
+
+ shared_examples '#new_blobs with revisions' do
+ before do
+ expect_next_instance_of(Gitlab::GitalyClient::BlobService) do |service|
+ expect(service)
+ .to receive(:list_blobs)
+ .with(expected_newrevs,
+ limit: Gitlab::Git::Repository::REV_LIST_COMMIT_LIMIT,
+ with_paths: true,
+ dynamic_timeout: nil)
+ .once
+ .and_call_original
+ end
+ end
+
+ it 'enumerates new blobs' do
+ expect(subject).to match_array(expected_blobs)
+ end
+
+ it 'memoizes results' do
+ expect(subject).to match_array(expected_blobs)
+ expect(subject).to match_array(expected_blobs)
+ end
+ end
+
+ context 'with a single revision' do
+ let(:newrevs) { commit }
+ let(:expected_newrevs) { ['--not', '--all', '--not', newrevs] }
+ let(:expected_blobs) do
+ [have_attributes(class: Gitlab::Git::Blob, id: blob, path: 'nested/new-blob.txt', size: 18)]
+ end
+
+ it_behaves_like '#new_blobs with revisions'
+ end
+
+ context 'with a single-entry array' do
+ let(:newrevs) { [commit] }
+ let(:expected_newrevs) { ['--not', '--all', '--not'] + newrevs }
+ let(:expected_blobs) do
+ [have_attributes(class: Gitlab::Git::Blob, id: blob, path: 'nested/new-blob.txt', size: 18)]
+ end
+
+ it_behaves_like '#new_blobs with revisions'
+ end
+
+ context 'with multiple revisions' do
+ let(:another_blob) { create_blob('Another blob') }
+ let(:newrevs) { [commit, create_commit('another_path.txt' => another_blob)] }
+ let(:expected_newrevs) { ['--not', '--all', '--not'] + newrevs.sort }
+ let(:expected_blobs) do
+ [
+ have_attributes(class: Gitlab::Git::Blob, id: blob, path: 'nested/new-blob.txt', size: 18),
+ have_attributes(class: Gitlab::Git::Blob, id: another_blob, path: 'another_path.txt', size: 12)
+ ]
+ end
+
+ it_behaves_like '#new_blobs with revisions'
+ end
+
+ context 'with partially blank revisions' do
+ let(:newrevs) { [nil, commit, Gitlab::Git::BLANK_SHA] }
+ let(:expected_newrevs) { ['--not', '--all', '--not', commit] }
+ let(:expected_blobs) do
+ [
+ have_attributes(class: Gitlab::Git::Blob, id: blob, path: 'nested/new-blob.txt', size: 18)
+ ]
+ end
+
+ it_behaves_like '#new_blobs with revisions'
+ end
+
+ context 'with repeated revisions' do
+ let(:newrevs) { [commit, commit, commit] }
+ let(:expected_newrevs) { ['--not', '--all', '--not', commit] }
+ let(:expected_blobs) do
+ [
+ have_attributes(class: Gitlab::Git::Blob, id: blob, path: 'nested/new-blob.txt', size: 18)
+ ]
+ end
+
+ it_behaves_like '#new_blobs with revisions'
+ end
+
+ context 'with preexisting commits' do
+ let(:newrevs) { ['refs/heads/master'] }
+ let(:expected_newrevs) { ['--not', '--all', '--not'] + newrevs }
+ let(:expected_blobs) { [] }
+
+ it_behaves_like '#new_blobs with revisions'
+ end
+
+ shared_examples '#new_blobs without revisions' do
+ before do
+ expect(Gitlab::GitalyClient::BlobService).not_to receive(:new)
+ end
+
+ it 'returns an empty array' do
+ expect(subject).to eq([])
+ end
+ end
+
+ context 'with a single nil newrev' do
+ let(:newrevs) { nil }
+
+ it_behaves_like '#new_blobs without revisions'
+ end
+
+ context 'with a single zero newrev' do
+ let(:newrevs) { Gitlab::Git::BLANK_SHA }
+
+ it_behaves_like '#new_blobs without revisions'
+ end
+
+ context 'with an empty array' do
+ let(:newrevs) { [] }
+
+ it_behaves_like '#new_blobs without revisions'
+ end
+
+ context 'with array containing only empty refs' do
+ let(:newrevs) { [nil, Gitlab::Git::BLANK_SHA] }
+
+ it_behaves_like '#new_blobs without revisions'
+ end
+ end
+
describe '#new_commits' do
let(:repository) { mutable_repository }
let(:new_commit) do
@@ -1132,28 +1311,6 @@ RSpec.describe Gitlab::Git::Repository, :seed_helper do
end
end
- describe '#ref_name_for_sha' do
- let(:ref_path) { 'refs/heads' }
- let(:sha) { repository.find_branch('master').dereferenced_target.id }
- let(:ref_name) { 'refs/heads/master' }
-
- it 'returns the ref name for the given sha' do
- expect(repository.ref_name_for_sha(ref_path, sha)).to eq(ref_name)
- end
-
- it "returns an empty name if the ref doesn't exist" do
- expect(repository.ref_name_for_sha(ref_path, "000000")).to eq("")
- end
-
- it "raise an exception if the ref is empty" do
- expect { repository.ref_name_for_sha(ref_path, "") }.to raise_error(ArgumentError)
- end
-
- it "raise an exception if the ref is nil" do
- expect { repository.ref_name_for_sha(ref_path, nil) }.to raise_error(ArgumentError)
- end
- end
-
describe '#branches' do
subject { repository.branches }
@@ -1732,83 +1889,42 @@ RSpec.describe Gitlab::Git::Repository, :seed_helper do
end
describe '#set_full_path' do
- shared_examples '#set_full_path' do
- before do
- repository_rugged.config["gitlab.fullpath"] = repository_path
- end
-
- context 'is given a path' do
- it 'writes it to disk' do
- repository.set_full_path(full_path: "not-the/real-path.git")
-
- config = File.read(File.join(repository_path, "config"))
-
- expect(config).to include("[gitlab]")
- expect(config).to include("fullpath = not-the/real-path.git")
- end
- end
-
- context 'it is given an empty path' do
- it 'does not write it to disk' do
- repository.set_full_path(full_path: "")
-
- config = File.read(File.join(repository_path, "config"))
-
- expect(config).to include("[gitlab]")
- expect(config).to include("fullpath = #{repository_path}")
- end
- end
+ before do
+ repository_rugged.config["gitlab.fullpath"] = repository_path
+ end
- context 'repository does not exist' do
- it 'raises NoRepository and does not call Gitaly WriteConfig' do
- repository = Gitlab::Git::Repository.new('default', 'does/not/exist.git', '', 'group/project')
+ context 'is given a path' do
+ it 'writes it to disk' do
+ repository.set_full_path(full_path: "not-the/real-path.git")
- expect(repository.gitaly_repository_client).not_to receive(:set_full_path)
+ config = File.read(File.join(repository_path, "config"))
- expect do
- repository.set_full_path(full_path: 'foo/bar.git')
- end.to raise_error(Gitlab::Git::Repository::NoRepository)
- end
+ expect(config).to include("[gitlab]")
+ expect(config).to include("fullpath = not-the/real-path.git")
end
end
- context 'with :set_full_path enabled' do
- before do
- stub_feature_flags(set_full_path: true)
- end
+ context 'it is given an empty path' do
+ it 'does not write it to disk' do
+ repository.set_full_path(full_path: "")
- it_behaves_like '#set_full_path'
- end
+ config = File.read(File.join(repository_path, "config"))
- context 'with :set_full_path disabled' do
- before do
- stub_feature_flags(set_full_path: false)
+ expect(config).to include("[gitlab]")
+ expect(config).to include("fullpath = #{repository_path}")
end
-
- it_behaves_like '#set_full_path'
end
- end
- describe '#set_config' do
- let(:repository) { mutable_repository }
- let(:entries) do
- {
- 'test.foo1' => 'bla bla',
- 'test.foo2' => 1234,
- 'test.foo3' => true
- }
- end
+ context 'repository does not exist' do
+ it 'raises NoRepository and does not call Gitaly WriteConfig' do
+ repository = Gitlab::Git::Repository.new('default', 'does/not/exist.git', '', 'group/project')
- it 'can set config settings' do
- expect(repository.set_config(entries)).to be_nil
+ expect(repository.gitaly_repository_client).not_to receive(:set_full_path)
- expect(repository_rugged.config['test.foo1']).to eq('bla bla')
- expect(repository_rugged.config['test.foo2']).to eq('1234')
- expect(repository_rugged.config['test.foo3']).to eq('true')
- end
-
- after do
- entries.keys.each { |k| repository_rugged.config.delete(k) }
+ expect do
+ repository.set_full_path(full_path: 'foo/bar.git')
+ end.to raise_error(Gitlab::Git::Repository::NoRepository)
+ end
end
end
diff --git a/spec/lib/gitlab/git/tag_spec.rb b/spec/lib/gitlab/git/tag_spec.rb
index 79ae47f8a7b..4f56595d7d2 100644
--- a/spec/lib/gitlab/git/tag_spec.rb
+++ b/spec/lib/gitlab/git/tag_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe Gitlab::Git::Tag, :seed_helper do
it { expect(tag.tagger.timezone).to eq("+0200") }
end
- shared_examples 'signed tag' do
+ describe 'signed tag' do
let(:project) { create(:project, :repository) }
let(:tag) { project.repository.find_tag('v1.1.1') }
@@ -54,18 +54,6 @@ RSpec.describe Gitlab::Git::Tag, :seed_helper do
it { expect(tag.tagger.timezone).to eq("+0100") }
end
- context 'with :get_tag_signatures enabled' do
- it_behaves_like 'signed tag'
- end
-
- context 'with :get_tag_signatures disabled' do
- before do
- stub_feature_flags(get_tag_signatures: false)
- end
-
- it_behaves_like 'signed tag'
- end
-
it { expect(repository.tags.size).to eq(SeedRepo::Repo::TAGS.size) }
end
diff --git a/spec/lib/gitlab/git/tree_spec.rb b/spec/lib/gitlab/git/tree_spec.rb
index f11d84bd8d3..005f8ecaa3a 100644
--- a/spec/lib/gitlab/git/tree_spec.rb
+++ b/spec/lib/gitlab/git/tree_spec.rb
@@ -189,12 +189,109 @@ RSpec.describe Gitlab::Git::Tree, :seed_helper do
end
it_behaves_like :repo do
- context 'with pagination parameters' do
- let(:pagination_params) { { limit: 3, page_token: nil } }
+ describe 'Pagination' do
+ context 'with restrictive limit' do
+ let(:pagination_params) { { limit: 3, page_token: nil } }
+
+ it 'returns limited paginated list of tree objects' do
+ expect(entries.count).to eq(3)
+ expect(cursor.next_cursor).to be_present
+ end
+ end
+
+ context 'when limit is equal to number of entries' do
+ let(:entries_count) { entries.count }
+
+ it 'returns all entries without a cursor' do
+ result, cursor = Gitlab::Git::Tree.where(repository, sha, path, recursive, { limit: entries_count, page_token: nil })
+
+ expect(cursor).to be_nil
+ expect(result.entries.count).to eq(entries_count)
+ end
+ end
+
+ context 'when limit is 0' do
+ let(:pagination_params) { { limit: 0, page_token: nil } }
+
+ it 'returns empty result' do
+ expect(entries).to eq([])
+ expect(cursor).to be_nil
+ end
+ end
+
+ context 'when limit is missing' do
+ let(:pagination_params) { { limit: nil, page_token: nil } }
+
+ it 'returns empty result' do
+ expect(entries).to eq([])
+ expect(cursor).to be_nil
+ end
+ end
+
+ context 'when limit is negative' do
+ let(:entries_count) { entries.count }
+
+ it 'returns all entries' do
+ result, cursor = Gitlab::Git::Tree.where(repository, sha, path, recursive, { limit: -1, page_token: nil })
+
+ expect(result.count).to eq(entries_count)
+ expect(cursor).to be_nil
+ end
+
+ context 'when token is provided' do
+ let(:pagination_params) { { limit: 1000, page_token: nil } }
+ let(:token) { entries.second.id }
+
+ it 'returns all entries after token' do
+ result, cursor = Gitlab::Git::Tree.where(repository, sha, path, recursive, { limit: -1, page_token: token })
+
+ expect(result.count).to eq(entries.count - 2)
+ expect(cursor).to be_nil
+ end
+ end
+ end
+
+ context 'when token does not exist' do
+ let(:pagination_params) { { limit: 5, page_token: 'aabbccdd' } }
+
+ it 'raises a command error' do
+ expect { entries }.to raise_error(Gitlab::Git::CommandError, 'could not find starting OID: aabbccdd')
+ end
+ end
+
+ context 'when limit is bigger than number of entries' do
+ let(:pagination_params) { { limit: 1000, page_token: nil } }
+
+ it 'returns only available entries' do
+ expect(entries.count).to be < 20
+ expect(cursor).to be_nil
+ end
+ end
+
+ it 'returns all tree entries in specific order during cursor pagination' do
+ collected_entries = []
+ token = nil
+
+ expected_entries = entries
+
+ loop do
+ result, cursor = Gitlab::Git::Tree.where(repository, sha, path, recursive, { limit: 5, page_token: token })
+
+ collected_entries += result.entries
+ token = cursor&.next_cursor
+
+ break if token.blank?
+ end
+
+ expect(collected_entries.map(&:path)).to match_array(expected_entries.map(&:path))
+
+ expected_order = [
+ collected_entries.select(&:dir?).map(&:path),
+ collected_entries.select(&:file?).map(&:path),
+ collected_entries.select(&:submodule?).map(&:path)
+ ].flatten
- it 'does not support pagination' do
- expect(entries.count).to be >= 10
- expect(cursor).to be_nil
+ expect(collected_entries.map(&:path)).to eq(expected_order)
end
end
end
diff --git a/spec/lib/gitlab/gitaly_client/blob_service_spec.rb b/spec/lib/gitlab/gitaly_client/blob_service_spec.rb
index 50078d8c127..f869c66337e 100644
--- a/spec/lib/gitlab/gitaly_client/blob_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/blob_service_spec.rb
@@ -92,13 +92,14 @@ RSpec.describe Gitlab::GitalyClient::BlobService do
describe '#list_blobs' do
let(:limit) { 0 }
let(:bytes_limit) { 0 }
- let(:expected_params) { { revisions: revisions, limit: limit, bytes_limit: bytes_limit } }
+ let(:with_paths) { false }
+ let(:expected_params) { { revisions: revisions, limit: limit, bytes_limit: bytes_limit, with_paths: with_paths } }
before do
::Gitlab::GitalyClient.clear_stubs!
end
- subject { client.list_blobs(revisions, limit: limit, bytes_limit: bytes_limit) }
+ subject { client.list_blobs(revisions, limit: limit, bytes_limit: bytes_limit, with_paths: with_paths) }
context 'with a single revision' do
let(:revisions) { ['master'] }
@@ -147,6 +148,24 @@ RSpec.describe Gitlab::GitalyClient::BlobService do
end
end
+ context 'with paths' do
+ let(:revisions) { ['master'] }
+ let(:limit) { 10 }
+ let(:bytes_lmit) { 1024 }
+ let(:with_paths) { true }
+
+ it 'sends a list_blobs message' do
+ expect_next_instance_of(Gitaly::BlobService::Stub) do |service|
+ expect(service)
+ .to receive(:list_blobs)
+ .with(gitaly_request_with_params(expected_params), kind_of(Hash))
+ .and_return([])
+ end
+
+ subject
+ end
+ end
+
context 'with split contents' do
let(:revisions) { ['master'] }
diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
index a0e2d43cf45..554a91f2bc5 100644
--- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
@@ -311,6 +311,10 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
end
describe '#list_commits' do
+ let(:revisions) { 'master' }
+ let(:reverse) { false }
+ let(:pagination_params) { nil }
+
shared_examples 'a ListCommits request' do
before do
::Gitlab::GitalyClient.clear_stubs!
@@ -318,26 +322,35 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
it 'sends a list_commits message' do
expect_next_instance_of(Gitaly::CommitService::Stub) do |service|
- expect(service)
- .to receive(:list_commits)
- .with(gitaly_request_with_params(expected_params), kind_of(Hash))
- .and_return([])
+ expected_request = gitaly_request_with_params(
+ Array.wrap(revisions),
+ reverse: reverse,
+ pagination_params: pagination_params
+ )
+
+ expect(service).to receive(:list_commits).with(expected_request, kind_of(Hash)).and_return([])
end
- client.list_commits(revisions)
+ client.list_commits(revisions, reverse: reverse, pagination_params: pagination_params)
end
end
- context 'with a single revision' do
- let(:revisions) { 'master' }
- let(:expected_params) { %w[master] }
+ it_behaves_like 'a ListCommits request'
+
+ context 'with multiple revisions' do
+ let(:revisions) { %w[master --not --all] }
+
+ it_behaves_like 'a ListCommits request'
+ end
+
+ context 'with reverse: true' do
+ let(:reverse) { true }
it_behaves_like 'a ListCommits request'
end
- context 'with multiple revisions' do
- let(:revisions) { %w[master --not --all] }
- let(:expected_params) { %w[master --not --all] }
+ context 'with pagination params' do
+ let(:pagination_params) { { limit: 1, page_token: 'foo' } }
it_behaves_like 'a ListCommits request'
end
diff --git a/spec/lib/gitlab/gitaly_client/ref_service_spec.rb b/spec/lib/gitlab/gitaly_client/ref_service_spec.rb
index e19be965e68..d308612ef31 100644
--- a/spec/lib/gitlab/gitaly_client/ref_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/ref_service_spec.rb
@@ -92,6 +92,36 @@ RSpec.describe Gitlab::GitalyClient::RefService do
end
end
+ describe '#find_branch' do
+ it 'sends a find_branch message' do
+ expect_any_instance_of(Gitaly::RefService::Stub)
+ .to receive(:find_branch)
+ .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+ .and_return(double(branch: Gitaly::Branch.new(name: 'name', target_commit: build(:gitaly_commit))))
+
+ client.find_branch('name')
+ end
+ end
+
+ describe '#find_tag' do
+ it 'sends a find_tag message' do
+ expect_any_instance_of(Gitaly::RefService::Stub)
+ .to receive(:find_tag)
+ .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+ .and_return(double(tag: Gitaly::Tag.new))
+
+ client.find_tag('name')
+ end
+
+ context 'when tag is empty' do
+ it 'does not send a fing_tag message' do
+ expect_any_instance_of(Gitaly::RefService::Stub).not_to receive(:find_tag)
+
+ expect(client.find_tag('')).to be_nil
+ end
+ end
+ end
+
describe '#default_branch_name' do
it 'sends a find_default_branch_name message' do
expect_any_instance_of(Gitaly::RefService::Stub)
@@ -103,16 +133,6 @@ RSpec.describe Gitlab::GitalyClient::RefService do
end
end
- describe '#list_new_blobs' do
- it 'raises DeadlineExceeded when timeout is too small' do
- newrev = '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51'
-
- expect do
- client.list_new_blobs(newrev, dynamic_timeout: 0.001)
- end.to raise_error(GRPC::DeadlineExceeded)
- end
- end
-
describe '#local_branches' do
it 'sends a find_local_branches message' do
expect_any_instance_of(Gitaly::RefService::Stub)
@@ -154,6 +174,22 @@ RSpec.describe Gitlab::GitalyClient::RefService do
client.tags
end
+
+ context 'with sorting option' do
+ it 'sends a correct find_all_tags message' do
+ expected_sort_by = Gitaly::FindAllTagsRequest::SortBy.new(
+ key: :REFNAME,
+ direction: :ASCENDING
+ )
+
+ expect_any_instance_of(Gitaly::RefService::Stub)
+ .to receive(:find_all_tags)
+ .with(gitaly_request_with_params(sort_by: expected_sort_by), kind_of(Hash))
+ .and_return([])
+
+ client.tags(sort_by: 'name_asc')
+ end
+ end
end
describe '#branch_names_contains_sha' do
@@ -189,13 +225,6 @@ RSpec.describe Gitlab::GitalyClient::RefService do
end
end
- describe '#find_ref_name', :seed_helper do
- subject { client.find_ref_name(SeedRepo::Commit::ID, 'refs/heads/master') }
-
- it { is_expected.to be_utf8 }
- it { is_expected.to eq('refs/heads/master') }
- end
-
describe '#ref_exists?', :seed_helper do
it 'finds the master branch ref' do
expect(client.ref_exists?('refs/heads/master')).to eq(true)
diff --git a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb
index 4b037d3f836..e5502a883b5 100644
--- a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb
@@ -195,19 +195,6 @@ RSpec.describe Gitlab::GitalyClient::RepositoryService do
end
end
- describe '#squash_in_progress?' do
- let(:squash_id) { 1 }
-
- it 'sends a repository_squash_in_progress message' do
- expect_any_instance_of(Gitaly::RepositoryService::Stub)
- .to receive(:is_squash_in_progress)
- .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
- .and_return(double(in_progress: true))
-
- client.squash_in_progress?(squash_id)
- end
- end
-
describe '#calculate_checksum' do
it 'sends a calculate_checksum message' do
expect_any_instance_of(Gitaly::RepositoryService::Stub)
diff --git a/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb b/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb
index 0af840d2c10..3dc15c7c059 100644
--- a/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/diff_note_importer_spec.rb
@@ -20,6 +20,7 @@ RSpec.describe Gitlab::GithubImport::Importer::DiffNoteImporter do
noteable_type: 'MergeRequest',
noteable_id: 1,
commit_id: '123abc',
+ original_commit_id: 'original123abc',
file_path: 'README.md',
diff_hunk: hunk,
author: Gitlab::GithubImport::Representation::User
@@ -64,13 +65,14 @@ RSpec.describe Gitlab::GithubImport::Importer::DiffNoteImporter do
LegacyDiffNote.table_name,
[
{
+ discussion_id: anything,
noteable_type: 'MergeRequest',
noteable_id: merge_request.id,
project_id: project.id,
author_id: user.id,
note: 'Hello',
system: false,
- commit_id: '123abc',
+ commit_id: 'original123abc',
line_code: note.line_code,
type: 'LegacyDiffNote',
created_at: created_at,
@@ -95,13 +97,14 @@ RSpec.describe Gitlab::GithubImport::Importer::DiffNoteImporter do
LegacyDiffNote.table_name,
[
{
+ discussion_id: anything,
noteable_type: 'MergeRequest',
noteable_id: merge_request.id,
project_id: project.id,
author_id: project.creator_id,
note: "*Created by: #{user.username}*\n\nHello",
system: false,
- commit_id: '123abc',
+ commit_id: 'original123abc',
line_code: note.line_code,
type: 'LegacyDiffNote',
created_at: created_at,
diff --git a/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb
index 7750e508713..46b9959ff64 100644
--- a/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb
@@ -12,6 +12,7 @@ RSpec.describe Gitlab::GithubImport::Importer::DiffNotesImporter do
html_url: 'https://github.com/foo/bar/pull/42',
path: 'README.md',
commit_id: '123abc',
+ original_commit_id: 'original123abc',
diff_hunk: "@@ -1 +1 @@\n-Hello\n+Hello world",
user: double(:user, id: 4, login: 'alice'),
body: 'Hello world',
diff --git a/spec/lib/gitlab/github_import/importer/single_endpoint_diff_notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/single_endpoint_diff_notes_importer_spec.rb
new file mode 100644
index 00000000000..8c71d7d0ed7
--- /dev/null
+++ b/spec/lib/gitlab/github_import/importer/single_endpoint_diff_notes_importer_spec.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::GithubImport::Importer::SingleEndpointDiffNotesImporter do
+ let(:client) { double }
+ let(:project) { create(:project, import_source: 'github/repo') }
+
+ subject { described_class.new(project, client) }
+
+ it { is_expected.to include_module(Gitlab::GithubImport::ParallelScheduling) }
+ it { is_expected.to include_module(Gitlab::GithubImport::SingleEndpointNotesImporting) }
+ it { expect(subject.representation_class).to eq(Gitlab::GithubImport::Representation::DiffNote) }
+ it { expect(subject.importer_class).to eq(Gitlab::GithubImport::Importer::DiffNoteImporter) }
+ it { expect(subject.collection_method).to eq(:pull_request_comments) }
+ it { expect(subject.object_type).to eq(:diff_note) }
+ it { expect(subject.id_for_already_imported_cache(double(id: 1))).to eq(1) }
+
+ describe '#each_object_to_import', :clean_gitlab_redis_cache do
+ let(:merge_request) do
+ create(
+ :merged_merge_request,
+ iid: 999,
+ source_project: project,
+ target_project: project
+ )
+ end
+
+ let(:note) { double(id: 1) }
+ let(:page) { double(objects: [note], number: 1) }
+
+ it 'fetches data' do
+ expect(client)
+ .to receive(:each_page)
+ .exactly(:once) # ensure to be cached on the second call
+ .with(:pull_request_comments, 'github/repo', merge_request.iid, page: 1)
+ .and_yield(page)
+
+ expect { |b| subject.each_object_to_import(&b) }.to yield_with_args(note)
+
+ subject.each_object_to_import {}
+
+ expect(
+ Gitlab::Cache::Import::Caching.set_includes?(
+ "github-importer/merge_request/diff_notes/already-imported/#{project.id}",
+ merge_request.iid
+ )
+ ).to eq(true)
+ end
+
+ it 'skips cached pages' do
+ Gitlab::GithubImport::PageCounter
+ .new(project, "merge_request/#{merge_request.id}/pull_request_comments")
+ .set(2)
+
+ expect(client)
+ .to receive(:each_page)
+ .exactly(:once) # ensure to be cached on the second call
+ .with(:pull_request_comments, 'github/repo', merge_request.iid, page: 2)
+
+ subject.each_object_to_import {}
+ end
+
+ it 'skips cached merge requests' do
+ Gitlab::Cache::Import::Caching.set_add(
+ "github-importer/merge_request/diff_notes/already-imported/#{project.id}",
+ merge_request.iid
+ )
+
+ expect(client).not_to receive(:each_page)
+
+ subject.each_object_to_import {}
+ end
+ end
+end
diff --git a/spec/lib/gitlab/github_import/importer/single_endpoint_issue_notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/single_endpoint_issue_notes_importer_spec.rb
new file mode 100644
index 00000000000..8d8f2730880
--- /dev/null
+++ b/spec/lib/gitlab/github_import/importer/single_endpoint_issue_notes_importer_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::GithubImport::Importer::SingleEndpointIssueNotesImporter do
+ let(:client) { double }
+ let(:project) { create(:project, import_source: 'github/repo') }
+
+ subject { described_class.new(project, client) }
+
+ it { is_expected.to include_module(Gitlab::GithubImport::ParallelScheduling) }
+ it { is_expected.to include_module(Gitlab::GithubImport::SingleEndpointNotesImporting) }
+ it { expect(subject.representation_class).to eq(Gitlab::GithubImport::Representation::Note) }
+ it { expect(subject.importer_class).to eq(Gitlab::GithubImport::Importer::NoteImporter) }
+ it { expect(subject.collection_method).to eq(:issue_comments) }
+ it { expect(subject.object_type).to eq(:note) }
+ it { expect(subject.id_for_already_imported_cache(double(id: 1))).to eq(1) }
+
+ describe '#each_object_to_import', :clean_gitlab_redis_cache do
+ let(:issue) do
+ create(
+ :issue,
+ iid: 999,
+ project: project
+ )
+ end
+
+ let(:note) { double(id: 1) }
+ let(:page) { double(objects: [note], number: 1) }
+
+ it 'fetches data' do
+ expect(client)
+ .to receive(:each_page)
+ .exactly(:once) # ensure to be cached on the second call
+ .with(:issue_comments, 'github/repo', issue.iid, page: 1)
+ .and_yield(page)
+
+ expect { |b| subject.each_object_to_import(&b) }.to yield_with_args(note)
+
+ subject.each_object_to_import {}
+
+ expect(
+ Gitlab::Cache::Import::Caching.set_includes?(
+ "github-importer/issue/notes/already-imported/#{project.id}",
+ issue.iid
+ )
+ ).to eq(true)
+ end
+
+ it 'skips cached pages' do
+ Gitlab::GithubImport::PageCounter
+ .new(project, "issue/#{issue.id}/issue_comments")
+ .set(2)
+
+ expect(client)
+ .to receive(:each_page)
+ .exactly(:once) # ensure to be cached on the second call
+ .with(:issue_comments, 'github/repo', issue.iid, page: 2)
+
+ subject.each_object_to_import {}
+ end
+
+ it 'skips cached merge requests' do
+ Gitlab::Cache::Import::Caching.set_add(
+ "github-importer/issue/notes/already-imported/#{project.id}",
+ issue.iid
+ )
+
+ expect(client).not_to receive(:each_page)
+
+ subject.each_object_to_import {}
+ end
+ end
+end
diff --git a/spec/lib/gitlab/github_import/importer/single_endpoint_merge_request_notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/single_endpoint_merge_request_notes_importer_spec.rb
new file mode 100644
index 00000000000..b8282212a90
--- /dev/null
+++ b/spec/lib/gitlab/github_import/importer/single_endpoint_merge_request_notes_importer_spec.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::GithubImport::Importer::SingleEndpointMergeRequestNotesImporter do
+ let(:client) { double }
+ let(:project) { create(:project, import_source: 'github/repo') }
+
+ subject { described_class.new(project, client) }
+
+ it { is_expected.to include_module(Gitlab::GithubImport::ParallelScheduling) }
+ it { is_expected.to include_module(Gitlab::GithubImport::SingleEndpointNotesImporting) }
+ it { expect(subject.representation_class).to eq(Gitlab::GithubImport::Representation::Note) }
+ it { expect(subject.importer_class).to eq(Gitlab::GithubImport::Importer::NoteImporter) }
+ it { expect(subject.collection_method).to eq(:issue_comments) }
+ it { expect(subject.object_type).to eq(:note) }
+ it { expect(subject.id_for_already_imported_cache(double(id: 1))).to eq(1) }
+
+ describe '#each_object_to_import', :clean_gitlab_redis_cache do
+ let(:merge_request) do
+ create(
+ :merge_request,
+ iid: 999,
+ source_project: project,
+ target_project: project
+ )
+ end
+
+ let(:note) { double(id: 1) }
+ let(:page) { double(objects: [note], number: 1) }
+
+ it 'fetches data' do
+ expect(client)
+ .to receive(:each_page)
+ .exactly(:once) # ensure to be cached on the second call
+ .with(:issue_comments, 'github/repo', merge_request.iid, page: 1)
+ .and_yield(page)
+
+ expect { |b| subject.each_object_to_import(&b) }.to yield_with_args(note)
+
+ subject.each_object_to_import {}
+
+ expect(
+ Gitlab::Cache::Import::Caching.set_includes?(
+ "github-importer/merge_request/notes/already-imported/#{project.id}",
+ merge_request.iid
+ )
+ ).to eq(true)
+ end
+
+ it 'skips cached pages' do
+ Gitlab::GithubImport::PageCounter
+ .new(project, "merge_request/#{merge_request.id}/issue_comments")
+ .set(2)
+
+ expect(client)
+ .to receive(:each_page)
+ .exactly(:once) # ensure to be cached on the second call
+ .with(:issue_comments, 'github/repo', merge_request.iid, page: 2)
+
+ subject.each_object_to_import {}
+ end
+
+ it 'skips cached merge requests' do
+ Gitlab::Cache::Import::Caching.set_add(
+ "github-importer/merge_request/notes/already-imported/#{project.id}",
+ merge_request.iid
+ )
+
+ expect(client).not_to receive(:each_page)
+
+ subject.each_object_to_import {}
+ end
+ end
+end
diff --git a/spec/lib/gitlab/github_import/issuable_finder_spec.rb b/spec/lib/gitlab/github_import/issuable_finder_spec.rb
index f009b61ad89..3afd006109b 100644
--- a/spec/lib/gitlab/github_import/issuable_finder_spec.rb
+++ b/spec/lib/gitlab/github_import/issuable_finder_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::GithubImport::IssuableFinder, :clean_gitlab_redis_cache do
- let(:project) { double(:project, id: 4) }
+ let(:project) { double(:project, id: 4, group: nil) }
let(:issue) do
double(:issue, issuable_type: MergeRequest, iid: 1)
end
@@ -26,15 +26,77 @@ RSpec.describe Gitlab::GithubImport::IssuableFinder, :clean_gitlab_redis_cache d
expect { finder.database_id }.to raise_error(TypeError)
end
+
+ context 'when group is present' do
+ context 'when github_importer_single_endpoint_notes_import feature flag is enabled' do
+ it 'reads cache value with longer timeout' do
+ project = create(:project, import_url: 'http://t0ken@github.com/user/repo.git')
+ group = create(:group, projects: [project])
+
+ stub_feature_flags(github_importer_single_endpoint_notes_import: group)
+
+ expect(Gitlab::Cache::Import::Caching)
+ .to receive(:read)
+ .with(anything, timeout: Gitlab::Cache::Import::Caching::LONGER_TIMEOUT)
+
+ described_class.new(project, issue).database_id
+ end
+ end
+
+ context 'when github_importer_single_endpoint_notes_import feature flag is disabled' do
+ it 'reads cache value with default timeout' do
+ project = double(:project, id: 4, group: create(:group))
+
+ stub_feature_flags(github_importer_single_endpoint_notes_import: false)
+
+ expect(Gitlab::Cache::Import::Caching)
+ .to receive(:read)
+ .with(anything, timeout: Gitlab::Cache::Import::Caching::TIMEOUT)
+
+ described_class.new(project, issue).database_id
+ end
+ end
+ end
end
describe '#cache_database_id' do
it 'caches the ID of a database row' do
expect(Gitlab::Cache::Import::Caching)
.to receive(:write)
- .with('github-import/issuable-finder/4/MergeRequest/1', 10)
+ .with('github-import/issuable-finder/4/MergeRequest/1', 10, timeout: 86400)
finder.cache_database_id(10)
end
+
+ context 'when group is present' do
+ context 'when github_importer_single_endpoint_notes_import feature flag is enabled' do
+ it 'caches value with longer timeout' do
+ project = create(:project, import_url: 'http://t0ken@github.com/user/repo.git')
+ group = create(:group, projects: [project])
+
+ stub_feature_flags(github_importer_single_endpoint_notes_import: group)
+
+ expect(Gitlab::Cache::Import::Caching)
+ .to receive(:write)
+ .with(anything, anything, timeout: Gitlab::Cache::Import::Caching::LONGER_TIMEOUT)
+
+ described_class.new(project, issue).cache_database_id(10)
+ end
+ end
+
+ context 'when github_importer_single_endpoint_notes_import feature flag is disabled' do
+ it 'caches value with default timeout' do
+ project = double(:project, id: 4, group: create(:group))
+
+ stub_feature_flags(github_importer_single_endpoint_notes_import: false)
+
+ expect(Gitlab::Cache::Import::Caching)
+ .to receive(:write)
+ .with(anything, anything, timeout: Gitlab::Cache::Import::Caching::TIMEOUT)
+
+ described_class.new(project, issue).cache_database_id(10)
+ end
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/github_import/representation/diff_note_spec.rb b/spec/lib/gitlab/github_import/representation/diff_note_spec.rb
index 7e540674258..7c24cd0a5db 100644
--- a/spec/lib/gitlab/github_import/representation/diff_note_spec.rb
+++ b/spec/lib/gitlab/github_import/representation/diff_note_spec.rb
@@ -67,6 +67,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do
html_url: 'https://github.com/foo/bar/pull/42',
path: 'README.md',
commit_id: '123abc',
+ original_commit_id: 'original123abc',
diff_hunk: hunk,
user: double(:user, id: 4, login: 'alice'),
body: 'Hello world',
@@ -99,6 +100,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do
'noteable_id' => 42,
'file_path' => 'README.md',
'commit_id' => '123abc',
+ 'original_commit_id' => 'original123abc',
'diff_hunk' => hunk,
'author' => { 'id' => 4, 'login' => 'alice' },
'note' => 'Hello world',
@@ -117,6 +119,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do
'noteable_id' => 42,
'file_path' => 'README.md',
'commit_id' => '123abc',
+ 'original_commit_id' => 'original123abc',
'diff_hunk' => hunk,
'note' => 'Hello world',
'created_at' => created_at.to_s,
@@ -145,6 +148,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do
'noteable_id' => 42,
'file_path' => 'README.md',
'commit_id' => '123abc',
+ 'original_commit_id' => 'original123abc',
'diff_hunk' => hunk,
'author' => { 'id' => 4, 'login' => 'alice' },
'note' => 'Hello world',
diff --git a/spec/lib/gitlab/github_import/sequential_importer_spec.rb b/spec/lib/gitlab/github_import/sequential_importer_spec.rb
index a5e89049ed9..3c3f8ff59d0 100644
--- a/spec/lib/gitlab/github_import/sequential_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/sequential_importer_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Gitlab::GithubImport::SequentialImporter do
describe '#execute' do
it 'imports a project in sequence' do
repository = double(:repository)
- project = double(:project, id: 1, repository: repository, import_url: 'http://t0ken@github.another-domain.com/repo-org/repo.git')
+ project = double(:project, id: 1, repository: repository, import_url: 'http://t0ken@github.another-domain.com/repo-org/repo.git', group: nil)
importer = described_class.new(project, token: 'foo')
expect_next_instance_of(Gitlab::GithubImport::Importer::RepositoryImporter) do |instance|
diff --git a/spec/lib/gitlab/github_import/user_finder_spec.rb b/spec/lib/gitlab/github_import/user_finder_spec.rb
index f81fa3b1e2e..8eb6eedd72d 100644
--- a/spec/lib/gitlab/github_import/user_finder_spec.rb
+++ b/spec/lib/gitlab/github_import/user_finder_spec.rb
@@ -195,7 +195,7 @@ RSpec.describe Gitlab::GithubImport::UserFinder, :clean_gitlab_redis_cache do
expect(Gitlab::Cache::Import::Caching)
.to receive(:write)
- .with(an_instance_of(String), email)
+ .with(an_instance_of(String), email, timeout: Gitlab::Cache::Import::Caching::TIMEOUT)
finder.email_for_github_username('kittens')
end
@@ -211,6 +211,16 @@ RSpec.describe Gitlab::GithubImport::UserFinder, :clean_gitlab_redis_cache do
expect(finder.email_for_github_username('kittens')).to be_nil
end
+
+ it 'shortens the timeout for Email address in cache when an Email address is private/nil from GitHub' do
+ user = double(:user, email: nil)
+ expect(client).to receive(:user).with('kittens').and_return(user)
+
+ expect(Gitlab::Cache::Import::Caching)
+ .to receive(:write).with(an_instance_of(String), nil, timeout: Gitlab::Cache::Import::Caching::SHORTER_TIMEOUT)
+
+ expect(finder.email_for_github_username('kittens')).to be_nil
+ end
end
end
diff --git a/spec/lib/gitlab/github_import_spec.rb b/spec/lib/gitlab/github_import_spec.rb
index 662757f66ad..1ea9f003098 100644
--- a/spec/lib/gitlab/github_import_spec.rb
+++ b/spec/lib/gitlab/github_import_spec.rb
@@ -3,13 +3,17 @@
require 'spec_helper'
RSpec.describe Gitlab::GithubImport do
+ before do
+ stub_feature_flags(github_importer_lower_per_page_limit: false)
+ end
+
context 'github.com' do
- let(:project) { double(:project, import_url: 'http://t0ken@github.com/user/repo.git', id: 1) }
+ let(:project) { double(:project, import_url: 'http://t0ken@github.com/user/repo.git', id: 1, group: nil) }
it 'returns a new Client with a custom token' do
expect(described_class::Client)
.to receive(:new)
- .with('123', host: nil, parallel: true)
+ .with('123', host: nil, parallel: true, per_page: 100)
described_class.new_client_for(project, token: '123')
end
@@ -23,7 +27,7 @@ RSpec.describe Gitlab::GithubImport do
expect(described_class::Client)
.to receive(:new)
- .with('123', host: nil, parallel: true)
+ .with('123', host: nil, parallel: true, per_page: 100)
described_class.new_client_for(project)
end
@@ -45,12 +49,12 @@ RSpec.describe Gitlab::GithubImport do
end
context 'GitHub Enterprise' do
- let(:project) { double(:project, import_url: 'http://t0ken@github.another-domain.com/repo-org/repo.git') }
+ let(:project) { double(:project, import_url: 'http://t0ken@github.another-domain.com/repo-org/repo.git', group: nil) }
it 'returns a new Client with a custom token' do
expect(described_class::Client)
.to receive(:new)
- .with('123', host: 'http://github.another-domain.com/api/v3', parallel: true)
+ .with('123', host: 'http://github.another-domain.com/api/v3', parallel: true, per_page: 100)
described_class.new_client_for(project, token: '123')
end
@@ -64,7 +68,7 @@ RSpec.describe Gitlab::GithubImport do
expect(described_class::Client)
.to receive(:new)
- .with('123', host: 'http://github.another-domain.com/api/v3', parallel: true)
+ .with('123', host: 'http://github.another-domain.com/api/v3', parallel: true, per_page: 100)
described_class.new_client_for(project)
end
@@ -88,4 +92,37 @@ RSpec.describe Gitlab::GithubImport do
expect(described_class.formatted_import_url(project)).to eq('http://github.another-domain.com/api/v3')
end
end
+
+ describe '.per_page' do
+ context 'when project group is present' do
+ context 'when github_importer_lower_per_page_limit is enabled' do
+ it 'returns lower per page value' do
+ project = create(:project, import_url: 'http://t0ken@github.com/user/repo.git')
+ group = create(:group, projects: [project])
+
+ stub_feature_flags(github_importer_lower_per_page_limit: group)
+
+ expect(described_class.per_page(project)).to eq(Gitlab::GithubImport::Client::LOWER_PER_PAGE)
+ end
+ end
+
+ context 'when github_importer_lower_per_page_limit is disabled' do
+ it 'returns default per page value' do
+ project = double(:project, import_url: 'http://t0ken@github.com/user/repo.git', id: 1, group: create(:group))
+
+ stub_feature_flags(github_importer_lower_per_page_limit: false)
+
+ expect(described_class.per_page(project)).to eq(Gitlab::GithubImport::Client::DEFAULT_PER_PAGE)
+ end
+ end
+ end
+
+ context 'when project group is missing' do
+ it 'returns default per page value' do
+ project = double(:project, import_url: 'http://t0ken@github.com/user/repo.git', id: 1, group: nil)
+
+ expect(described_class.per_page(project)).to eq(Gitlab::GithubImport::Client::DEFAULT_PER_PAGE)
+ 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 2b7138a7a10..614aa55c3c5 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -58,6 +58,7 @@ issues:
- test_reports
- requirement
- incident_management_issuable_escalation_status
+- pending_escalations
work_item_type:
- issues
events:
@@ -223,6 +224,7 @@ ci_pipelines:
- builds
- bridges
- processables
+- generic_commit_statuses
- trigger_requests
- variables
- auto_canceled_by
@@ -318,6 +320,7 @@ integrations:
- project
- service_hook
- jira_tracker_data
+- zentao_tracker_data
- issue_tracker_data
- open_project_tracker_data
hooks:
@@ -354,6 +357,8 @@ project:
- taggings
- base_tags
- topic_taggings
+- topics_acts_as_taggable
+- project_topics
- topics
- chat_services
- cluster
@@ -365,6 +370,7 @@ project:
- value_streams
- group
- namespace
+- project_namespace
- management_clusters
- boards
- last_event
@@ -395,6 +401,7 @@ project:
- teamcity_integration
- pushover_integration
- jira_integration
+- zentao_integration
- redmine_integration
- youtrack_integration
- custom_issue_tracker_integration
@@ -583,6 +590,9 @@ project:
- timelogs
- error_tracking_errors
- error_tracking_client_keys
+- pending_builds
+- security_scans
+- ci_feature_usages
award_emoji:
- awardable
- user
@@ -673,6 +683,7 @@ boards:
- destroyable_lists
- milestone
- iteration
+- iteration_cadence
- board_labels
- board_assignee
- assignee
@@ -762,3 +773,5 @@ push_rule:
- group
bulk_import_export:
- group
+service_desk_setting:
+ - file_template_project
diff --git a/spec/lib/gitlab/import_export/attributes_permitter_spec.rb b/spec/lib/gitlab/import_export/attributes_permitter_spec.rb
index 0c1b1cd74bf..36a831a785c 100644
--- a/spec/lib/gitlab/import_export/attributes_permitter_spec.rb
+++ b/spec/lib/gitlab/import_export/attributes_permitter_spec.rb
@@ -74,4 +74,73 @@ RSpec.describe Gitlab::ImportExport::AttributesPermitter do
expect(subject.permitted_attributes_for(:labels)).to contain_exactly(:title, :description, :type, :priorities)
end
end
+
+ describe '#permitted_attributes_defined?' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:attributes_permitter) { described_class.new }
+
+ where(:relation_name, :permitted_attributes_defined) do
+ :user | false
+ :author | false
+ :ci_cd_settings | false
+ :issuable_sla | false
+ :push_rule | false
+ :metrics_setting | true
+ :project_badges | true
+ :pipeline_schedules | true
+ :error_tracking_setting | true
+ :auto_devops | true
+ end
+
+ with_them do
+ it { expect(attributes_permitter.permitted_attributes_defined?(relation_name)).to eq(permitted_attributes_defined) }
+ end
+ end
+
+ describe 'included_attributes for Project' do
+ let(:prohibited_attributes) { %i[remote_url my_attributes my_ids token my_id test] }
+
+ subject { described_class.new }
+
+ Gitlab::ImportExport::Config.new.to_h[:included_attributes].each do |relation_sym, permitted_attributes|
+ context "for #{relation_sym}" do
+ let(:import_export_config) { Gitlab::ImportExport::Config.new.to_h }
+ let(:project_relation_factory) { Gitlab::ImportExport::Project::RelationFactory }
+
+ let(:relation_hash) { (permitted_attributes + prohibited_attributes).map(&:to_s).zip([]).to_h }
+ let(:relation_name) { project_relation_factory.overrides[relation_sym]&.to_sym || relation_sym }
+ let(:relation_class) { project_relation_factory.relation_class(relation_name) }
+ let(:excluded_keys) { import_export_config.dig(:excluded_keys, relation_sym) || [] }
+
+ let(:cleaned_hash) do
+ Gitlab::ImportExport::AttributeCleaner.new(
+ relation_hash: relation_hash,
+ relation_class: relation_class,
+ excluded_keys: excluded_keys
+ ).clean
+ end
+
+ let(:permitted_hash) { subject.permit(relation_sym, relation_hash) }
+
+ if described_class.new.permitted_attributes_defined?(relation_sym)
+ it 'contains only attributes that are defined as permitted in the import/export config' do
+ expect(permitted_hash.keys).to contain_exactly(*permitted_attributes.map(&:to_s))
+ end
+
+ it 'does not contain attributes that would be cleaned with AttributeCleaner' do
+ expect(cleaned_hash.keys).to include(*permitted_hash.keys)
+ end
+
+ it 'does not contain prohibited attributes that are not related to given relation' do
+ expect(permitted_hash.keys).not_to include(*prohibited_attributes.map(&:to_s))
+ end
+ else
+ it 'is disabled' do
+ expect(subject).not_to be_permitted_attributes_defined(relation_sym)
+ end
+ end
+ 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 77d126e012e..a9efa32f986 100644
--- a/spec/lib/gitlab/import_export/safe_model_attributes.yml
+++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml
@@ -167,6 +167,7 @@ ProjectMember:
- expires_at
- ldap
- override
+- invite_email_success
User:
- id
- username
@@ -761,6 +762,7 @@ Board:
- group_id
- milestone_id
- iteration_id
+- iteration_cadence_id
- weight
- name
- hide_backlog_list
diff --git a/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb b/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb
index cd1828791c3..b2a11353d0c 100644
--- a/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb
+++ b/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb
@@ -130,15 +130,25 @@ RSpec.describe Gitlab::Instrumentation::RedisInterceptor, :clean_gitlab_redis_sh
end
context 'when report_on_long_redis_durations is enabled' do
- it 'tracks an exception and continues' do
- expect(Gitlab::ErrorTracking)
- .to receive(:track_exception)
- .with(an_instance_of(described_class::MysteryRedisDurationError),
- command: 'mget',
- duration: be > threshold,
- timestamp: a_string_matching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{5}/))
+ context 'for an instance other than SharedState' do
+ it 'does nothing' do
+ expect(Gitlab::ErrorTracking).not_to receive(:track_exception)
- Gitlab::Redis::SharedState.with { |r| r.mget('foo', 'foo') { sleep threshold + 0.1 } }
+ Gitlab::Redis::Queues.with { |r| r.mget('foo', 'foo') { sleep threshold + 0.1 } }
+ end
+ end
+
+ context 'for the SharedState instance' do
+ it 'tracks an exception and continues' do
+ expect(Gitlab::ErrorTracking)
+ .to receive(:track_exception)
+ .with(an_instance_of(described_class::MysteryRedisDurationError),
+ command: 'mget',
+ duration: be > threshold,
+ timestamp: a_string_matching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{5}/))
+
+ Gitlab::Redis::SharedState.with { |r| r.mget('foo', 'foo') { sleep threshold + 0.1 } }
+ end
end
end
end
diff --git a/spec/lib/gitlab/instrumentation/redis_spec.rb b/spec/lib/gitlab/instrumentation/redis_spec.rb
index 6cddf958f2a..ebc2e92a0dd 100644
--- a/spec/lib/gitlab/instrumentation/redis_spec.rb
+++ b/spec/lib/gitlab/instrumentation/redis_spec.rb
@@ -28,6 +28,13 @@ RSpec.describe Gitlab::Instrumentation::Redis do
describe '.payload', :request_store do
before do
+ # If this is the first spec in a spec run that uses Redis, there
+ # will be an extra SELECT command to choose the right database. We
+ # don't want to make the spec less precise, so we force that to
+ # happen (if needed) first, then clear the counts.
+ Gitlab::Redis::Cache.with { |redis| redis.info }
+ RequestStore.clear!
+
Gitlab::Redis::Cache.with { |redis| redis.set('cache-test', 321) }
Gitlab::Redis::SharedState.with { |redis| redis.set('shared-state-test', 123) }
end
diff --git a/spec/lib/gitlab/issuables_count_for_state_spec.rb b/spec/lib/gitlab/issuables_count_for_state_spec.rb
index a6170c146ab..cc4ebba863d 100644
--- a/spec/lib/gitlab/issuables_count_for_state_spec.rb
+++ b/spec/lib/gitlab/issuables_count_for_state_spec.rb
@@ -66,4 +66,106 @@ RSpec.describe Gitlab::IssuablesCountForState do
end
end
end
+
+ context 'when store_in_redis_cache is `true`', :clean_gitlab_redis_cache do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+
+ let(:cache_options) { { expires_in: 1.hour } }
+ let(:cache_key) { ['group', group.id, 'issues'] }
+ let(:threshold) { described_class::THRESHOLD }
+ let(:states_count) { { opened: 1, closed: 1, all: 2 } }
+ let(:params) { {} }
+
+ subject { described_class.new(finder, fast_fail: true, store_in_redis_cache: true ) }
+
+ before do
+ allow(finder).to receive(:count_by_state).and_return(states_count)
+ allow_next_instance_of(described_class) do |counter|
+ allow(counter).to receive(:parent_group).and_return(group)
+ end
+ end
+
+ shared_examples 'calculating counts without caching' do
+ it 'does not store in redis store' do
+ expect(Rails.cache).not_to receive(:read)
+ expect(finder).to receive(:count_by_state)
+ expect(Rails.cache).not_to receive(:write)
+ expect(subject[:all]).to eq(states_count[:all])
+ end
+ end
+
+ context 'with Issues' do
+ let(:finder) { IssuesFinder.new(user, params) }
+
+ it 'returns -1 for the requested state' do
+ allow(finder).to receive(:count_by_state).and_raise(ActiveRecord::QueryCanceled)
+ expect(Rails.cache).not_to receive(:write)
+
+ expect(subject[:all]).to eq(-1)
+ end
+
+ context 'when parent group is not present' do
+ let(:group) { nil }
+
+ it_behaves_like 'calculating counts without caching'
+ end
+
+ context 'when params include search filters' do
+ let(:parent) { group }
+
+ before do
+ finder.params[:assignee_username] = [user.username, 'root']
+ end
+
+ it_behaves_like 'calculating counts without caching'
+ end
+
+ context 'when counts are stored in cache' do
+ before do
+ allow(Rails.cache).to receive(:read).with(cache_key, cache_options)
+ .and_return({ opened: 1000, closed: 1000, all: 2000 })
+ end
+
+ it 'does not call finder count_by_state' do
+ expect(finder).not_to receive(:count_by_state)
+
+ expect(subject[:all]).to eq(2000)
+ end
+ end
+
+ context 'when cache is empty' do
+ context 'when state counts are under threshold' do
+ let(:states_count) { { opened: 1, closed: 1, all: 2 } }
+
+ it 'does not store state counts in cache' do
+ expect(Rails.cache).to receive(:read).with(cache_key, cache_options)
+ expect(finder).to receive(:count_by_state)
+ expect(Rails.cache).not_to receive(:write)
+ expect(subject[:all]).to eq(states_count[:all])
+ end
+ end
+
+ context 'when state counts are over threshold' do
+ let(:states_count) do
+ { opened: threshold + 1, closed: threshold + 1, all: (threshold + 1) * 2 }
+ end
+
+ it 'stores state counts in cache' do
+ expect(Rails.cache).to receive(:read).with(cache_key, cache_options)
+ expect(finder).to receive(:count_by_state)
+ expect(Rails.cache).to receive(:write).with(cache_key, states_count, cache_options)
+
+ expect(subject[:all]).to eq((threshold + 1) * 2)
+ end
+ end
+ end
+ end
+
+ context 'with Merge Requests' do
+ let(:finder) { MergeRequestsFinder.new(user, params) }
+
+ it_behaves_like 'calculating counts without caching'
+ end
+ end
end
diff --git a/spec/lib/gitlab/issues/rebalancing/state_spec.rb b/spec/lib/gitlab/issues/rebalancing/state_spec.rb
new file mode 100644
index 00000000000..bdd0dbd365d
--- /dev/null
+++ b/spec/lib/gitlab/issues/rebalancing/state_spec.rb
@@ -0,0 +1,223 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Issues::Rebalancing::State, :clean_gitlab_redis_shared_state do
+ shared_examples 'issues rebalance caching' do
+ describe '#track_new_running_rebalance' do
+ it 'caches a project id to track caching in progress' do
+ expect { rebalance_caching.track_new_running_rebalance }.to change { rebalance_caching.concurrent_running_rebalances_count }.from(0).to(1)
+ end
+ end
+
+ describe '#set and get current_index' do
+ it 'returns zero as current index when index not cached' do
+ expect(rebalance_caching.get_current_index).to eq(0)
+ end
+
+ it 'returns cached current index' do
+ expect { rebalance_caching.cache_current_index(123) }.to change { rebalance_caching.get_current_index }.from(0).to(123)
+ end
+ end
+
+ describe '#set and get current_project' do
+ it 'returns nil if there is no project_id cached' do
+ expect(rebalance_caching.get_current_project_id).to be_nil
+ end
+
+ it 'returns cached current project_id' do
+ expect { rebalance_caching.cache_current_project_id(456) }.to change { rebalance_caching.get_current_project_id }.from(nil).to('456')
+ end
+ end
+
+ describe "#rebalance_in_progress?" do
+ it 'return zero if no re-balances are running' do
+ expect(rebalance_caching.concurrent_running_rebalances_count).to eq(0)
+ end
+
+ it 'return false if no re-balances are running' do
+ expect(rebalance_caching.rebalance_in_progress?).to be false
+ end
+
+ it 'return true a re-balance for given project/namespace is running' do
+ rebalance_caching.track_new_running_rebalance
+
+ expect(rebalance_caching.rebalance_in_progress?).to be true
+ end
+ end
+
+ context 'caching issue ids' do
+ context 'with no issue ids cached' do
+ it 'returns zero when there are no cached issue ids' do
+ expect(rebalance_caching.issue_count).to eq(0)
+ end
+
+ it 'returns empty array when there are no cached issue ids' do
+ expect(rebalance_caching.get_cached_issue_ids(0, 100)).to eq([])
+ end
+ end
+
+ context 'with cached issue ids' do
+ before do
+ generate_and_cache_issues_ids(count: 3)
+ end
+
+ it 'returns count of cached issue ids' do
+ expect(rebalance_caching.issue_count).to eq(3)
+ end
+
+ it 'returns array of issue ids' do
+ expect(rebalance_caching.get_cached_issue_ids(0, 100)).to eq(%w(1 2 3))
+ end
+
+ it 'limits returned values' do
+ expect(rebalance_caching.get_cached_issue_ids(0, 2)).to eq(%w(1 2))
+ end
+
+ context 'when caching duplicate issue_ids' do
+ before do
+ generate_and_cache_issues_ids(count: 3, position_offset: 3, position_direction: -1)
+ end
+
+ it 'does not cache duplicate issues' do
+ expect(rebalance_caching.issue_count).to eq(3)
+ end
+
+ it 'returns cached issues with latest scores' do
+ expect(rebalance_caching.get_cached_issue_ids(0, 100)).to eq(%w(3 2 1))
+ end
+ end
+ end
+ end
+
+ context 'when setting expiration' do
+ context 'when tracking new rebalance' do
+ it 'returns as expired for non existent key' do
+ ::Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.ttl(rebalance_caching.send(:concurrent_running_rebalances_key))).to be < 0
+ end
+ end
+
+ it 'has expiration set' do
+ rebalance_caching.track_new_running_rebalance
+
+ ::Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.ttl(rebalance_caching.send(:concurrent_running_rebalances_key))).to be_between(0, described_class::REDIS_EXPIRY_TIME.ago.to_i)
+ end
+ end
+ end
+
+ context 'when setting current index' do
+ it 'returns as expiring for non existent key' do
+ ::Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.ttl(rebalance_caching.send(:current_index_key))).to be < 0
+ end
+ end
+
+ it 'has expiration set' do
+ rebalance_caching.cache_current_index(123)
+
+ ::Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.ttl(rebalance_caching.send(:current_index_key))).to be_between(0, described_class::REDIS_EXPIRY_TIME.ago.to_i)
+ end
+ end
+ end
+
+ context 'when setting current project id' do
+ it 'returns as expired for non existent key' do
+ ::Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.ttl(rebalance_caching.send(:current_project_key))).to be < 0
+ end
+ end
+
+ it 'has expiration set' do
+ rebalance_caching.cache_current_project_id(456)
+
+ ::Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.ttl(rebalance_caching.send(:current_project_key))).to be_between(0, described_class::REDIS_EXPIRY_TIME.ago.to_i)
+ end
+ end
+ end
+
+ context 'when setting cached issue ids' do
+ it 'returns as expired for non existent key' do
+ ::Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.ttl(rebalance_caching.send(:issue_ids_key))).to be < 0
+ end
+ end
+
+ it 'has expiration set' do
+ generate_and_cache_issues_ids(count: 3)
+
+ ::Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.ttl(rebalance_caching.send(:issue_ids_key))).to be_between(0, described_class::REDIS_EXPIRY_TIME.ago.to_i)
+ end
+ end
+ end
+ end
+
+ context 'cleanup cache' do
+ before do
+ generate_and_cache_issues_ids(count: 3)
+ rebalance_caching.cache_current_index(123)
+ rebalance_caching.cache_current_project_id(456)
+ rebalance_caching.track_new_running_rebalance
+ end
+
+ it 'removes cache keys' do
+ expect(check_existing_keys).to eq(4)
+
+ rebalance_caching.cleanup_cache
+
+ expect(check_existing_keys).to eq(0)
+ end
+ end
+ end
+
+ context 'rebalancing issues in namespace' do
+ let_it_be(:group) { create(:group, :private) }
+ let_it_be(:project) { create(:project, namespace: group) }
+
+ subject(:rebalance_caching) { described_class.new(group, group.projects) }
+
+ it { expect(rebalance_caching.send(:rebalanced_container_type)).to eq(described_class::NAMESPACE) }
+
+ it_behaves_like 'issues rebalance caching'
+ end
+
+ context 'rebalancing issues in a project' do
+ let_it_be(:project) { create(:project) }
+
+ subject(:rebalance_caching) { described_class.new(project.namespace, Project.where(id: project)) }
+
+ it { expect(rebalance_caching.send(:rebalanced_container_type)).to eq(described_class::PROJECT) }
+
+ it_behaves_like 'issues rebalance caching'
+ end
+
+ # count - how many issue ids to generate, issue ids will start at 1
+ # position_offset - if you'd want to offset generated relative_position for the issue ids,
+ # relative_position is generated as = issue id * 10 + position_offset
+ # position_direction - (1) for positive relative_positions, (-1) for negative relative_positions
+ def generate_and_cache_issues_ids(count:, position_offset: 0, position_direction: 1)
+ issues = []
+
+ count.times do |idx|
+ id = idx + 1
+ issues << double(relative_position: position_direction * (id * 10 + position_offset), id: id)
+ end
+
+ rebalance_caching.cache_issue_ids(issues)
+ end
+
+ def check_existing_keys
+ index = 0
+
+ index += 1 if rebalance_caching.get_current_index > 0
+ index += 1 if rebalance_caching.get_current_project_id.present?
+ index += 1 if rebalance_caching.get_cached_issue_ids(0, 100).present?
+ index += 1 if rebalance_caching.rebalance_in_progress?
+
+ index
+ end
+end
diff --git a/spec/lib/gitlab/kas/client_spec.rb b/spec/lib/gitlab/kas/client_spec.rb
index 40e18f58ee4..5b89023cc13 100644
--- a/spec/lib/gitlab/kas/client_spec.rb
+++ b/spec/lib/gitlab/kas/client_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe Gitlab::Kas::Client do
let_it_be(:project) { create(:project) }
+ let_it_be(:agent) { create(:cluster_agent, project: project) }
describe '#initialize' do
context 'kas is not enabled' do
@@ -44,6 +45,32 @@ RSpec.describe Gitlab::Kas::Client do
expect(token).to receive(:audience=).with(described_class::JWT_AUDIENCE)
end
+ describe '#get_connected_agents' do
+ let(:stub) { instance_double(Gitlab::Agent::AgentTracker::Rpc::AgentTracker::Stub) }
+ let(:request) { instance_double(Gitlab::Agent::AgentTracker::Rpc::GetConnectedAgentsRequest) }
+ let(:response) { double(Gitlab::Agent::AgentTracker::Rpc::GetConnectedAgentsResponse, agents: connected_agents) }
+
+ let(:connected_agents) { [double] }
+
+ subject { described_class.new.get_connected_agents(project: project) }
+
+ before do
+ expect(Gitlab::Agent::AgentTracker::Rpc::AgentTracker::Stub).to receive(:new)
+ .with('example.kas.internal', :this_channel_is_insecure, timeout: described_class::TIMEOUT)
+ .and_return(stub)
+
+ expect(Gitlab::Agent::AgentTracker::Rpc::GetConnectedAgentsRequest).to receive(:new)
+ .with(project_id: project.id)
+ .and_return(request)
+
+ expect(stub).to receive(:get_connected_agents)
+ .with(request, metadata: { 'authorization' => 'bearer test-token' })
+ .and_return(response)
+ end
+
+ it { expect(subject).to eq(connected_agents) }
+ end
+
describe '#list_agent_config_files' do
let(:stub) { instance_double(Gitlab::Agent::ConfigurationProject::Rpc::ConfigurationProject::Stub) }
diff --git a/spec/lib/gitlab/middleware/sidekiq_web_static_spec.rb b/spec/lib/gitlab/middleware/sidekiq_web_static_spec.rb
new file mode 100644
index 00000000000..e6815a46a56
--- /dev/null
+++ b/spec/lib/gitlab/middleware/sidekiq_web_static_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Middleware::SidekiqWebStatic do
+ let(:app) { double(:app) }
+ let(:middleware) { described_class.new(app) }
+ let(:env) { {} }
+
+ describe '#call' do
+ before do
+ env['HTTP_X_SENDFILE_TYPE'] = 'X-Sendfile'
+ env['PATH_INFO'] = path
+ end
+
+ context 'with an /admin/sidekiq route' do
+ let(:path) { '/admin/sidekiq/javascripts/application.js'}
+
+ it 'deletes the HTTP_X_SENDFILE_TYPE header' do
+ expect(app).to receive(:call)
+
+ middleware.call(env)
+
+ expect(env['HTTP_X_SENDFILE_TYPE']).to be_nil
+ end
+ end
+
+ context 'with some static asset route' do
+ let(:path) { '/assets/test.png' }
+
+ it 'keeps the HTTP_X_SENDFILE_TYPE header' do
+ expect(app).to receive(:call)
+
+ middleware.call(env)
+
+ expect(env['HTTP_X_SENDFILE_TYPE']).to eq('X-Sendfile')
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/pagination/cursor_based_keyset_spec.rb b/spec/lib/gitlab/pagination/cursor_based_keyset_spec.rb
new file mode 100644
index 00000000000..ac2695977c4
--- /dev/null
+++ b/spec/lib/gitlab/pagination/cursor_based_keyset_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Pagination::CursorBasedKeyset do
+ subject { described_class }
+
+ describe '.available_for_type?' do
+ it 'returns true for Group' do
+ expect(subject.available_for_type?(Group.all)).to be_truthy
+ end
+
+ it 'return false for other types of relations' do
+ expect(subject.available_for_type?(User.all)).to be_falsey
+ end
+ end
+
+ describe '.available?' do
+ let(:request_context) { double('request_context', params: { order_by: order_by, sort: sort }) }
+ let(:cursor_based_request_context) { Gitlab::Pagination::Keyset::CursorBasedRequestContext.new(request_context) }
+
+ context 'with order-by name asc' do
+ let(:order_by) { :name }
+ let(:sort) { :asc }
+
+ it 'returns true for Group' do
+ expect(subject.available?(cursor_based_request_context, Group.all)).to be_truthy
+ end
+
+ it 'return false for other types of relations' do
+ expect(subject.available?(cursor_based_request_context, User.all)).to be_falsey
+ end
+ end
+
+ context 'with other order-by columns' do
+ let(:order_by) { :path }
+ let(:sort) { :asc }
+
+ it 'returns false for Group' do
+ expect(subject.available?(cursor_based_request_context, Group.all)).to be_falsey
+ end
+
+ it 'return false for other types of relations' do
+ expect(subject.available?(cursor_based_request_context, User.all)).to be_falsey
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/pagination/gitaly_keyset_pager_spec.rb b/spec/lib/gitlab/pagination/gitaly_keyset_pager_spec.rb
index 8a26e153385..dcb8138bdde 100644
--- a/spec/lib/gitlab/pagination/gitaly_keyset_pager_spec.rb
+++ b/spec/lib/gitlab/pagination/gitaly_keyset_pager_spec.rb
@@ -74,7 +74,7 @@ RSpec.describe Gitlab::Pagination::GitalyKeysetPager do
allow(request_context).to receive(:request).and_return(fake_request)
allow(project.repository).to receive(:branch_count).and_return(branches.size)
- expect(finder).to receive(:execute).with(gitaly_pagination: true).and_return(branches)
+ expect(finder).to receive(:execute).and_return(branches)
expect(request_context).to receive(:header).with('X-Per-Page', '2')
expect(request_context).to receive(:header).with('X-Page', '1')
expect(request_context).to receive(:header).with('X-Next-Page', '2')
@@ -99,6 +99,7 @@ RSpec.describe Gitlab::Pagination::GitalyKeysetPager do
before do
allow(request_context).to receive(:request).and_return(fake_request)
+ allow(finder).to receive(:is_a?).with(BranchesFinder) { true }
expect(finder).to receive(:execute).with(gitaly_pagination: true).and_return(branches)
end
diff --git a/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb b/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb
index 6e9e987f90c..69384e0c501 100644
--- a/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb
+++ b/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb
@@ -185,4 +185,25 @@ RSpec.describe Gitlab::Pagination::Keyset::ColumnOrderDefinition do
end
end
end
+
+ describe "#order_direction_as_sql_string" 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 { expect(project_name_column.order_direction_as_sql_string).to eq('ASC') }
+ it { expect(project_name_column.reverse.order_direction_as_sql_string).to eq('DESC') }
+ it { expect(project_name_lower_column.order_direction_as_sql_string).to eq('DESC') }
+ it { expect(project_name_lower_column.reverse.order_direction_as_sql_string).to eq('ASC') }
+ it { expect(nulls_last_order.order_direction_as_sql_string).to eq('DESC NULLS LAST') }
+ it { expect(nulls_last_order.reverse.order_direction_as_sql_string).to eq('ASC NULLS FIRST') }
+ end
end
diff --git a/spec/lib/gitlab/pagination/keyset/cursor_based_request_context_spec.rb b/spec/lib/gitlab/pagination/keyset/cursor_based_request_context_spec.rb
new file mode 100644
index 00000000000..79de6f230ec
--- /dev/null
+++ b/spec/lib/gitlab/pagination/keyset/cursor_based_request_context_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Pagination::Keyset::CursorBasedRequestContext do
+ let(:params) { { per_page: 2, cursor: 'eyJuYW1lIjoiR2l0TGFiIEluc3RhbmNlIiwiaWQiOiI1MiIsIl9rZCI6Im4ifQ==', order_by: :name, sort: :asc } }
+ let(:request) { double('request', url: 'http://localhost') }
+ let(:request_context) { double('request_context', header: nil, params: params, request: request) }
+
+ describe '#per_page' do
+ subject(:per_page) { described_class.new(request_context).per_page }
+
+ it { is_expected.to eq 2 }
+ end
+
+ describe '#cursor' do
+ subject(:cursor) { described_class.new(request_context).cursor }
+
+ it { is_expected.to eq 'eyJuYW1lIjoiR2l0TGFiIEluc3RhbmNlIiwiaWQiOiI1MiIsIl9rZCI6Im4ifQ==' }
+ end
+
+ describe '#order_by' do
+ subject(:order_by) { described_class.new(request_context).order_by }
+
+ it { is_expected.to eq({ name: :asc }) }
+ end
+
+ describe '#apply_headers' do
+ let(:request) { double('request', url: "http://#{Gitlab.config.gitlab.host}/api/v4/projects?per_page=3") }
+ let(:params) { { per_page: 3 } }
+ let(:request_context) { double('request_context', header: nil, params: params, request: request) }
+ let(:cursor_for_next_page) { 'eyJuYW1lIjoiSDVicCIsImlkIjoiMjgiLCJfa2QiOiJuIn0=' }
+
+ subject(:apply_headers) { described_class.new(request_context).apply_headers(cursor_for_next_page) }
+
+ it 'sets Link header with same host/path as the original request' do
+ orig_uri = URI.parse(request_context.request.url)
+
+ expect(request_context).to receive(:header).once do |name, header|
+ first_link, _ = /<([^>]+)>; rel="next"/.match(header).captures
+
+ uri = URI.parse(first_link)
+
+ expect(name).to eq('Link')
+ expect(uri.host).to eq(orig_uri.host)
+ expect(uri.path).to eq(orig_uri.path)
+ end
+
+ apply_headers
+ end
+
+ it 'sets Link header with a cursor to the next page' do
+ orig_uri = URI.parse(request_context.request.url)
+
+ expect(request_context).to receive(:header).once do |name, header|
+ first_link, _ = /<([^>]+)>; rel="next"/.match(header).captures
+
+ query = CGI.parse(URI.parse(first_link).query)
+
+ expect(name).to eq('Link')
+ expect(query.except('cursor')).to eq(CGI.parse(orig_uri.query).except('cursor'))
+ expect(query['cursor']).to eq([cursor_for_next_page])
+ end
+
+ apply_headers
+ end
+ end
+end
diff --git a/spec/lib/gitlab/pagination/keyset/cursor_pager_spec.rb b/spec/lib/gitlab/pagination/keyset/cursor_pager_spec.rb
new file mode 100644
index 00000000000..783e728b34c
--- /dev/null
+++ b/spec/lib/gitlab/pagination/keyset/cursor_pager_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Pagination::Keyset::CursorPager do
+ let(:relation) { Group.all.order(:name, :id) }
+ let(:per_page) { 3 }
+ let(:params) { { cursor: nil, per_page: per_page } }
+ let(:request_context) { double('request_context', params: params) }
+ let(:cursor_based_request_context) { Gitlab::Pagination::Keyset::CursorBasedRequestContext.new(request_context) }
+
+ before_all do
+ create_list(:group, 7)
+ end
+
+ describe '#paginate' do
+ subject(:paginated_result) { described_class.new(cursor_based_request_context).paginate(relation) }
+
+ it 'returns the limited relation' do
+ expect(paginated_result).to eq(relation.limit(per_page))
+ end
+ end
+
+ describe '#finalize' do
+ subject(:finalize) do
+ service = described_class.new(cursor_based_request_context)
+ # we need to do this because `finalize` can only be called
+ # after `paginate` is called. Otherwise the `paginator` object won't be set.
+ service.paginate(relation)
+ service.finalize
+ end
+
+ it 'passes information about next page to request' do
+ cursor_for_next_page = relation.keyset_paginate(**params).cursor_for_next_page
+
+ expect_next_instance_of(Gitlab::Pagination::Keyset::HeaderBuilder, request_context) do |builder|
+ expect(builder).to receive(:add_next_page_header).with({ cursor: cursor_for_next_page })
+ end
+
+ finalize
+ end
+
+ context 'when retrieving the last page' do
+ let(:relation) { Group.where('id > ?', Group.maximum(:id) - per_page).order(:name, :id) }
+
+ it 'does not build information about the next page' do
+ expect(Gitlab::Pagination::Keyset::HeaderBuilder).not_to receive(:new)
+
+ finalize
+ end
+ end
+
+ context 'when retrieving an empty page' do
+ let(:relation) { Group.where('id > ?', Group.maximum(:id) + 1).order(:name, :id) }
+
+ it 'does not build information about the next page' do
+ expect(Gitlab::Pagination::Keyset::HeaderBuilder).not_to receive(:new)
+
+ finalize
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/array_scope_columns_spec.rb b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/array_scope_columns_spec.rb
new file mode 100644
index 00000000000..2cebf0d9473
--- /dev/null
+++ b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/array_scope_columns_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::ArrayScopeColumns do
+ let(:columns) { [:relative_position, :id] }
+
+ subject(:array_scope_columns) { described_class.new(columns) }
+
+ it 'builds array column names' do
+ expect(array_scope_columns.array_aggregated_column_names).to eq(%w[array_cte_relative_position_array array_cte_id_array])
+ end
+
+ context 'when no columns are given' do
+ let(:columns) { [] }
+
+ it { expect { array_scope_columns }.to raise_error /No array columns were given/ }
+ end
+end
diff --git a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/column_data_spec.rb b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/column_data_spec.rb
new file mode 100644
index 00000000000..4f200c9096f
--- /dev/null
+++ b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/column_data_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::ColumnData do
+ subject(:column_data) { described_class.new('id', 'issue_id', Issue.arel_table) }
+
+ describe '#array_aggregated_column_name' do
+ it { expect(column_data.array_aggregated_column_name).to eq('issues_id_array') }
+ end
+
+ describe '#projection' do
+ it 'returns the Arel projection for the column with a new alias' do
+ expect(column_data.projection.to_sql).to eq('"issues"."id" AS issue_id')
+ end
+ end
+
+ it 'accepts symbols for original_column_name and as' do
+ column_data = described_class.new(:id, :issue_id, Issue.arel_table)
+
+ expect(column_data.projection.to_sql).to eq('"issues"."id" AS issue_id')
+ end
+end
diff --git a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/order_by_columns_spec.rb b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/order_by_columns_spec.rb
new file mode 100644
index 00000000000..f4fa14e2261
--- /dev/null
+++ b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/order_by_columns_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::OrderByColumns do
+ let(:columns) do
+ [
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: :relative_position,
+ order_expression: Issue.arel_table[:relative_position].desc
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: :id,
+ order_expression: Issue.arel_table[:id].desc
+ )
+ ]
+ end
+
+ subject(:order_by_columns) { described_class.new(columns, Issue.arel_table) }
+
+ describe '#array_aggregated_column_names' do
+ it { expect(order_by_columns.array_aggregated_column_names).to eq(%w[issues_relative_position_array issues_id_array]) }
+ end
+
+ describe '#original_column_names' do
+ it { expect(order_by_columns.original_column_names).to eq(%w[relative_position id]) }
+ end
+
+ describe '#cursor_values' do
+ it 'returns the keyset pagination cursor values from the column arrays as SQL expression' do
+ expect(order_by_columns.cursor_values('tbl')).to eq({
+ "id" => "tbl.issues_id_array[position]",
+ "relative_position" => "tbl.issues_relative_position_array[position]"
+ })
+ end
+ end
+end
diff --git a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb
new file mode 100644
index 00000000000..4ce51e37685
--- /dev/null
+++ b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb
@@ -0,0 +1,225 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder do
+ let_it_be(:two_weeks_ago) { 2.weeks.ago }
+ let_it_be(:three_weeks_ago) { 3.weeks.ago }
+ let_it_be(:four_weeks_ago) { 4.weeks.ago }
+ let_it_be(:five_weeks_ago) { 5.weeks.ago }
+
+ let_it_be(:top_level_group) { create(:group) }
+ let_it_be(:sub_group_1) { create(:group, parent: top_level_group) }
+ let_it_be(:sub_group_2) { create(:group, parent: top_level_group) }
+ let_it_be(:sub_sub_group_1) { create(:group, parent: sub_group_2) }
+
+ let_it_be(:project_1) { create(:project, group: top_level_group) }
+ let_it_be(:project_2) { create(:project, group: top_level_group) }
+
+ let_it_be(:project_3) { create(:project, group: sub_group_1) }
+ let_it_be(:project_4) { create(:project, group: sub_group_2) }
+
+ let_it_be(:project_5) { create(:project, group: sub_sub_group_1) }
+
+ let_it_be(:issues) do
+ [
+ create(:issue, project: project_1, created_at: three_weeks_ago, relative_position: 5),
+ create(:issue, project: project_1, created_at: two_weeks_ago),
+ create(:issue, project: project_2, created_at: two_weeks_ago, relative_position: 15),
+ create(:issue, project: project_2, created_at: two_weeks_ago),
+ create(:issue, project: project_3, created_at: four_weeks_ago),
+ create(:issue, project: project_4, created_at: five_weeks_ago, relative_position: 10),
+ create(:issue, project: project_5, created_at: four_weeks_ago)
+ ]
+ end
+
+ shared_examples 'correct ordering examples' do
+ let(:iterator) do
+ Gitlab::Pagination::Keyset::Iterator.new(
+ scope: scope.limit(batch_size),
+ in_operator_optimization_options: in_operator_optimization_options
+ )
+ end
+
+ it 'returns records in correct order' do
+ all_records = []
+ iterator.each_batch(of: batch_size) do |records|
+ all_records.concat(records)
+ end
+
+ expect(all_records).to eq(expected_order)
+ end
+ end
+
+ context 'when ordering by issues.id DESC' do
+ let(:scope) { Issue.order(id: :desc) }
+ let(:expected_order) { issues.sort_by(&:id).reverse }
+
+ let(:in_operator_optimization_options) do
+ {
+ array_scope: Project.where(namespace_id: top_level_group.self_and_descendants.select(:id)).select(:id),
+ array_mapping_scope: -> (id_expression) { Issue.where(Issue.arel_table[:project_id].eq(id_expression)) },
+ finder_query: -> (id_expression) { Issue.where(Issue.arel_table[:id].eq(id_expression)) }
+ }
+ end
+
+ context 'when iterating records one by one' do
+ let(:batch_size) { 1 }
+
+ it_behaves_like 'correct ordering examples'
+ end
+
+ context 'when iterating records with LIMIT 3' do
+ let(:batch_size) { 3 }
+
+ it_behaves_like 'correct ordering examples'
+ end
+
+ context 'when loading records at once' do
+ let(:batch_size) { issues.size + 1 }
+
+ it_behaves_like 'correct ordering examples'
+ end
+ end
+
+ context 'when ordering by issues.relative_position DESC NULLS LAST, id DESC' do
+ let(:scope) { Issue.order(order) }
+ let(:expected_order) { scope.to_a }
+
+ let(:order) do
+ # NULLS LAST ordering requires custom Order object for keyset pagination:
+ # https://docs.gitlab.com/ee/development/database/keyset_pagination.html#complex-order-configuration
+ Gitlab::Pagination::Keyset::Order.build([
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: :relative_position,
+ column_expression: Issue.arel_table[:relative_position],
+ order_expression: Gitlab::Database.nulls_last_order('relative_position', :desc),
+ reversed_order_expression: Gitlab::Database.nulls_first_order('relative_position', :asc),
+ order_direction: :desc,
+ nullable: :nulls_last,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: :id,
+ order_expression: Issue.arel_table[:id].desc,
+ nullable: :not_nullable,
+ distinct: true
+ )
+ ])
+ end
+
+ let(:in_operator_optimization_options) do
+ {
+ array_scope: Project.where(namespace_id: top_level_group.self_and_descendants.select(:id)).select(:id),
+ array_mapping_scope: -> (id_expression) { Issue.where(Issue.arel_table[:project_id].eq(id_expression)) },
+ finder_query: -> (_relative_position_expression, id_expression) { Issue.where(Issue.arel_table[:id].eq(id_expression)) }
+ }
+ end
+
+ context 'when iterating records one by one' do
+ let(:batch_size) { 1 }
+
+ it_behaves_like 'correct ordering examples'
+ end
+
+ context 'when iterating records with LIMIT 3' do
+ let(:batch_size) { 3 }
+
+ it_behaves_like 'correct ordering examples'
+ end
+ end
+
+ context 'when ordering by issues.created_at DESC, issues.id ASC' do
+ let(:scope) { Issue.order(created_at: :desc, id: :asc) }
+ let(:expected_order) { issues.sort_by { |issue| [issue.created_at.to_f * -1, issue.id] } }
+
+ let(:in_operator_optimization_options) do
+ {
+ array_scope: Project.where(namespace_id: top_level_group.self_and_descendants.select(:id)).select(:id),
+ array_mapping_scope: -> (id_expression) { Issue.where(Issue.arel_table[:project_id].eq(id_expression)) },
+ finder_query: -> (_created_at_expression, id_expression) { Issue.where(Issue.arel_table[:id].eq(id_expression)) }
+ }
+ end
+
+ context 'when iterating records one by one' do
+ let(:batch_size) { 1 }
+
+ it_behaves_like 'correct ordering examples'
+ end
+
+ context 'when iterating records with LIMIT 3' do
+ let(:batch_size) { 3 }
+
+ it_behaves_like 'correct ordering examples'
+ end
+
+ context 'when loading records at once' do
+ let(:batch_size) { issues.size + 1 }
+
+ it_behaves_like 'correct ordering examples'
+ end
+ end
+
+ context 'pagination support' do
+ let(:scope) { Issue.order(id: :desc) }
+ let(:expected_order) { issues.sort_by(&:id).reverse }
+
+ let(:options) do
+ {
+ scope: scope,
+ array_scope: Project.where(namespace_id: top_level_group.self_and_descendants.select(:id)).select(:id),
+ array_mapping_scope: -> (id_expression) { Issue.where(Issue.arel_table[:project_id].eq(id_expression)) },
+ finder_query: -> (id_expression) { Issue.where(Issue.arel_table[:id].eq(id_expression)) }
+ }
+ end
+
+ context 'offset pagination' do
+ subject(:optimized_scope) { described_class.new(**options).execute }
+
+ it 'paginates the scopes' do
+ first_page = optimized_scope.page(1).per(2)
+ expect(first_page).to eq(expected_order[0...2])
+
+ second_page = optimized_scope.page(2).per(2)
+ expect(second_page).to eq(expected_order[2...4])
+
+ third_page = optimized_scope.page(3).per(2)
+ expect(third_page).to eq(expected_order[4...6])
+ end
+ end
+
+ context 'keyset pagination' do
+ def paginator(cursor = nil)
+ scope.keyset_paginate(cursor: cursor, per_page: 2, keyset_order_options: options)
+ end
+
+ it 'paginates correctly' do
+ first_page = paginator.records
+ expect(first_page).to eq(expected_order[0...2])
+
+ cursor_for_page_2 = paginator.cursor_for_next_page
+
+ second_page = paginator(cursor_for_page_2).records
+ expect(second_page).to eq(expected_order[2...4])
+
+ cursor_for_page_3 = paginator(cursor_for_page_2).cursor_for_next_page
+
+ third_page = paginator(cursor_for_page_3).records
+ expect(third_page).to eq(expected_order[4...6])
+ end
+ end
+ end
+
+ it 'raises error when unsupported scope is passed' do
+ scope = Issue.order(Issue.arel_table[:id].lower.desc)
+
+ options = {
+ scope: scope,
+ array_scope: Project.where(namespace_id: top_level_group.self_and_descendants.select(:id)).select(:id),
+ array_mapping_scope: -> (id_expression) { Issue.where(Issue.arel_table[:project_id].eq(id_expression)) },
+ finder_query: -> (id_expression) { Issue.where(Issue.arel_table[:id].eq(id_expression)) }
+ }
+
+ expect { described_class.new(**options).execute }.to raise_error(/The order on the scope does not support keyset pagination/)
+ end
+end
diff --git a/spec/lib/gitlab/pagination/keyset/order_spec.rb b/spec/lib/gitlab/pagination/keyset/order_spec.rb
index b867dd533e0..3c14d91fdfd 100644
--- a/spec/lib/gitlab/pagination/keyset/order_spec.rb
+++ b/spec/lib/gitlab/pagination/keyset/order_spec.rb
@@ -538,6 +538,47 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do
end
it_behaves_like 'cursor attribute examples'
+
+ context 'with projections' do
+ context 'when additional_projections is empty' do
+ let(:scope) { Project.select(:id, :namespace_id) }
+
+ subject(:sql) { order.apply_cursor_conditions(scope, { id: '100' }).to_sql }
+
+ it 'has correct projections' do
+ is_expected.to include('SELECT "projects"."id", "projects"."namespace_id" FROM "projects"')
+ end
+ end
+
+ context 'when there are additional_projections' do
+ let(:order) do
+ order = Gitlab::Pagination::Keyset::Order.build([
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'created_at_field',
+ column_expression: Project.arel_table[:created_at],
+ order_expression: Project.arel_table[:created_at].desc,
+ order_direction: :desc,
+ distinct: false,
+ add_to_projections: true
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id',
+ order_expression: Project.arel_table[:id].desc
+ )
+ ])
+
+ order
+ end
+
+ let(:scope) { Project.select(:id, :namespace_id).reorder(order) }
+
+ subject(:sql) { order.apply_cursor_conditions(scope).to_sql }
+
+ it 'has correct projections' do
+ is_expected.to include('SELECT "projects"."id", "projects"."namespace_id", "projects"."created_at" AS created_at_field FROM "projects"')
+ end
+ end
+ end
end
end
end
diff --git a/spec/lib/gitlab/pagination/offset_pagination_spec.rb b/spec/lib/gitlab/pagination/offset_pagination_spec.rb
index f8d50fbc517..ffecbb06ff8 100644
--- a/spec/lib/gitlab/pagination/offset_pagination_spec.rb
+++ b/spec/lib/gitlab/pagination/offset_pagination_spec.rb
@@ -82,7 +82,7 @@ RSpec.describe Gitlab::Pagination::OffsetPagination do
context 'when the api_kaminari_count_with_limit feature flag is enabled' do
before do
- stub_feature_flags(api_kaminari_count_with_limit: true)
+ stub_feature_flags(api_kaminari_count_with_limit: true, lower_relation_max_count_limit: false)
end
context 'when resources count is less than MAX_COUNT_LIMIT' do
@@ -120,6 +120,41 @@ RSpec.describe Gitlab::Pagination::OffsetPagination do
end
end
+ context 'when lower_relation_max_count_limit FF is enabled' do
+ before do
+ stub_feature_flags(lower_relation_max_count_limit: true)
+ end
+
+ it_behaves_like 'paginated response'
+ it_behaves_like 'response with pagination headers'
+
+ context 'when limit is met' do
+ before do
+ stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_NEW_LOWER_LIMIT", 2)
+ end
+
+ it_behaves_like 'paginated response'
+
+ it 'does not return the X-Total and X-Total-Pages headers' do
+ expect_no_header('X-Total')
+ expect_no_header('X-Total-Pages')
+ expect_header('X-Per-Page', '2')
+ expect_header('X-Page', '1')
+ expect_header('X-Next-Page', '2')
+ expect_header('X-Prev-Page', '')
+
+ expect_header('Link', anything) do |_key, val|
+ expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="first"))
+ expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 2).to_query}>; rel="next"))
+ expect(val).not_to include('rel="last"')
+ expect(val).not_to include('rel="prev"')
+ end
+
+ subject.paginate(resource)
+ end
+ end
+ end
+
it 'does not return the total headers when excluding them' do
expect_no_header('X-Total')
expect_no_header('X-Total-Pages')
diff --git a/spec/lib/gitlab/patch/legacy_database_config_spec.rb b/spec/lib/gitlab/patch/legacy_database_config_spec.rb
new file mode 100644
index 00000000000..e6c0bdbf360
--- /dev/null
+++ b/spec/lib/gitlab/patch/legacy_database_config_spec.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Patch::LegacyDatabaseConfig do
+ it 'module is included' do
+ expect(Rails::Application::Configuration).to include(described_class)
+ end
+
+ describe 'config/database.yml' do
+ let(:configuration) { Rails::Application::Configuration.new(Rails.root) }
+
+ before do
+ # The `AS::ConfigurationFile` calls `read` in `def initialize`
+ # thus we cannot use `expect_next_instance_of`
+ # rubocop:disable RSpec/AnyInstanceOf
+ expect_any_instance_of(ActiveSupport::ConfigurationFile)
+ .to receive(:read).with(Rails.root.join('config/database.yml')).and_return(database_yml)
+ # rubocop:enable RSpec/AnyInstanceOf
+ end
+
+ shared_examples 'hash containing main: connection name' do
+ it 'returns a hash containing only main:' do
+ database_configuration = configuration.database_configuration
+
+ expect(database_configuration).to match(
+ "production" => { "main" => a_hash_including("adapter") },
+ "development" => { "main" => a_hash_including("adapter" => "postgresql") },
+ "test" => { "main" => a_hash_including("adapter" => "postgresql") }
+ )
+ end
+ end
+
+ context 'when a new syntax is used' do
+ let(:database_yml) do
+ <<-EOS
+ production:
+ main:
+ adapter: postgresql
+ encoding: unicode
+ database: gitlabhq_production
+ username: git
+ password: "secure password"
+ host: localhost
+
+ development:
+ main:
+ adapter: postgresql
+ encoding: unicode
+ database: gitlabhq_development
+ username: postgres
+ password: "secure password"
+ host: localhost
+ variables:
+ statement_timeout: 15s
+
+ test: &test
+ main:
+ adapter: postgresql
+ encoding: unicode
+ database: gitlabhq_test
+ username: postgres
+ password:
+ host: localhost
+ prepared_statements: false
+ variables:
+ statement_timeout: 15s
+ EOS
+ end
+
+ include_examples 'hash containing main: connection name'
+
+ it 'configuration is not legacy one' do
+ configuration.database_configuration
+
+ expect(configuration.uses_legacy_database_config).to eq(false)
+ end
+ end
+
+ context 'when a legacy syntax is used' do
+ let(:database_yml) do
+ <<-EOS
+ production:
+ adapter: postgresql
+ encoding: unicode
+ database: gitlabhq_production
+ username: git
+ password: "secure password"
+ host: localhost
+
+ development:
+ adapter: postgresql
+ encoding: unicode
+ database: gitlabhq_development
+ username: postgres
+ password: "secure password"
+ host: localhost
+ variables:
+ statement_timeout: 15s
+
+ test: &test
+ adapter: postgresql
+ encoding: unicode
+ database: gitlabhq_test
+ username: postgres
+ password:
+ host: localhost
+ prepared_statements: false
+ variables:
+ statement_timeout: 15s
+ EOS
+ end
+
+ include_examples 'hash containing main: connection name'
+
+ it 'configuration is legacy' do
+ configuration.database_configuration
+
+ expect(configuration.uses_legacy_database_config).to eq(true)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb
index d343634fb92..aa13660deb4 100644
--- a/spec/lib/gitlab/path_regex_spec.rb
+++ b/spec/lib/gitlab/path_regex_spec.rb
@@ -468,6 +468,7 @@ RSpec.describe Gitlab::PathRegex do
end
let_it_be(:git_paths) { container_paths.map { |path| path + '.git' } }
+ let_it_be(:git_lfs_paths) { git_paths.flat_map { |path| [path + '/info/lfs/', path + '/gitlab-lfs/'] } }
let_it_be(:snippet_paths) { container_paths.grep(%r{snippets/\d}) }
let_it_be(:wiki_git_paths) { (container_paths - snippet_paths).map { |path| path + '.wiki.git' } }
let_it_be(:invalid_git_paths) { invalid_paths.map { |path| path + '.git' } }
@@ -498,6 +499,15 @@ RSpec.describe Gitlab::PathRegex do
end
end
+ describe '.repository_git_lfs_route_regex' do
+ subject { %r{\A#{described_class.repository_git_lfs_route_regex}\z} }
+
+ it 'matches the expected paths' do
+ expect_route_match(git_lfs_paths)
+ expect_no_route_match(container_paths + invalid_paths + git_paths + invalid_git_paths)
+ end
+ end
+
describe '.repository_wiki_git_route_regex' do
subject { %r{\A#{described_class.repository_wiki_git_route_regex}\z} }
diff --git a/spec/lib/gitlab/rack_attack/request_spec.rb b/spec/lib/gitlab/rack_attack/request_spec.rb
new file mode 100644
index 00000000000..3be7ec17e45
--- /dev/null
+++ b/spec/lib/gitlab/rack_attack/request_spec.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::RackAttack::Request do
+ describe 'FILES_PATH_REGEX' do
+ subject { described_class::FILES_PATH_REGEX }
+
+ it { is_expected.to match('/api/v4/projects/1/repository/files/README') }
+ it { is_expected.to match('/api/v4/projects/1/repository/files/README?ref=master') }
+ it { is_expected.to match('/api/v4/projects/1/repository/files/README/blame') }
+ it { is_expected.to match('/api/v4/projects/1/repository/files/README/raw') }
+ it { is_expected.to match('/api/v4/projects/some%2Fnested%2Frepo/repository/files/README') }
+ it { is_expected.not_to match('/api/v4/projects/some/nested/repo/repository/files/README') }
+ end
+end
diff --git a/spec/lib/gitlab/rack_attack_spec.rb b/spec/lib/gitlab/rack_attack_spec.rb
index 788d2eac61f..8f03905e08d 100644
--- a/spec/lib/gitlab/rack_attack_spec.rb
+++ b/spec/lib/gitlab/rack_attack_spec.rb
@@ -10,12 +10,19 @@ RSpec.describe Gitlab::RackAttack, :aggregate_failures do
let(:throttles) do
{
- throttle_unauthenticated: Gitlab::Throttle.unauthenticated_options,
- throttle_authenticated_api: Gitlab::Throttle.authenticated_api_options,
+ throttle_unauthenticated_api: Gitlab::Throttle.options(:api, authenticated: false),
+ throttle_authenticated_api: Gitlab::Throttle.options(:api, authenticated: true),
+ throttle_unauthenticated_web: Gitlab::Throttle.unauthenticated_web_options,
+ throttle_authenticated_web: Gitlab::Throttle.authenticated_web_options,
throttle_product_analytics_collector: { limit: 100, period: 60 },
- throttle_unauthenticated_protected_paths: Gitlab::Throttle.unauthenticated_options,
- throttle_authenticated_protected_paths_api: Gitlab::Throttle.authenticated_api_options,
- throttle_authenticated_protected_paths_web: Gitlab::Throttle.authenticated_web_options
+ throttle_unauthenticated_protected_paths: Gitlab::Throttle.protected_paths_options,
+ throttle_authenticated_protected_paths_api: Gitlab::Throttle.protected_paths_options,
+ throttle_authenticated_protected_paths_web: Gitlab::Throttle.protected_paths_options,
+ throttle_unauthenticated_packages_api: Gitlab::Throttle.options(:packages_api, authenticated: false),
+ throttle_authenticated_packages_api: Gitlab::Throttle.options(:packages_api, authenticated: true),
+ throttle_authenticated_git_lfs: Gitlab::Throttle.throttle_authenticated_git_lfs_options,
+ throttle_unauthenticated_files_api: Gitlab::Throttle.options(:files_api, authenticated: false),
+ throttle_authenticated_files_api: Gitlab::Throttle.options(:files_api, authenticated: true)
}
end
@@ -84,6 +91,15 @@ RSpec.describe Gitlab::RackAttack, :aggregate_failures do
end
end
+ it 'enables dry-runs for `throttle_unauthenticated_api` and `throttle_unauthenticated_web` when selecting `throttle_unauthenticated`' do
+ stub_env('GITLAB_THROTTLE_DRY_RUN', 'throttle_unauthenticated')
+
+ described_class.configure(fake_rack_attack)
+
+ expect(fake_rack_attack).to have_received(:track).with('throttle_unauthenticated_api', throttles[:throttle_unauthenticated_api])
+ expect(fake_rack_attack).to have_received(:track).with('throttle_unauthenticated_web', throttles[:throttle_unauthenticated_web])
+ end
+
context 'user allowlist' do
subject { described_class.user_allowlist }
diff --git a/spec/lib/gitlab/reference_extractor_spec.rb b/spec/lib/gitlab/reference_extractor_spec.rb
index f6e69aa6533..177e9d346b6 100644
--- a/spec/lib/gitlab/reference_extractor_spec.rb
+++ b/spec/lib/gitlab/reference_extractor_spec.rb
@@ -332,14 +332,59 @@ RSpec.describe Gitlab::ReferenceExtractor do
it 'returns visible references of given type' do
expect(subject.references(:issue)).to eq([issue])
end
+ end
- it 'does not increase stateful_not_visible_counter' do
- expect { subject.references(:issue) }.not_to change { subject.stateful_not_visible_counter }
- end
+ it 'does not return any references' do
+ expect(subject.references(:issue)).to be_empty
+ end
+ end
+
+ describe '#all_visible?' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project2) { create(:project) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:issue2) { create(:issue, project: project2) }
+
+ let(:text) { "Ref. #{issue.to_reference} and #{issue2.to_reference(project)}" }
+
+ subject { described_class.new(project, user) }
+
+ before do
+ subject.analyze(text)
end
- it 'increases stateful_not_visible_counter' do
- expect { subject.references(:issue) }.to change { subject.stateful_not_visible_counter }.by(1)
+ it 'returns true if no references were parsed yet' do
+ expect(subject.all_visible?).to be_truthy
+ end
+
+ context 'when references was already called' do
+ let(:membership) { [] }
+
+ before do
+ membership.each { |p| p.add_developer(user) }
+
+ subject.references(:issue)
+ end
+
+ it 'returns false' do
+ expect(subject.all_visible?).to be_falsey
+ end
+
+ context 'when user can access only some references' do
+ let(:membership) { [project] }
+
+ it 'returns false' do
+ expect(subject.all_visible?).to be_falsey
+ end
+ end
+
+ context 'when user can access all references' do
+ let(:membership) { [project, project2] }
+
+ it 'returns true' do
+ expect(subject.all_visible?).to be_truthy
+ end
+ end
end
end
end
diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb
index c1c97e87a4c..f1b4e50b1eb 100644
--- a/spec/lib/gitlab/regex_spec.rb
+++ b/spec/lib/gitlab/regex_spec.rb
@@ -924,4 +924,25 @@ RSpec.describe Gitlab::Regex do
it { is_expected.not_to match('/api/v4/groups/1234/packages/debian/dists/stable/Release.gpg') }
it { is_expected.not_to match('/api/v4/groups/1234/packages/debian/pool/compon/a/pkg/file.name') }
end
+
+ describe '.composer_package_version_regex' do
+ subject { described_class.composer_package_version_regex }
+
+ it { is_expected.to match('v1.2.3') }
+ it { is_expected.to match('v1.2.x') }
+ it { is_expected.to match('v1.2.X') }
+ it { is_expected.to match('1.2.3') }
+ it { is_expected.to match('1') }
+ it { is_expected.to match('v1') }
+ it { is_expected.to match('1.2') }
+ it { is_expected.to match('v1.2') }
+ it { is_expected.not_to match('1.2.3-beta') }
+ it { is_expected.not_to match('1.2.x-beta') }
+ it { is_expected.not_to match('1.2.X-beta') }
+ it { is_expected.not_to match('1.2.3-alpha.3') }
+ it { is_expected.not_to match('1./2.3') }
+ it { is_expected.not_to match('v1./2.3') }
+ it { is_expected.not_to match('../../../../../1.2.3') }
+ it { is_expected.not_to match('%2e%2e%2f1.2.3') }
+ end
end
diff --git a/spec/lib/gitlab/repository_cache/preloader_spec.rb b/spec/lib/gitlab/repository_cache/preloader_spec.rb
new file mode 100644
index 00000000000..8c6618c9f8f
--- /dev/null
+++ b/spec/lib/gitlab/repository_cache/preloader_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::RepositoryCache::Preloader, :use_clean_rails_redis_caching do
+ let(:projects) { create_list(:project, 2, :repository) }
+ let(:repositories) { projects.map(&:repository) }
+
+ describe '#preload' do
+ context 'when the values are already cached' do
+ before do
+ # Warm the cache but use a different model so they are not memoized
+ repos = Project.id_in(projects).order(:id).map(&:repository)
+
+ allow(repos[0].head_tree).to receive(:readme_path).and_return('README.txt')
+ allow(repos[1].head_tree).to receive(:readme_path).and_return('README.md')
+
+ repos.map(&:exists?)
+ repos.map(&:readme_path)
+ end
+
+ it 'prevents individual cache reads for cached methods' do
+ expect(Rails.cache).to receive(:read_multi).once.and_call_original
+
+ described_class.new(repositories).preload(
+ %i[exists? readme_path]
+ )
+
+ expect(Rails.cache).not_to receive(:read)
+ expect(Rails.cache).not_to receive(:write)
+
+ expect(repositories[0].exists?).to eq(true)
+ expect(repositories[0].readme_path).to eq('README.txt')
+
+ expect(repositories[1].exists?).to eq(true)
+ expect(repositories[1].readme_path).to eq('README.md')
+ end
+ end
+
+ context 'when values are not cached' do
+ it 'reads and writes from cache individually' do
+ described_class.new(repositories).preload(
+ %i[exists? has_visible_content?]
+ )
+
+ expect(Rails.cache).to receive(:read).exactly(4).times
+ expect(Rails.cache).to receive(:write).exactly(4).times
+
+ repositories.each(&:exists?)
+ repositories.each(&:has_visible_content?)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb
index b8972f28889..27d65e14347 100644
--- a/spec/lib/gitlab/search_results_spec.rb
+++ b/spec/lib/gitlab/search_results_spec.rb
@@ -148,13 +148,13 @@ RSpec.describe Gitlab::SearchResults do
end
end
- it 'includes merge requests from source and target projects' do
+ it 'does not include merge requests from source projects' do
forked_project = fork_project(project, user)
merge_request_2 = create(:merge_request, target_project: project, source_project: forked_project, title: 'foo')
results = described_class.new(user, 'foo', Project.where(id: forked_project.id))
- expect(results.objects('merge_requests')).to include merge_request_2
+ expect(results.objects('merge_requests')).not_to include merge_request_2
end
describe '#merge_requests' do
diff --git a/spec/lib/gitlab/seeder_spec.rb b/spec/lib/gitlab/seeder_spec.rb
new file mode 100644
index 00000000000..877461a7064
--- /dev/null
+++ b/spec/lib/gitlab/seeder_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Seeder do
+ describe '.quiet' do
+ it 'disables mail deliveries' do
+ expect(ActionMailer::Base.perform_deliveries).to eq(true)
+
+ described_class.quiet do
+ expect(ActionMailer::Base.perform_deliveries).to eq(false)
+ end
+
+ expect(ActionMailer::Base.perform_deliveries).to eq(true)
+ end
+
+ it 'disables new note notifications' do
+ note = create(:note_on_issue)
+
+ notification_service = NotificationService.new
+
+ expect(notification_service).to receive(:send_new_note_notifications).twice
+
+ notification_service.new_note(note)
+
+ described_class.quiet do
+ expect(notification_service.new_note(note)).to eq(nil)
+ end
+
+ notification_service.new_note(note)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb b/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb
index 3dd5ac8ee6c..e818b03cf75 100644
--- a/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb
+++ b/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb
@@ -48,6 +48,18 @@ RSpec.describe Gitlab::SidekiqCluster::CLI do
cli.run(%w(*))
end
+ it 'raises an error when the arguments contain newlines' do
+ invalid_arguments = [
+ ["foo\n"],
+ ["foo\r"],
+ %W[foo b\nar]
+ ]
+
+ invalid_arguments.each do |arguments|
+ expect { cli.run(arguments) }.to raise_error(described_class::CommandError)
+ end
+ end
+
context 'with --negate flag' do
it 'starts Sidekiq workers for all queues in all_queues.yml except the ones in argv' do
expect(Gitlab::SidekiqConfig::CliMethods).to receive(:worker_queues).and_return(['baz'])
diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb
index d67cb95f483..cc69a11f7f8 100644
--- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb
@@ -9,7 +9,14 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi
described_class.new(job, queue)
end
- let(:job) { { 'class' => 'AuthorizedProjectsWorker', 'args' => [1], 'jid' => '123' } }
+ let(:wal_locations) do
+ {
+ main: '0/D525E3A8',
+ ci: 'AB/12345'
+ }
+ end
+
+ let(:job) { { 'class' => 'AuthorizedProjectsWorker', 'args' => [1], 'jid' => '123', 'wal_locations' => wal_locations } }
let(:queue) { 'authorized_projects' }
let(:idempotency_key) do
@@ -74,13 +81,39 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi
context 'when there was no job in the queue yet' do
it { expect(duplicate_job.check!).to eq('123') }
- it "adds a key with ttl set to #{described_class::DUPLICATE_KEY_TTL}" do
+ it "adds a idempotency key with ttl set to #{described_class::DUPLICATE_KEY_TTL}" do
expect { duplicate_job.check! }
.to change { read_idempotency_key_with_ttl(idempotency_key) }
.from([nil, -2])
.to(['123', be_within(1).of(described_class::DUPLICATE_KEY_TTL)])
end
+ context 'when wal locations is not empty' do
+ it "adds a existing wal locations key with ttl set to #{described_class::DUPLICATE_KEY_TTL}" do
+ expect { duplicate_job.check! }
+ .to change { read_idempotency_key_with_ttl(existing_wal_location_key(idempotency_key, :main)) }
+ .from([nil, -2])
+ .to([wal_locations[:main], be_within(1).of(described_class::DUPLICATE_KEY_TTL)])
+ .and change { read_idempotency_key_with_ttl(existing_wal_location_key(idempotency_key, :ci)) }
+ .from([nil, -2])
+ .to([wal_locations[:ci], be_within(1).of(described_class::DUPLICATE_KEY_TTL)])
+ end
+ end
+
+ context 'when preserve_latest_wal_locations_for_idempotent_jobs feature flag is disabled' do
+ before do
+ stub_feature_flags(preserve_latest_wal_locations_for_idempotent_jobs: false)
+ end
+
+ it "does not change the existing wal locations key's TTL" do
+ expect { duplicate_job.check! }
+ .to not_change { read_idempotency_key_with_ttl(existing_wal_location_key(idempotency_key, :main)) }
+ .from([nil, -2])
+ .and not_change { read_idempotency_key_with_ttl(existing_wal_location_key(idempotency_key, :ci)) }
+ .from([nil, -2])
+ end
+ end
+
it "adds the idempotency key to the jobs payload" do
expect { duplicate_job.check! }.to change { job['idempotency_key'] }.from(nil).to(idempotency_key)
end
@@ -89,6 +122,9 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi
context 'when there was already a job with same arguments in the same queue' do
before do
set_idempotency_key(idempotency_key, 'existing-key')
+ wal_locations.each do |config_name, location|
+ set_idempotency_key(existing_wal_location_key(idempotency_key, config_name), location)
+ end
end
it { expect(duplicate_job.check!).to eq('existing-key') }
@@ -99,6 +135,14 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi
.from(['existing-key', -1])
end
+ it "does not change the existing wal locations key's TTL" do
+ expect { duplicate_job.check! }
+ .to not_change { read_idempotency_key_with_ttl(existing_wal_location_key(idempotency_key, :main)) }
+ .from([wal_locations[:main], -1])
+ .and not_change { read_idempotency_key_with_ttl(existing_wal_location_key(idempotency_key, :ci)) }
+ .from([wal_locations[:ci], -1])
+ end
+
it 'sets the existing jid' do
duplicate_job.check!
@@ -107,6 +151,117 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi
end
end
+ describe '#update_latest_wal_location!' do
+ let(:offset) { '1024' }
+
+ before do
+ allow(duplicate_job).to receive(:pg_wal_lsn_diff).with(:main).and_return(offset)
+ allow(duplicate_job).to receive(:pg_wal_lsn_diff).with(:ci).and_return(offset)
+ end
+
+ shared_examples 'updates wal location' do
+ it 'updates a wal location to redis with an offset' do
+ expect { duplicate_job.update_latest_wal_location! }
+ .to change { read_range_from_redis(wal_location_key(idempotency_key, :main)) }
+ .from(existing_wal_with_offset[:main])
+ .to(new_wal_with_offset[:main])
+ .and change { read_range_from_redis(wal_location_key(idempotency_key, :ci)) }
+ .from(existing_wal_with_offset[:ci])
+ .to(new_wal_with_offset[:ci])
+ end
+ end
+
+ context 'when preserve_latest_wal_locations_for_idempotent_jobs feature flag is disabled' do
+ before do
+ stub_feature_flags(preserve_latest_wal_locations_for_idempotent_jobs: false)
+ end
+
+ it "doesn't call Sidekiq.redis" do
+ expect(Sidekiq).not_to receive(:redis)
+
+ duplicate_job.update_latest_wal_location!
+ end
+
+ it "doesn't update a wal location to redis with an offset" do
+ expect { duplicate_job.update_latest_wal_location! }
+ .to not_change { read_range_from_redis(wal_location_key(idempotency_key, :main)) }
+ .from([])
+ .and not_change { read_range_from_redis(wal_location_key(idempotency_key, :ci)) }
+ .from([])
+ end
+ end
+
+ context "when the key doesn't exists in redis" do
+ include_examples 'updates wal location' do
+ let(:existing_wal_with_offset) { { main: [], ci: [] } }
+ let(:new_wal_with_offset) { wal_locations.transform_values { |v| [v, offset] } }
+ end
+ end
+
+ context "when the key exists in redis" do
+ let(:existing_offset) { '1023'}
+ let(:existing_wal_locations) do
+ {
+ main: '0/D525E3NM',
+ ci: 'AB/111112'
+ }
+ end
+
+ before do
+ rpush_to_redis_key(wal_location_key(idempotency_key, :main), existing_wal_locations[:main], existing_offset)
+ rpush_to_redis_key(wal_location_key(idempotency_key, :ci), existing_wal_locations[:ci], existing_offset)
+ end
+
+ context "when the new offset is bigger then the existing one" do
+ include_examples 'updates wal location' do
+ let(:existing_wal_with_offset) { existing_wal_locations.transform_values { |v| [v, existing_offset] } }
+ let(:new_wal_with_offset) { wal_locations.transform_values { |v| [v, offset] } }
+ end
+ end
+
+ context "when the old offset is not bigger then the existing one" do
+ let(:existing_offset) { offset }
+
+ it "does not update a wal location to redis with an offset" do
+ expect { duplicate_job.update_latest_wal_location! }
+ .to not_change { read_range_from_redis(wal_location_key(idempotency_key, :main)) }
+ .from([existing_wal_locations[:main], existing_offset])
+ .and not_change { read_range_from_redis(wal_location_key(idempotency_key, :ci)) }
+ .from([existing_wal_locations[:ci], existing_offset])
+ end
+ end
+ end
+ end
+
+ describe '#latest_wal_locations' do
+ context 'when job was deduplicated and wal locations were already persisted' do
+ before do
+ rpush_to_redis_key(wal_location_key(idempotency_key, :main), wal_locations[:main], 1024)
+ rpush_to_redis_key(wal_location_key(idempotency_key, :ci), wal_locations[:ci], 1024)
+ end
+
+ it { expect(duplicate_job.latest_wal_locations).to eq(wal_locations) }
+ end
+
+ context 'when job is not deduplication and wal locations were not persisted' do
+ it { expect(duplicate_job.latest_wal_locations).to be_empty }
+ end
+
+ context 'when preserve_latest_wal_locations_for_idempotent_jobs feature flag is disabled' do
+ before do
+ stub_feature_flags(preserve_latest_wal_locations_for_idempotent_jobs: false)
+ end
+
+ it "doesn't call Sidekiq.redis" do
+ expect(Sidekiq).not_to receive(:redis)
+
+ duplicate_job.latest_wal_locations
+ end
+
+ it { expect(duplicate_job.latest_wal_locations).to eq({}) }
+ end
+ end
+
describe '#delete!' do
context "when we didn't track the definition" do
it { expect { duplicate_job.delete! }.not_to raise_error }
@@ -115,14 +270,79 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi
context 'when the key exists in redis' do
before do
set_idempotency_key(idempotency_key, 'existing-jid')
+ wal_locations.each do |config_name, location|
+ set_idempotency_key(existing_wal_location_key(idempotency_key, config_name), location)
+ set_idempotency_key(wal_location_key(idempotency_key, config_name), location)
+ end
end
shared_examples 'deleting the duplicate job' do
- it 'removes the key from redis' do
- expect { duplicate_job.delete! }
- .to change { read_idempotency_key_with_ttl(idempotency_key) }
- .from(['existing-jid', -1])
- .to([nil, -2])
+ shared_examples 'deleting keys from redis' do |key_name|
+ it "removes the #{key_name} from redis" do
+ expect { duplicate_job.delete! }
+ .to change { read_idempotency_key_with_ttl(key) }
+ .from([from_value, -1])
+ .to([nil, -2])
+ end
+ end
+
+ shared_examples 'does not delete key from redis' do |key_name|
+ it "does not remove the #{key_name} from redis" do
+ expect { duplicate_job.delete! }
+ .to not_change { read_idempotency_key_with_ttl(key) }
+ .from([from_value, -1])
+ end
+ end
+
+ it_behaves_like 'deleting keys from redis', 'idempotent key' do
+ let(:key) { idempotency_key }
+ let(:from_value) { 'existing-jid' }
+ end
+
+ it_behaves_like 'deleting keys from redis', 'existing wal location keys for main database' do
+ let(:key) { existing_wal_location_key(idempotency_key, :main) }
+ let(:from_value) { wal_locations[:main] }
+ end
+
+ it_behaves_like 'deleting keys from redis', 'existing wal location keys for ci database' do
+ let(:key) { existing_wal_location_key(idempotency_key, :ci) }
+ let(:from_value) { wal_locations[:ci] }
+ end
+
+ it_behaves_like 'deleting keys from redis', 'latest wal location keys for main database' do
+ let(:key) { wal_location_key(idempotency_key, :main) }
+ let(:from_value) { wal_locations[:main] }
+ end
+
+ it_behaves_like 'deleting keys from redis', 'latest wal location keys for ci database' do
+ let(:key) { wal_location_key(idempotency_key, :ci) }
+ let(:from_value) { wal_locations[:ci] }
+ end
+
+ context 'when preserve_latest_wal_locations_for_idempotent_jobs feature flag is disabled' do
+ before do
+ stub_feature_flags(preserve_latest_wal_locations_for_idempotent_jobs: false)
+ end
+
+ it_behaves_like 'does not delete key from redis', 'latest wal location keys for main database' do
+ let(:key) { existing_wal_location_key(idempotency_key, :main) }
+ let(:from_value) { wal_locations[:main] }
+ end
+
+ it_behaves_like 'does not delete key from redis', 'latest wal location keys for ci database' do
+ let(:key) { existing_wal_location_key(idempotency_key, :ci) }
+ let(:from_value) { wal_locations[:ci] }
+ end
+
+ it_behaves_like 'does not delete key from redis', 'latest wal location keys for main database' do
+ let(:key) { wal_location_key(idempotency_key, :main) }
+ let(:from_value) { wal_locations[:main] }
+ end
+
+ it_behaves_like 'does not delete key from redis', 'latest wal location keys for ci database' do
+ let(:key) { wal_location_key(idempotency_key, :ci) }
+ let(:from_value) { wal_locations[:ci] }
+ end
end
end
@@ -254,10 +474,22 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi
end
end
+ def existing_wal_location_key(idempotency_key, config_name)
+ "#{idempotency_key}:#{config_name}:existing_wal_location"
+ end
+
+ def wal_location_key(idempotency_key, config_name)
+ "#{idempotency_key}:#{config_name}:wal_location"
+ end
+
def set_idempotency_key(key, value = '1')
Sidekiq.redis { |r| r.set(key, value) }
end
+ def rpush_to_redis_key(key, wal, offset)
+ Sidekiq.redis { |r| r.rpush(key, [wal, offset]) }
+ end
+
def read_idempotency_key_with_ttl(key)
Sidekiq.redis do |redis|
redis.pipelined do |p|
@@ -266,4 +498,10 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi
end
end
end
+
+ def read_range_from_redis(key)
+ Sidekiq.redis do |redis|
+ redis.lrange(key, 0, -1)
+ end
+ end
end
diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executed_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executed_spec.rb
index b3d463b6f6b..9772255fc50 100644
--- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executed_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executed_spec.rb
@@ -7,6 +7,10 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::Strategies::UntilExecut
describe '#perform' do
let(:proc) { -> {} }
+ before do
+ allow(fake_duplicate_job).to receive(:latest_wal_locations).and_return( {} )
+ end
+
it 'deletes the lock after executing' do
expect(proc).to receive(:call).ordered
expect(fake_duplicate_job).to receive(:delete!).ordered
diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb
index d45b6c5fcd1..c4045b8c63b 100644
--- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb
@@ -7,6 +7,10 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::Strategies::UntilExecut
describe '#perform' do
let(:proc) { -> {} }
+ before do
+ allow(fake_duplicate_job).to receive(:latest_wal_locations).and_return( {} )
+ end
+
it 'deletes the lock before executing' do
expect(fake_duplicate_job).to receive(:delete!).ordered
expect(proc).to receive(:call).ordered
diff --git a/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb b/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb
index 440eca10a88..abbfb9cd9fa 100644
--- a/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/size_limiter/validator_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do
+RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator, :aggregate_failures do
let(:base_payload) do
{
"class" => "ARandomWorker",
@@ -31,10 +31,35 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do
end
before do
+ # Settings aren't in the database in specs, but stored in memory, this is fine
+ # for these tests.
+ allow(Gitlab::CurrentSettings).to receive(:current_application_settings?).and_return(true)
stub_const("TestSizeLimiterWorker", worker_class)
end
describe '#initialize' do
+ context 'configuration from application settings' do
+ let(:validator) { described_class.new(worker_class, job_payload) }
+
+ it 'has the right defaults' do
+ expect(validator.mode).to eq(described_class::COMPRESS_MODE)
+ expect(validator.compression_threshold).to eq(described_class::DEFAULT_COMPRESSION_THRESHOLD_BYTES)
+ expect(validator.size_limit).to eq(described_class::DEFAULT_SIZE_LIMIT)
+ end
+
+ it 'allows configuration through application settings' do
+ stub_application_setting(
+ sidekiq_job_limiter_mode: 'track',
+ sidekiq_job_limiter_compression_threshold_bytes: 1,
+ sidekiq_job_limiter_limit_bytes: 2
+ )
+
+ expect(validator.mode).to eq(described_class::TRACK_MODE)
+ expect(validator.compression_threshold).to eq(1)
+ expect(validator.size_limit).to eq(2)
+ end
+ end
+
context 'when the input mode is valid' do
it 'does not log a warning message' do
expect(::Sidekiq.logger).not_to receive(:warn)
@@ -58,7 +83,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do
it 'defaults to track mode' do
expect(::Sidekiq.logger).not_to receive(:warn)
- validator = described_class.new(TestSizeLimiterWorker, job_payload)
+ validator = described_class.new(TestSizeLimiterWorker, job_payload, mode: nil)
expect(validator.mode).to eql('track')
end
@@ -74,10 +99,12 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do
end
context 'when the size input is invalid' do
- it 'defaults to 0 and logs a warning message' do
+ it 'logs a warning message' do
expect(::Sidekiq.logger).to receive(:warn).with('Invalid Sidekiq size limiter limit: -1')
- described_class.new(TestSizeLimiterWorker, job_payload, size_limit: -1)
+ validator = described_class.new(TestSizeLimiterWorker, job_payload, size_limit: -1)
+
+ expect(validator.size_limit).to be(0)
end
end
@@ -85,9 +112,9 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do
it 'defaults to 0' do
expect(::Sidekiq.logger).not_to receive(:warn)
- validator = described_class.new(TestSizeLimiterWorker, job_payload)
+ validator = described_class.new(TestSizeLimiterWorker, job_payload, size_limit: nil)
- expect(validator.size_limit).to be(0)
+ expect(validator.size_limit).to be(described_class::DEFAULT_SIZE_LIMIT)
end
end
@@ -258,6 +285,22 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do
end
end
+ context 'when job size is bigger than compression threshold and size limit is 0' do
+ let(:size_limit) { 0 }
+ let(:args) { { a: 'a' * 300 } }
+ let(:job) { job_payload(args) }
+
+ it 'does not raise an exception and compresses the arguments' do
+ expect(::Gitlab::SidekiqMiddleware::SizeLimiter::Compressor).to receive(:compress).with(
+ job, Sidekiq.dump_json(args)
+ ).and_return('a' * 40)
+
+ expect do
+ validate.call(TestSizeLimiterWorker, job)
+ end.not_to raise_error
+ end
+ end
+
context 'when the job was already compressed' do
let(:job) do
job_payload({ a: 'a' * 10 })
@@ -275,7 +318,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do
let(:args) { { a: 'a' * 3000 } }
let(:job) { job_payload(args) }
- it 'does not raise an exception' do
+ it 'raises an exception' do
expect(::Gitlab::SidekiqMiddleware::SizeLimiter::Compressor).to receive(:compress).with(
job, Sidekiq.dump_json(args)
).and_return('a' * 60)
@@ -284,24 +327,46 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do
validate.call(TestSizeLimiterWorker, job)
end.to raise_error(Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError)
end
+
+ it 'does not raise an exception when the worker allows big payloads' do
+ worker_class.big_payload!
+
+ expect(::Gitlab::SidekiqMiddleware::SizeLimiter::Compressor).to receive(:compress).with(
+ job, Sidekiq.dump_json(args)
+ ).and_return('a' * 60)
+
+ expect do
+ validate.call(TestSizeLimiterWorker, job)
+ end.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) } }
+ describe '.validate!' do
+ let(:validate) { ->(worker_class, job) { described_class.validate!(worker_class, job) } }
+ it_behaves_like 'validate limit job payload size' do
before do
- stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_MODE', mode)
- stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_LIMIT_BYTES', size_limit)
- stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_COMPRESSION_THRESHOLD_BYTES', compression_threshold)
+ stub_application_setting(
+ sidekiq_job_limiter_mode: mode,
+ sidekiq_job_limiter_compression_threshold_bytes: compression_threshold,
+ sidekiq_job_limiter_limit_bytes: size_limit
+ )
end
+ end
- it_behaves_like 'validate limit job payload size'
+ it "skips background migrations" do
+ expect(described_class).not_to receive(:new)
+
+ described_class::EXEMPT_WORKER_NAMES.each do |class_name|
+ validate.call(class_name.constantize, job_payload)
+ end
end
+ end
- context 'when creating an instance with the related ENV variables' do
+ describe '#validate!' do
+ context 'when creating an instance with the related configuration variables' do
let(:validate) do
->(worker_clas, job) do
described_class.new(worker_class, job).validate!
@@ -309,9 +374,11 @@ RSpec.describe Gitlab::SidekiqMiddleware::SizeLimiter::Validator do
end
before do
- stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_MODE', mode)
- stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_LIMIT_BYTES', size_limit)
- stub_env('GITLAB_SIDEKIQ_SIZE_LIMITER_COMPRESSION_THRESHOLD_BYTES', compression_threshold)
+ stub_application_setting(
+ sidekiq_job_limiter_mode: mode,
+ sidekiq_job_limiter_compression_threshold_bytes: compression_threshold,
+ sidekiq_job_limiter_limit_bytes: size_limit
+ )
end
it_behaves_like 'validate limit job payload size'
diff --git a/spec/lib/gitlab/sidekiq_middleware_spec.rb b/spec/lib/gitlab/sidekiq_middleware_spec.rb
index 5e4e79e818e..8285cf960d2 100644
--- a/spec/lib/gitlab/sidekiq_middleware_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware_spec.rb
@@ -66,12 +66,12 @@ RSpec.describe Gitlab::SidekiqMiddleware do
::Gitlab::SidekiqMiddleware::BatchLoader,
::Labkit::Middleware::Sidekiq::Server,
::Gitlab::SidekiqMiddleware::InstrumentationLogger,
- ::Gitlab::Database::LoadBalancing::SidekiqServerMiddleware,
::Gitlab::SidekiqMiddleware::AdminMode::Server,
::Gitlab::SidekiqVersioning::Middleware,
::Gitlab::SidekiqStatus::ServerMiddleware,
::Gitlab::SidekiqMiddleware::WorkerContext::Server,
- ::Gitlab::SidekiqMiddleware::DuplicateJobs::Server
+ ::Gitlab::SidekiqMiddleware::DuplicateJobs::Server,
+ ::Gitlab::Database::LoadBalancing::SidekiqServerMiddleware
]
end
@@ -177,12 +177,12 @@ RSpec.describe Gitlab::SidekiqMiddleware do
[
::Gitlab::SidekiqMiddleware::WorkerContext::Client,
::Labkit::Middleware::Sidekiq::Client,
+ ::Gitlab::Database::LoadBalancing::SidekiqClientMiddleware,
::Gitlab::SidekiqMiddleware::DuplicateJobs::Client,
::Gitlab::SidekiqStatus::ClientMiddleware,
::Gitlab::SidekiqMiddleware::AdminMode::Client,
::Gitlab::SidekiqMiddleware::SizeLimiter::Client,
- ::Gitlab::SidekiqMiddleware::ClientMetrics,
- ::Gitlab::Database::LoadBalancing::SidekiqClientMiddleware
+ ::Gitlab::SidekiqMiddleware::ClientMetrics
]
end
diff --git a/spec/lib/gitlab/sidekiq_queue_spec.rb b/spec/lib/gitlab/sidekiq_queue_spec.rb
index 2ab32657f0e..5e91282612e 100644
--- a/spec/lib/gitlab/sidekiq_queue_spec.rb
+++ b/spec/lib/gitlab/sidekiq_queue_spec.rb
@@ -4,15 +4,15 @@ require 'spec_helper'
RSpec.describe Gitlab::SidekiqQueue, :clean_gitlab_redis_queues do
around do |example|
- Sidekiq::Queue.new('authorized_projects').clear
+ Sidekiq::Queue.new('default').clear
Sidekiq::Testing.disable!(&example)
- Sidekiq::Queue.new('authorized_projects').clear
+ Sidekiq::Queue.new('default').clear
end
- def add_job(user, args)
+ def add_job(args, user:, klass: 'AuthorizedProjectsWorker')
Sidekiq::Client.push(
- 'class' => 'AuthorizedProjectsWorker',
- 'queue' => 'authorized_projects',
+ 'class' => klass,
+ 'queue' => 'default',
'args' => args,
'meta.user' => user.username
)
@@ -20,13 +20,13 @@ RSpec.describe Gitlab::SidekiqQueue, :clean_gitlab_redis_queues do
describe '#drop_jobs!' do
shared_examples 'queue processing' do
- let(:sidekiq_queue) { described_class.new('authorized_projects') }
+ let(:sidekiq_queue) { described_class.new('default') }
let_it_be(:sidekiq_queue_user) { create(:user) }
before do
- add_job(create(:user), [1])
- add_job(sidekiq_queue_user, [2])
- add_job(sidekiq_queue_user, [3])
+ add_job([1], user: create(:user))
+ add_job([2], user: sidekiq_queue_user, klass: 'MergeWorker')
+ add_job([3], user: sidekiq_queue_user)
end
context 'when the queue is not processed in time' do
@@ -68,11 +68,19 @@ RSpec.describe Gitlab::SidekiqQueue, :clean_gitlab_redis_queues do
end
end
+ context 'when there are jobs matching the class name' do
+ include_examples 'queue processing' do
+ let(:search_metadata) { { user: sidekiq_queue_user.username, worker_class: 'AuthorizedProjectsWorker' } }
+ let(:timeout_deleted) { 1 }
+ let(:no_timeout_deleted) { 1 }
+ end
+ end
+
context 'when there are no valid metadata keys passed' do
it 'raises NoMetadataError' do
- add_job(create(:user), [1])
+ add_job([1], user: create(:user))
- expect { described_class.new('authorized_projects').drop_jobs!({ username: 'sidekiq_queue_user' }, timeout: 1) }
+ expect { described_class.new('default').drop_jobs!({ username: 'sidekiq_queue_user' }, timeout: 1) }
.to raise_error(described_class::NoMetadataError)
end
end
diff --git a/spec/lib/gitlab/tracking/snowplow_schema_validation_spec.rb b/spec/lib/gitlab/tracking/snowplow_schema_validation_spec.rb
new file mode 100644
index 00000000000..32c601ae47d
--- /dev/null
+++ b/spec/lib/gitlab/tracking/snowplow_schema_validation_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Snowplow Schema Validation' do
+ context 'snowplow events definition' do
+ shared_examples 'matches schema' do
+ it 'conforms schema json' do
+ paths = Dir[Rails.root.join(yaml_path)]
+
+ events = paths.each_with_object([]) do |path, metrics|
+ metrics.push(
+ YAML.safe_load(File.read(path), aliases: true)
+ )
+ end
+
+ expect(events).to all match_schema(Rails.root.join('config/events/schema.json'))
+ end
+ end
+
+ describe 'matches the schema for CE' do
+ let(:yaml_path) { 'config/events/*.yml' }
+
+ it_behaves_like 'matches schema'
+ end
+
+ describe 'matches the schema for EE' do
+ let(:yaml_path) { 'ee/config/events/*.yml' }
+
+ it_behaves_like 'matches schema'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/tracking/standard_context_spec.rb b/spec/lib/gitlab/tracking/standard_context_spec.rb
index a0fb6a270a5..ca7a6b6b1c3 100644
--- a/spec/lib/gitlab/tracking/standard_context_spec.rb
+++ b/spec/lib/gitlab/tracking/standard_context_spec.rb
@@ -87,8 +87,26 @@ RSpec.describe Gitlab::Tracking::StandardContext do
end
end
- it 'does not contain any ids' do
- expect(snowplow_context.to_json[:data].keys).not_to include(:user_id, :project_id, :namespace_id)
+ it 'does not contain user id' do
+ expect(snowplow_context.to_json[:data].keys).not_to include(:user_id)
+ end
+
+ it 'contains namespace and project ids' do
+ expect(snowplow_context.to_json[:data].keys).to include(:project_id, :namespace_id)
+ end
+
+ it 'accepts just project id as integer' do
+ expect { described_class.new(project: 1).to_context }.not_to raise_error
+ end
+
+ context 'without add_namespace_and_project_to_snowplow_tracking feature' do
+ before do
+ stub_feature_flags(add_namespace_and_project_to_snowplow_tracking: false)
+ end
+
+ it 'does not contain any ids' do
+ expect(snowplow_context.to_json[:data].keys).not_to include(:user_id, :project_id, :namespace_id)
+ end
end
end
end
diff --git a/spec/lib/gitlab/tracking_spec.rb b/spec/lib/gitlab/tracking_spec.rb
index 994316f38ee..02e66458f46 100644
--- a/spec/lib/gitlab/tracking_spec.rb
+++ b/spec/lib/gitlab/tracking_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe Gitlab::Tracking do
described_class.instance_variable_set("@snowplow", nil)
end
- describe '.snowplow_options' do
+ describe '.options' do
it 'returns useful client options' do
expected_fields = {
namespace: 'gl',
@@ -22,13 +22,13 @@ RSpec.describe Gitlab::Tracking do
linkClickTracking: true
}
- expect(subject.snowplow_options(nil)).to match(expected_fields)
+ expect(subject.options(nil)).to match(expected_fields)
end
it 'when feature flag is disabled' do
stub_feature_flags(additional_snowplow_tracking: false)
- expect(subject.snowplow_options(nil)).to include(
+ expect(subject.options(nil)).to include(
formTracking: false,
linkClickTracking: false
)
@@ -47,7 +47,7 @@ RSpec.describe Gitlab::Tracking do
it "delegates to #{klass} destination" do
other_context = double(:context)
- project = double(:project)
+ project = build_stubbed(:project)
user = double(:user)
expect(Gitlab::Tracking::StandardContext)
diff --git a/spec/lib/gitlab/url_builder_spec.rb b/spec/lib/gitlab/url_builder_spec.rb
index b359eb422d7..8e372ba795b 100644
--- a/spec/lib/gitlab/url_builder_spec.rb
+++ b/spec/lib/gitlab/url_builder_spec.rb
@@ -69,6 +69,27 @@ RSpec.describe Gitlab::UrlBuilder do
end
end
+ context 'when passing a compare' do
+ # NOTE: The Compare requires an actual repository, which isn't available
+ # with the `build_stubbed` strategy used by the table tests above
+ let_it_be(:compare) { create(:compare) }
+ let_it_be(:project) { compare.project }
+
+ it 'returns the full URL' do
+ expect(subject.build(compare)).to eq("#{Gitlab.config.gitlab.url}/#{project.full_path}/-/compare/#{compare.base_commit_sha}...#{compare.head_commit_sha}")
+ end
+
+ it 'returns only the path if only_path is given' do
+ expect(subject.build(compare, only_path: true)).to eq("/#{project.full_path}/-/compare/#{compare.base_commit_sha}...#{compare.head_commit_sha}")
+ end
+
+ it 'returns an empty string for missing project' do
+ expect(compare).to receive(:project).and_return(nil)
+
+ expect(subject.build(compare)).to eq('')
+ end
+ end
+
context 'when passing a commit without a project' do
let(:commit) { build_stubbed(:commit) }
diff --git a/spec/lib/gitlab/usage/metric_definition_spec.rb b/spec/lib/gitlab/usage/metric_definition_spec.rb
index 1ae8a0881ef..6406c0b5458 100644
--- a/spec/lib/gitlab/usage/metric_definition_spec.rb
+++ b/spec/lib/gitlab/usage/metric_definition_spec.rb
@@ -9,7 +9,8 @@ RSpec.describe Gitlab::Usage::MetricDefinition do
value_type: 'string',
product_category: 'collection',
product_stage: 'growth',
- status: 'data_available',
+ status: 'active',
+ milestone: '14.1',
default_generation: 'generation_1',
key_path: 'uuid',
product_group: 'group::product analytics',
@@ -64,6 +65,7 @@ RSpec.describe Gitlab::Usage::MetricDefinition do
:value_type | nil
:value_type | 'test'
:status | nil
+ :milestone | nil
:data_category | nil
:key_path | nil
:product_group | nil
@@ -127,9 +129,7 @@ RSpec.describe Gitlab::Usage::MetricDefinition do
where(:status, :skip_validation?) do
'deprecated' | true
'removed' | true
- 'data_available' | false
- 'implemented' | false
- 'not_used' | false
+ 'active' | false
end
with_them do
@@ -191,7 +191,8 @@ RSpec.describe Gitlab::Usage::MetricDefinition do
value_type: 'string',
product_category: 'collection',
product_stage: 'growth',
- status: 'data_available',
+ status: 'active',
+ milestone: '14.1',
default_generation: 'generation_1',
key_path: 'counter.category.event',
product_group: 'group::product analytics',
diff --git a/spec/lib/gitlab/usage/metric_spec.rb b/spec/lib/gitlab/usage/metric_spec.rb
index d83f59e4a7d..ea8d1a135a6 100644
--- a/spec/lib/gitlab/usage/metric_spec.rb
+++ b/spec/lib/gitlab/usage/metric_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe Gitlab::Usage::Metric do
product_group: "group::plan",
product_category: "issue_tracking",
value_type: "number",
- status: "data_available",
+ status: "active",
time_frame: "all",
data_source: "database",
instrumentation_class: "CountIssuesMetric",
diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/service_ping_features_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/service_ping_features_metric_spec.rb
new file mode 100644
index 00000000000..40e9b962878
--- /dev/null
+++ b/spec/lib/gitlab/usage/metrics/instrumentations/service_ping_features_metric_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Usage::Metrics::Instrumentations::ServicePingFeaturesMetric do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:usage_ping_features_enabled, :expected_value) do
+ true | true
+ false | false
+ end
+
+ with_them do
+ before do
+ stub_application_setting(usage_ping_features_enabled: usage_ping_features_enabled)
+ end
+
+ it_behaves_like 'a correct instrumented metric value', { time_frame: 'none' }
+ end
+end
diff --git a/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb
index d4148b57348..4996b0a0089 100644
--- a/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb
@@ -77,11 +77,22 @@ RSpec.describe Gitlab::UsageDataCounters::CiTemplateUniqueCounter do
let(:project_id) { 1 }
let(:config_source) { :repository_source }
- Dir.glob(File.join('lib', 'gitlab', 'ci', 'templates', '**'), base: Rails.root) do |template|
+ described_class.ci_templates.each do |template|
next if described_class::TEMPLATE_TO_EVENT.key?(template)
- it "does not track #{template}" do
- expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to(receive(:track_event))
+ it "has an event defined for #{template}" do
+ expect do
+ described_class.track_unique_project_event(
+ project_id: project_id,
+ template: template,
+ config_source: config_source
+ )
+ end.not_to raise_error
+ end
+
+ it "tracks #{template}" do
+ expected_template_event_name = described_class.ci_template_event_name(template, :repository_source)
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).to(receive(:track_event)).with(expected_template_event_name, values: project_id)
described_class.track_unique_project_event(project_id: project_id, template: template, config_source: config_source)
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
index a1dee442131..c4a84445a01 100644
--- a/spec/lib/gitlab/usage_data_counters/code_review_events_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/code_review_events_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe 'Code review events' do
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]
+ exceptions = %w[i_code_review_mr_diffs i_code_review_mr_single_file_diffs i_code_review_total_suggestions_applied i_code_review_total_suggestions_added]
code_review_aggregated_events += exceptions
expect(code_review_events - code_review_aggregated_events).to be_empty
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 887759014f5..427dd4a205e 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
@@ -462,6 +462,8 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
allow(described_class).to receive(:known_events).and_return(known_events)
allow(described_class).to receive(:categories).and_return(%w(category1 category2))
+ stub_const('Gitlab::UsageDataCounters::HLLRedisCounter::CATEGORIES_FOR_TOTALS', %w(category1 category2))
+
described_class.track_event('event1_slot', values: entity1, time: 2.days.ago)
described_class.track_event('event2_slot', values: entity2, time: 2.days.ago)
described_class.track_event('event2_slot', values: entity3, time: 2.weeks.ago)
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 041fc2f20a8..cd3388701fe 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
@@ -206,18 +206,32 @@ RSpec.describe Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter, :cl
end
describe '.track_add_suggestion_action' do
- subject { described_class.track_add_suggestion_action(user: user) }
+ subject { described_class.track_add_suggestion_action(note: note) }
+
+ before do
+ note.suggestions << build(:suggestion, id: 1, note: note)
+ end
it_behaves_like 'a tracked merge request unique event' do
- let(:action) { described_class::MR_ADD_SUGGESTION_ACTION }
+ let(:action) { described_class::MR_USER_ADD_SUGGESTION_ACTION }
+ end
+
+ it_behaves_like 'a tracked merge request unique event' do
+ let(:action) { described_class::MR_TOTAL_ADD_SUGGESTION_ACTION }
end
end
describe '.track_apply_suggestion_action' do
- subject { described_class.track_apply_suggestion_action(user: user) }
+ subject { described_class.track_apply_suggestion_action(user: user, suggestions: suggestions) }
+
+ let(:suggestions) { [build(:suggestion, id: 1, note: note)] }
+
+ it_behaves_like 'a tracked merge request unique event' do
+ let(:action) { described_class::MR_USER_APPLY_SUGGESTION_ACTION }
+ end
it_behaves_like 'a tracked merge request unique event' do
- let(:action) { described_class::MR_APPLY_SUGGESTION_ACTION }
+ let(:action) { described_class::MR_TOTAL_APPLY_SUGGESTION_ACTION }
end
end
@@ -394,4 +408,12 @@ RSpec.describe Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter, :cl
let(:action) { described_class::MR_RESOLVE_CONFLICT_ACTION }
end
end
+
+ describe '.track_resolve_thread_in_issue_action' do
+ subject { described_class.track_resolve_thread_in_issue_action(user: user) }
+
+ it_behaves_like 'a tracked merge request unique event' do
+ let(:action) { described_class::MR_RESOLVE_THREAD_IN_ISSUE_ACTION }
+ end
+ end
end
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index 5d85ad5ad01..a70b68a181f 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -1089,6 +1089,10 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(subject[:settings][:collected_data_categories]).to eq(expected_value)
end
+
+ it 'gathers service_ping_features_enabled' do
+ expect(subject[:settings][:service_ping_features_enabled]).to eq(Gitlab::CurrentSettings.usage_ping_features_enabled)
+ end
end
end
@@ -1279,9 +1283,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
subject { described_class.redis_hll_counters }
let(:categories) { ::Gitlab::UsageDataCounters::HLLRedisCounter.categories }
- let(:ineligible_total_categories) do
- %w[source_code ci_secrets_management incident_management_alerts snippets terraform incident_management_oncall secure network_policies]
- end
context 'with redis_hll_tracking feature enabled' do
it 'has all known_events' do
@@ -1296,7 +1297,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
metrics = keys.map { |key| "#{key}_weekly" } + keys.map { |key| "#{key}_monthly" }
- if ineligible_total_categories.exclude?(category)
+ if ::Gitlab::UsageDataCounters::HLLRedisCounter::CATEGORIES_FOR_TOTALS.include?(category)
metrics.append("#{category}_total_unique_counts_weekly", "#{category}_total_unique_counts_monthly")
end
diff --git a/spec/lib/gitlab/x509/tag_spec.rb b/spec/lib/gitlab/x509/tag_spec.rb
index be120aaf16a..f52880cfc52 100644
--- a/spec/lib/gitlab/x509/tag_spec.rb
+++ b/spec/lib/gitlab/x509/tag_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe Gitlab::X509::Tag do
let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') }
let(:project) { create(:project, :repository) }
- shared_examples 'signed tag' do
+ describe 'signed tag' do
let(:tag) { project.repository.find_tag('v1.1.1') }
let(:certificate_attributes) do
{
@@ -33,24 +33,10 @@ RSpec.describe Gitlab::X509::Tag do
it { expect(signature.x509_certificate.x509_issuer).to have_attributes(issuer_attributes) }
end
- shared_examples 'unsigned tag' do
+ describe 'unsigned tag' do
let(:tag) { project.repository.find_tag('v1.0.0') }
it { expect(signature).to be_nil }
end
-
- context 'with :get_tag_signatures enabled' do
- it_behaves_like 'signed tag'
- it_behaves_like 'unsigned tag'
- end
-
- context 'with :get_tag_signatures disabled' do
- before do
- stub_feature_flags(get_tag_signatures: false)
- end
-
- it_behaves_like 'signed tag'
- it_behaves_like 'unsigned tag'
- end
end
end
diff --git a/spec/lib/gitlab/zentao/client_spec.rb b/spec/lib/gitlab/zentao/client_spec.rb
new file mode 100644
index 00000000000..e3a335c1e89
--- /dev/null
+++ b/spec/lib/gitlab/zentao/client_spec.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Zentao::Client do
+ subject(:integration) { described_class.new(zentao_integration) }
+
+ let(:zentao_integration) { create(:zentao_integration) }
+ let(:mock_get_products_url) { integration.send(:url, "products/#{zentao_integration.zentao_product_xid}") }
+
+ describe '#new' do
+ context 'if integration is nil' do
+ let(:zentao_integration) { nil }
+
+ it 'raises ConfigError' do
+ expect { integration }.to raise_error(described_class::ConfigError)
+ end
+ end
+
+ context 'integration is provided' do
+ it 'is initialized successfully' do
+ expect { integration }.not_to raise_error
+ end
+ end
+ end
+
+ describe '#fetch_product' do
+ let(:mock_headers) do
+ {
+ headers: {
+ 'Content-Type' => 'application/json',
+ 'Token' => zentao_integration.api_token
+ }
+ }
+ end
+
+ context 'with valid product' do
+ let(:mock_response) { { 'id' => zentao_integration.zentao_product_xid } }
+
+ before do
+ WebMock.stub_request(:get, mock_get_products_url)
+ .with(mock_headers).to_return(status: 200, body: mock_response.to_json)
+ end
+
+ it 'fetches the product' do
+ expect(integration.fetch_product(zentao_integration.zentao_product_xid)).to eq mock_response
+ end
+ end
+
+ context 'with invalid product' do
+ before do
+ WebMock.stub_request(:get, mock_get_products_url)
+ .with(mock_headers).to_return(status: 404, body: {}.to_json)
+ end
+
+ it 'fetches the empty product' do
+ expect(integration.fetch_product(zentao_integration.zentao_product_xid)).to eq({})
+ end
+ end
+
+ context 'with invalid response' do
+ before do
+ WebMock.stub_request(:get, mock_get_products_url)
+ .with(mock_headers).to_return(status: 200, body: '[invalid json}')
+ end
+
+ it 'fetches the empty product' do
+ expect(integration.fetch_product(zentao_integration.zentao_product_xid)).to eq({})
+ end
+ end
+ end
+
+ describe '#ping' do
+ let(:mock_headers) do
+ {
+ headers: {
+ 'Content-Type' => 'application/json',
+ 'Token' => zentao_integration.api_token
+ }
+ }
+ end
+
+ context 'with valid resource' do
+ before do
+ WebMock.stub_request(:get, mock_get_products_url)
+ .with(mock_headers).to_return(status: 200, body: { 'deleted' => '0' }.to_json)
+ end
+
+ it 'responds with success' do
+ expect(integration.ping[:success]).to eq true
+ end
+ end
+
+ context 'with deleted resource' do
+ before do
+ WebMock.stub_request(:get, mock_get_products_url)
+ .with(mock_headers).to_return(status: 200, body: { 'deleted' => '1' }.to_json)
+ end
+
+ it 'responds with unsuccess' do
+ expect(integration.ping[:success]).to eq false
+ end
+ end
+ end
+end
diff --git a/spec/lib/marginalia_spec.rb b/spec/lib/marginalia_spec.rb
index dd57cd7980e..3f39d969dbd 100644
--- a/spec/lib/marginalia_spec.rb
+++ b/spec/lib/marginalia_spec.rb
@@ -42,7 +42,8 @@ RSpec.describe 'Marginalia spec' do
{
"application" => "test",
"endpoint_id" => "MarginaliaTestController#first_user",
- "correlation_id" => correlation_id
+ "correlation_id" => correlation_id,
+ "db_config_name" => "main"
}
end
@@ -51,6 +52,29 @@ RSpec.describe 'Marginalia spec' do
expect(recorded.log.last).to include("#{component}:#{value}")
end
end
+
+ context 'when using CI database' do
+ let(:component_map) do
+ {
+ "application" => "test",
+ "endpoint_id" => "MarginaliaTestController#first_user",
+ "correlation_id" => correlation_id,
+ "db_config_name" => "ci"
+ }
+ end
+
+ before do |example|
+ skip_if_multiple_databases_not_setup
+
+ allow(User).to receive(:connection) { Ci::CiDatabaseRecord.connection }
+ 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
end
describe 'for Sidekiq worker jobs' do
@@ -79,7 +103,8 @@ RSpec.describe 'Marginalia spec' do
"application" => "sidekiq",
"endpoint_id" => "MarginaliaTestJob",
"correlation_id" => sidekiq_job['correlation_id'],
- "jid" => sidekiq_job['jid']
+ "jid" => sidekiq_job['jid'],
+ "db_config_name" => "main"
}
end
@@ -100,9 +125,10 @@ RSpec.describe 'Marginalia spec' do
let(:component_map) do
{
- "application" => "sidekiq",
- "endpoint_id" => "ActionMailer::MailDeliveryJob",
- "jid" => delivery_job.job_id
+ "application" => "sidekiq",
+ "endpoint_id" => "ActionMailer::MailDeliveryJob",
+ "jid" => delivery_job.job_id,
+ "db_config_name" => "main"
}
end
diff --git a/spec/lib/object_storage/config_spec.rb b/spec/lib/object_storage/config_spec.rb
index 0ead2a1d269..21b8a44b3d6 100644
--- a/spec/lib/object_storage/config_spec.rb
+++ b/spec/lib/object_storage/config_spec.rb
@@ -188,6 +188,7 @@ RSpec.describe ObjectStorage::Config do
end
context 'with SSE-KMS enabled' do
+ it { expect(subject.aws_server_side_encryption_enabled?).to be true }
it { expect(subject.server_side_encryption).to eq('AES256') }
it { expect(subject.server_side_encryption_kms_key_id).to eq('arn:aws:12345') }
it { expect(subject.fog_attributes.keys).to match_array(%w(x-amz-server-side-encryption x-amz-server-side-encryption-aws-kms-key-id)) }
@@ -196,6 +197,7 @@ RSpec.describe ObjectStorage::Config do
context 'with only server side encryption enabled' do
let(:storage_options) { { server_side_encryption: 'AES256' } }
+ it { expect(subject.aws_server_side_encryption_enabled?).to be true }
it { expect(subject.server_side_encryption).to eq('AES256') }
it { expect(subject.server_side_encryption_kms_key_id).to be_nil }
it { expect(subject.fog_attributes).to eq({ 'x-amz-server-side-encryption' => 'AES256' }) }
@@ -204,6 +206,7 @@ RSpec.describe ObjectStorage::Config do
context 'without encryption enabled' do
let(:storage_options) { {} }
+ it { expect(subject.aws_server_side_encryption_enabled?).to be false }
it { expect(subject.server_side_encryption).to be_nil }
it { expect(subject.server_side_encryption_kms_key_id).to be_nil }
it { expect(subject.fog_attributes).to eq({}) }
@@ -215,6 +218,5 @@ RSpec.describe ObjectStorage::Config do
end
it { expect(subject.enabled?).to be false }
- it { expect(subject.fog_attributes).to eq({}) }
end
end
diff --git a/spec/lib/sidebars/menu_spec.rb b/spec/lib/sidebars/menu_spec.rb
index 1db80351e45..eb6a68f1afd 100644
--- a/spec/lib/sidebars/menu_spec.rb
+++ b/spec/lib/sidebars/menu_spec.rb
@@ -198,4 +198,27 @@ RSpec.describe Sidebars::Menu do
end
end
end
+
+ describe '#link' do
+ let(:foo_path) { '/foo_path'}
+
+ let(:foo_menu) do
+ ::Sidebars::MenuItem.new(
+ title: 'foo',
+ link: foo_path,
+ active_routes: {},
+ item_id: :foo
+ )
+ end
+
+ it 'returns first visible menu item link' do
+ menu.add_item(foo_menu)
+
+ expect(menu.link).to eq foo_path
+ end
+
+ it 'returns nil if there are no visible menu items' do
+ expect(menu.link).to be_nil
+ end
+ end
end
diff --git a/spec/lib/sidebars/projects/menus/learn_gitlab_menu_spec.rb b/spec/lib/sidebars/projects/menus/learn_gitlab_menu_spec.rb
index 231e5a850c2..36a76e70a48 100644
--- a/spec/lib/sidebars/projects/menus/learn_gitlab_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/learn_gitlab_menu_spec.rb
@@ -4,15 +4,13 @@ require 'spec_helper'
RSpec.describe Sidebars::Projects::Menus::LearnGitlabMenu do
let_it_be(:project) { build(:project) }
- let_it_be(:experiment_enabled) { true }
- let_it_be(:tracking_category) { 'Growth::Activation::Experiment::LearnGitLabB' }
+ let_it_be(:learn_gitlab_enabled) { true }
let(:context) do
Sidebars::Projects::Context.new(
current_user: nil,
container: project,
- learn_gitlab_experiment_enabled: experiment_enabled,
- learn_gitlab_experiment_tracking_category: tracking_category
+ learn_gitlab_enabled: learn_gitlab_enabled
)
end
@@ -27,7 +25,6 @@ RSpec.describe Sidebars::Projects::Menus::LearnGitlabMenu do
{
class: 'home',
data: {
- track_property: tracking_category,
track_label: 'learn_gitlab'
}
}
@@ -46,7 +43,7 @@ RSpec.describe Sidebars::Projects::Menus::LearnGitlabMenu do
end
context 'when learn gitlab experiment is disabled' do
- let(:experiment_enabled) { false }
+ let(:learn_gitlab_enabled) { false }
it 'returns false' do
expect(subject.render?).to eq false
@@ -62,7 +59,7 @@ RSpec.describe Sidebars::Projects::Menus::LearnGitlabMenu do
end
context 'when learn gitlab experiment is disabled' do
- let(:experiment_enabled) { false }
+ let(:learn_gitlab_enabled) { false }
it 'returns false' do
expect(subject.has_pill?).to eq false
diff --git a/spec/lib/sidebars/projects/menus/monitor_menu_spec.rb b/spec/lib/sidebars/projects/menus/monitor_menu_spec.rb
index 381842be5ab..77efe99aaa9 100644
--- a/spec/lib/sidebars/projects/menus/monitor_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/monitor_menu_spec.rb
@@ -49,25 +49,6 @@ RSpec.describe Sidebars::Projects::Menus::MonitorMenu do
end
end
- describe '#link' do
- let(:foo_path) { '/foo_path'}
-
- let(:foo_menu) do
- ::Sidebars::MenuItem.new(
- title: 'foo',
- link: foo_path,
- active_routes: {},
- item_id: :foo
- )
- end
-
- it 'returns first visible item link' do
- subject.insert_element_before(subject.renderable_items, subject.renderable_items.first.item_id, foo_menu)
-
- expect(subject.link).to eq foo_path
- end
- end
-
context 'Menu items' do
subject { described_class.new(context).renderable_items.index { |e| e.item_id == item_id } }
diff --git a/spec/lib/sidebars/projects/menus/settings_menu_spec.rb b/spec/lib/sidebars/projects/menus/settings_menu_spec.rb
index 9b79614db20..3079c781d73 100644
--- a/spec/lib/sidebars/projects/menus/settings_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/settings_menu_spec.rb
@@ -158,5 +158,31 @@ RSpec.describe Sidebars::Projects::Menus::SettingsMenu do
end
end
end
+
+ describe 'Usage Quotas' do
+ let(:item_id) { :usage_quotas }
+
+ describe 'with project_storage_ui feature flag enabled' do
+ before do
+ stub_feature_flags(project_storage_ui: true)
+ end
+
+ specify { is_expected.not_to be_nil }
+
+ describe 'when the user does not have access' do
+ let(:user) { nil }
+
+ specify { is_expected.to be_nil }
+ end
+ end
+
+ describe 'with project_storage_ui feature flag disabled' do
+ before do
+ stub_feature_flags(project_storage_ui: false)
+ end
+
+ specify { is_expected.to be_nil }
+ end
+ end
end
end
diff --git a/spec/lib/system_check/incoming_email_check_spec.rb b/spec/lib/system_check/incoming_email_check_spec.rb
new file mode 100644
index 00000000000..710702b93fc
--- /dev/null
+++ b/spec/lib/system_check/incoming_email_check_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe SystemCheck::IncomingEmailCheck do
+ before do
+ allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production'))
+ end
+
+ describe '#multi_check' do
+ context 'when incoming e-mail is disabled' do
+ before do
+ stub_incoming_email_setting(enabled: false)
+ end
+
+ it 'does not run any checks' do
+ expect(SystemCheck).not_to receive(:run)
+
+ subject.multi_check
+ end
+ end
+
+ context 'when incoming e-mail is enabled for IMAP' do
+ before do
+ stub_incoming_email_setting(enabled: true)
+ end
+
+ it 'runs IMAP and mailroom checks' do
+ expect(SystemCheck).to receive(:run).with('Reply by email', [
+ SystemCheck::IncomingEmail::ImapAuthenticationCheck,
+ SystemCheck::IncomingEmail::InitdConfiguredCheck,
+ SystemCheck::IncomingEmail::MailRoomRunningCheck
+ ])
+
+ subject.multi_check
+ end
+ end
+
+ context 'when incoming e-mail is enabled for Microsoft Graph' do
+ before do
+ stub_incoming_email_setting(enabled: true, inbox_method: 'microsoft_graph')
+ end
+
+ it 'runs mailroom checks' do
+ expect(SystemCheck).to receive(:run).with('Reply by email', [
+ SystemCheck::IncomingEmail::InitdConfiguredCheck,
+ SystemCheck::IncomingEmail::MailRoomRunningCheck
+ ])
+
+ subject.multi_check
+ end
+ end
+ end
+end
diff --git a/spec/mailers/emails/in_product_marketing_spec.rb b/spec/mailers/emails/in_product_marketing_spec.rb
index 74354630ade..99beef92dea 100644
--- a/spec/mailers/emails/in_product_marketing_spec.rb
+++ b/spec/mailers/emails/in_product_marketing_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe Emails::InProductMarketing do
it 'sends to the right user with a link to unsubscribe' do
aggregate_failures do
- expect(subject).to deliver_to(user.notification_email)
+ expect(subject).to deliver_to(user.notification_email_or_default)
expect(subject).to have_body_text(profile_notifications_url)
end
end
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index 8272b5d64c1..f39037cf744 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -71,7 +71,7 @@ RSpec.describe Notify do
it 'is sent to the assignee as the author' do
aggregate_failures do
expect_sender(current_user)
- expect(subject).to deliver_to(recipient.notification_email)
+ expect(subject).to deliver_to(recipient.notification_email_or_default)
end
end
end
@@ -710,7 +710,7 @@ RSpec.describe Notify do
it 'contains all the useful information' do
to_emails = subject.header[:to].addrs.map(&:address)
- expect(to_emails).to eq([recipient.notification_email])
+ expect(to_emails).to eq([recipient.notification_email_or_default])
is_expected.to have_subject "Request to join the #{project.full_name} project"
is_expected.to have_body_text project.full_name
@@ -800,8 +800,7 @@ RSpec.describe Notify do
is_expected.to have_body_text project_member.invite_token
is_expected.to have_link('Join now',
href: invite_url(project_member.invite_token,
- invite_type: Emails::Members::INITIAL_INVITE,
- experiment_name: 'invite_email_preview_text'))
+ invite_type: Emails::Members::INITIAL_INVITE))
is_expected.to have_content("#{inviter.name} invited you to join the")
is_expected.to have_content('Project details')
is_expected.to have_content("What's it about?")
@@ -818,13 +817,54 @@ RSpec.describe Notify do
is_expected.to have_body_text project_member.invite_token
is_expected.to have_link('Join now',
href: invite_url(project_member.invite_token,
- invite_type: Emails::Members::INITIAL_INVITE,
- experiment_name: 'invite_email_preview_text'))
+ invite_type: Emails::Members::INITIAL_INVITE))
is_expected.to have_content('Project details')
is_expected.to have_content("What's it about?")
end
end
+ context 'with invite_email_preview_text enabled', :experiment do
+ before do
+ stub_experiments(invite_email_preview_text: :control)
+ end
+
+ it 'has the correct invite_url with params' do
+ is_expected.to have_link('Join now',
+ href: invite_url(project_member.invite_token,
+ invite_type: Emails::Members::INITIAL_INVITE,
+ experiment_name: 'invite_email_preview_text'))
+ end
+
+ it 'tracks the sent invite' do
+ expect(experiment(:invite_email_preview_text)).to track(:assignment)
+ .with_context(actor: project_member)
+ .on_next_instance
+
+ invite_email.deliver_now
+ end
+ end
+
+ context 'with invite_email_from enabled', :experiment do
+ before do
+ stub_experiments(invite_email_from: :control)
+ end
+
+ it 'has the correct invite_url with params' do
+ is_expected.to have_link('Join now',
+ href: invite_url(project_member.invite_token,
+ invite_type: Emails::Members::INITIAL_INVITE,
+ experiment_name: 'invite_email_from'))
+ end
+
+ it 'tracks the sent invite' do
+ expect(experiment(:invite_email_from)).to track(:assignment)
+ .with_context(actor: project_member)
+ .on_next_instance
+
+ invite_email.deliver_now
+ end
+ end
+
context 'when invite email sent is tracked', :snowplow do
it 'tracks the sent invite' do
invite_email.deliver_now
@@ -838,15 +878,15 @@ RSpec.describe Notify do
end
end
- context 'when on gitlab.com' do
+ context 'when mailgun events are enabled' do
before do
- allow(Gitlab).to receive(:dev_env_or_com?).and_return(true)
+ stub_application_setting(mailgun_events_enabled: true)
end
it 'has custom headers' do
aggregate_failures do
- expect(subject).to have_header('X-Mailgun-Tag', 'invite_email')
- expect(subject).to have_header('X-Mailgun-Variables', { 'invite_token' => project_member.invite_token }.to_json)
+ expect(subject).to have_header('X-Mailgun-Tag', ::Members::Mailgun::INVITE_EMAIL_TAG)
+ expect(subject).to have_header('X-Mailgun-Variables', { ::Members::Mailgun::INVITE_EMAIL_TOKEN_KEY => project_member.invite_token }.to_json)
end
end
end
@@ -1007,7 +1047,7 @@ RSpec.describe Notify do
it 'is sent to the given recipient as the author' do
aggregate_failures do
expect_sender(note_author)
- expect(subject).to deliver_to(recipient.notification_email)
+ expect(subject).to deliver_to(recipient.notification_email_or_default)
end
end
@@ -1164,7 +1204,7 @@ RSpec.describe Notify do
it 'is sent to the given recipient as the author' do
aggregate_failures do
expect_sender(note_author)
- expect(subject).to deliver_to(recipient.notification_email)
+ expect(subject).to deliver_to(recipient.notification_email_or_default)
end
end
@@ -1301,7 +1341,7 @@ RSpec.describe Notify do
it 'contains all the useful information' do
to_emails = subject.header[:to].addrs.map(&:address)
- expect(to_emails).to eq([recipient.notification_email])
+ expect(to_emails).to eq([recipient.notification_email_or_default])
is_expected.to have_subject "Request to join the #{group.name} group"
is_expected.to have_body_text group.name
diff --git a/spec/migrations/20210804150320_create_base_work_item_types_spec.rb b/spec/migrations/20210804150320_create_base_work_item_types_spec.rb
index 535472f5931..9ba29637e00 100644
--- a/spec/migrations/20210804150320_create_base_work_item_types_spec.rb
+++ b/spec/migrations/20210804150320_create_base_work_item_types_spec.rb
@@ -6,7 +6,17 @@ require_migration!('create_base_work_item_types')
RSpec.describe CreateBaseWorkItemTypes, :migration do
let!(:work_item_types) { table(:work_item_types) }
+ after(:all) do
+ # Make sure base types are recreated after running the migration
+ # because migration specs are not run in a transaction
+ WorkItem::Type.delete_all
+ Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter.import
+ end
+
it 'creates default data' do
+ # Need to delete all as base types are seeded before entire test suite
+ WorkItem::Type.delete_all
+
reversible_migration do |migration|
migration.before -> {
# Depending on whether the migration has been run before,
diff --git a/spec/migrations/20210818185845_backfill_projects_with_coverage_spec.rb b/spec/migrations/20210818185845_backfill_projects_with_coverage_spec.rb
new file mode 100644
index 00000000000..d87f952b5da
--- /dev/null
+++ b/spec/migrations/20210818185845_backfill_projects_with_coverage_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!('backfill_projects_with_coverage')
+
+RSpec.describe BackfillProjectsWithCoverage do
+ let(:projects) { table(:projects) }
+ let(:ci_pipelines) { table(:ci_pipelines) }
+ let(:ci_daily_build_group_report_results) { table(:ci_daily_build_group_report_results) }
+ let(:group) { table(:namespaces).create!(name: 'user', path: 'user') }
+ let(:project_1) { projects.create!(namespace_id: group.id) }
+ let(:project_2) { projects.create!(namespace_id: group.id) }
+ let(:pipeline_1) { ci_pipelines.create!(project_id: project_1.id) }
+ let(:pipeline_2) { ci_pipelines.create!(project_id: project_2.id) }
+ let(:pipeline_3) { ci_pipelines.create!(project_id: project_2.id) }
+
+ describe '#up' do
+ before do
+ stub_const("#{described_class}::BATCH_SIZE", 2)
+ stub_const("#{described_class}::SUB_BATCH_SIZE", 1)
+
+ ci_daily_build_group_report_results.create!(
+ id: 1,
+ project_id: project_1.id,
+ date: 3.days.ago,
+ last_pipeline_id: pipeline_1.id,
+ ref_path: 'main',
+ group_name: 'rspec',
+ data: { coverage: 95.0 },
+ default_branch: true,
+ group_id: group.id
+ )
+
+ ci_daily_build_group_report_results.create!(
+ id: 2,
+ project_id: project_2.id,
+ date: 2.days.ago,
+ last_pipeline_id: pipeline_2.id,
+ ref_path: 'main',
+ group_name: 'rspec',
+ data: { coverage: 95.0 },
+ default_branch: true,
+ group_id: group.id
+ )
+
+ ci_daily_build_group_report_results.create!(
+ id: 3,
+ project_id: project_2.id,
+ date: 1.day.ago,
+ last_pipeline_id: pipeline_3.id,
+ ref_path: 'test_branch',
+ group_name: 'rspec',
+ data: { coverage: 95.0 },
+ default_branch: false,
+ group_id: group.id
+ )
+ end
+
+ it 'schedules BackfillProjectsWithCoverage background jobs', :aggregate_failures do
+ Sidekiq::Testing.fake! do
+ freeze_time do
+ migrate!
+
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, 1, 2, 1)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, 3, 3, 1)
+ expect(BackgroundMigrationWorker.jobs.size).to eq(2)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20210819145000_drop_temporary_columns_and_triggers_for_ci_builds_runner_session_spec.rb b/spec/migrations/20210819145000_drop_temporary_columns_and_triggers_for_ci_builds_runner_session_spec.rb
new file mode 100644
index 00000000000..b1751216732
--- /dev/null
+++ b/spec/migrations/20210819145000_drop_temporary_columns_and_triggers_for_ci_builds_runner_session_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!('drop_temporary_columns_and_triggers_for_ci_builds_runner_session')
+
+RSpec.describe DropTemporaryColumnsAndTriggersForCiBuildsRunnerSession, :migration do
+ let(:ci_builds_runner_session_table) { table(:ci_builds_runner_session) }
+
+ it 'correctly migrates up and down' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(ci_builds_runner_session_table.column_names).to include('build_id_convert_to_bigint')
+ }
+
+ migration.after -> {
+ ci_builds_runner_session_table.reset_column_information
+ expect(ci_builds_runner_session_table.column_names).not_to include('build_id_convert_to_bigint')
+ }
+ end
+ end
+end
diff --git a/spec/migrations/20210831203408_upsert_base_work_item_types_spec.rb b/spec/migrations/20210831203408_upsert_base_work_item_types_spec.rb
new file mode 100644
index 00000000000..c23110750c3
--- /dev/null
+++ b/spec/migrations/20210831203408_upsert_base_work_item_types_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!('upsert_base_work_item_types')
+
+RSpec.describe UpsertBaseWorkItemTypes, :migration do
+ let!(:work_item_types) { table(:work_item_types) }
+
+ after(:all) do
+ # Make sure base types are recreated after running the migration
+ # because migration specs are not run in a transaction
+ WorkItem::Type.delete_all
+ Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter.import
+ end
+
+ context 'when no default types exist' do
+ it 'creates default data' do
+ # Need to delete all as base types are seeded before entire test suite
+ WorkItem::Type.delete_all
+
+ expect(work_item_types.count).to eq(0)
+
+ reversible_migration do |migration|
+ migration.before -> {
+ # Depending on whether the migration has been run before,
+ # the size could be 4, or 0, so we don't set any expectations
+ # as we don't delete base types on migration reverse
+ }
+
+ migration.after -> {
+ expect(work_item_types.count).to eq(4)
+ expect(work_item_types.all.pluck(:base_type)).to match_array(WorkItem::Type.base_types.values)
+ }
+ end
+ end
+ end
+
+ context 'when default types already exist' do
+ it 'does not create default types again' do
+ expect(work_item_types.all.pluck(:base_type)).to match_array(WorkItem::Type.base_types.values)
+
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(work_item_types.all.pluck(:base_type)).to match_array(WorkItem::Type.base_types.values)
+ }
+
+ migration.after -> {
+ expect(work_item_types.count).to eq(4)
+ expect(work_item_types.all.pluck(:base_type)).to match_array(WorkItem::Type.base_types.values)
+ }
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20210902144144_drop_temporary_columns_and_triggers_for_ci_build_needs_spec.rb b/spec/migrations/20210902144144_drop_temporary_columns_and_triggers_for_ci_build_needs_spec.rb
new file mode 100644
index 00000000000..1b35982c41d
--- /dev/null
+++ b/spec/migrations/20210902144144_drop_temporary_columns_and_triggers_for_ci_build_needs_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!('drop_temporary_columns_and_triggers_for_ci_build_needs')
+
+RSpec.describe DropTemporaryColumnsAndTriggersForCiBuildNeeds do
+ let(:ci_build_needs_table) { table(:ci_build_needs) }
+
+ it 'correctly migrates up and down' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(ci_build_needs_table.column_names).to include('build_id_convert_to_bigint')
+ }
+
+ migration.after -> {
+ ci_build_needs_table.reset_column_information
+ expect(ci_build_needs_table.column_names).not_to include('build_id_convert_to_bigint')
+ }
+ end
+ end
+end
diff --git a/spec/migrations/20210906100316_drop_temporary_columns_and_triggers_for_ci_build_trace_chunks_spec.rb b/spec/migrations/20210906100316_drop_temporary_columns_and_triggers_for_ci_build_trace_chunks_spec.rb
new file mode 100644
index 00000000000..8d46ba7eb58
--- /dev/null
+++ b/spec/migrations/20210906100316_drop_temporary_columns_and_triggers_for_ci_build_trace_chunks_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!('drop_temporary_columns_and_triggers_for_ci_build_trace_chunks')
+
+RSpec.describe DropTemporaryColumnsAndTriggersForCiBuildTraceChunks do
+ let(:ci_build_trace_chunks_table) { table(:ci_build_trace_chunks) }
+
+ it 'correctly migrates up and down' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(ci_build_trace_chunks_table.column_names).to include('build_id_convert_to_bigint')
+ }
+
+ migration.after -> {
+ ci_build_trace_chunks_table.reset_column_information
+ expect(ci_build_trace_chunks_table.column_names).not_to include('build_id_convert_to_bigint')
+ }
+ end
+ end
+end
diff --git a/spec/migrations/active_record/schema_spec.rb b/spec/migrations/active_record/schema_spec.rb
index 4a505c51a16..042b5710dce 100644
--- a/spec/migrations/active_record/schema_spec.rb
+++ b/spec/migrations/active_record/schema_spec.rb
@@ -7,7 +7,7 @@ require 'spec_helper'
RSpec.describe ActiveRecord::Schema, schema: :latest do
let(:all_migrations) do
- migrations_directories = %w[db/migrate db/post_migrate].map { |path| Rails.root.join(path).to_s }
+ migrations_directories = Rails.application.paths["db/migrate"].paths.map(&:to_s)
migrations_paths = migrations_directories.map { |path| File.join(path, '*') }
migrations = Dir[*migrations_paths] - migrations_directories
diff --git a/spec/migrations/add_default_project_approval_rules_vuln_allowed_spec.rb b/spec/migrations/add_default_project_approval_rules_vuln_allowed_spec.rb
new file mode 100644
index 00000000000..057e95eb158
--- /dev/null
+++ b/spec/migrations/add_default_project_approval_rules_vuln_allowed_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe AddDefaultProjectApprovalRulesVulnAllowed do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:namespace) { namespaces.create!(name: 'namespace', path: 'namespace') }
+ let(:project) { projects.create!(name: 'project', path: 'project', namespace_id: namespace.id) }
+ let(:approval_project_rules) { table(:approval_project_rules) }
+
+ it 'updates records when vulnerabilities_allowed is nil' do
+ records_to_migrate = 10
+
+ records_to_migrate.times do |i|
+ approval_project_rules.create!(name: "rule #{i}", project_id: project.id)
+ end
+
+ expect { migrate! }
+ .to change { approval_project_rules.where(vulnerabilities_allowed: nil).count }
+ .from(records_to_migrate)
+ .to(0)
+ end
+
+ it 'defaults vulnerabilities_allowed to 0' do
+ approval_project_rule = approval_project_rules.create!(name: "new rule", project_id: project.id)
+
+ expect(approval_project_rule.vulnerabilities_allowed).to be_nil
+
+ migrate!
+
+ expect(approval_project_rule.reload.vulnerabilities_allowed).to eq(0)
+ end
+end
diff --git a/spec/migrations/add_triggers_to_integrations_type_new_spec.rb b/spec/migrations/add_triggers_to_integrations_type_new_spec.rb
index 07845715a52..01af5884170 100644
--- a/spec/migrations/add_triggers_to_integrations_type_new_spec.rb
+++ b/spec/migrations/add_triggers_to_integrations_type_new_spec.rb
@@ -8,6 +8,18 @@ RSpec.describe AddTriggersToIntegrationsTypeNew do
let(:migration) { described_class.new }
let(:integrations) { table(:integrations) }
+ # This matches Gitlab::Integrations::StiType at the time the trigger was added
+ let(:namespaced_integrations) do
+ %w[
+ Asana Assembla Bamboo Bugzilla Buildkite Campfire Confluence CustomIssueTracker Datadog
+ Discord DroneCi EmailsOnPush Ewm ExternalWiki Flowdock HangoutsChat Irker Jenkins Jira Mattermost
+ MattermostSlashCommands MicrosoftTeams MockCi MockMonitoring Packagist PipelinesEmail Pivotaltracker
+ Prometheus Pushover Redmine Slack SlackSlashCommands Teamcity UnifyCircuit WebexTeams Youtrack
+
+ Github GitlabSlackApplication
+ ]
+ end
+
describe '#up' do
before do
migrate!
@@ -15,7 +27,7 @@ RSpec.describe AddTriggersToIntegrationsTypeNew do
describe 'INSERT trigger' do
it 'sets `type_new` to the transformed `type` class name' do
- Gitlab::Integrations::StiType.namespaced_integrations.each do |type|
+ namespaced_integrations.each do |type|
integration = integrations.create!(type: "#{type}Service")
expect(integration.reload).to have_attributes(
diff --git a/spec/migrations/backfill_cadence_id_for_boards_scoped_to_iteration_spec.rb b/spec/migrations/backfill_cadence_id_for_boards_scoped_to_iteration_spec.rb
new file mode 100644
index 00000000000..1a64de8d0db
--- /dev/null
+++ b/spec/migrations/backfill_cadence_id_for_boards_scoped_to_iteration_spec.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+# require Rails.root.join('db', 'post_migrate', '20210825193652_backfill_candence_id_for_boards_scoped_to_iteration.rb')
+
+RSpec.describe BackfillCadenceIdForBoardsScopedToIteration, :migration do
+ let(:projects) { table(:projects) }
+ let(:namespaces) { table(:namespaces) }
+ let(:iterations_cadences) { table(:iterations_cadences) }
+ let(:boards) { table(:boards) }
+
+ let!(:group) { namespaces.create!(name: 'group1', path: 'group1', type: 'Group') }
+ let!(:cadence) { iterations_cadences.create!(title: 'group cadence', group_id: group.id, start_date: Time.current) }
+ let!(:project) { projects.create!(name: 'gitlab1', path: 'gitlab1', namespace_id: group.id, visibility_level: 0) }
+ let!(:project_board1) { boards.create!(name: 'Project Dev1', project_id: project.id) }
+ let!(:project_board2) { boards.create!(name: 'Project Dev2', project_id: project.id, iteration_id: -4) }
+ let!(:project_board3) { boards.create!(name: 'Project Dev3', project_id: project.id, iteration_id: -4) }
+ let!(:project_board4) { boards.create!(name: 'Project Dev4', project_id: project.id, iteration_id: -4) }
+
+ let!(:group_board1) { boards.create!(name: 'Group Dev1', group_id: group.id) }
+ let!(:group_board2) { boards.create!(name: 'Group Dev2', group_id: group.id, iteration_id: -4) }
+ let!(:group_board3) { boards.create!(name: 'Group Dev3', group_id: group.id, iteration_id: -4) }
+ let!(:group_board4) { boards.create!(name: 'Group Dev4', group_id: group.id, iteration_id: -4) }
+
+ describe '#up' do
+ it 'schedules background migrations' do
+ Sidekiq::Testing.fake! do
+ freeze_time do
+ described_class.new.up
+
+ migration = described_class::MIGRATION
+
+ expect(migration).to be_scheduled_delayed_migration(2.minutes, 'group', 'up', group_board2.id, group_board4.id)
+ expect(migration).to be_scheduled_delayed_migration(2.minutes, 'project', 'up', project_board2.id, project_board4.id)
+ expect(BackgroundMigrationWorker.jobs.size).to eq 2
+ end
+ end
+ end
+
+ context 'in batches' do
+ before do
+ stub_const('BackfillCadenceIdForBoardsScopedToIteration::BATCH_SIZE', 2)
+ end
+
+ it 'schedules background migrations' do
+ Sidekiq::Testing.fake! do
+ freeze_time do
+ described_class.new.up
+
+ migration = described_class::MIGRATION
+
+ expect(migration).to be_scheduled_delayed_migration(2.minutes, 'group', 'up', group_board2.id, group_board3.id)
+ expect(migration).to be_scheduled_delayed_migration(4.minutes, 'group', 'up', group_board4.id, group_board4.id)
+ expect(migration).to be_scheduled_delayed_migration(2.minutes, 'project', 'up', project_board2.id, project_board3.id)
+ expect(migration).to be_scheduled_delayed_migration(4.minutes, 'project', 'up', project_board4.id, project_board4.id)
+ expect(BackgroundMigrationWorker.jobs.size).to eq 4
+ end
+ end
+ end
+ end
+ end
+
+ describe '#down' do
+ let!(:project_board1) { boards.create!(name: 'Project Dev1', project_id: project.id) }
+ let!(:project_board2) { boards.create!(name: 'Project Dev2', project_id: project.id, iteration_cadence_id: cadence.id) }
+ let!(:project_board3) { boards.create!(name: 'Project Dev3', project_id: project.id, iteration_id: -4, iteration_cadence_id: cadence.id) }
+ let!(:project_board4) { boards.create!(name: 'Project Dev4', project_id: project.id, iteration_id: -4, iteration_cadence_id: cadence.id) }
+
+ let!(:group_board1) { boards.create!(name: 'Group Dev1', group_id: group.id) }
+ let!(:group_board2) { boards.create!(name: 'Group Dev2', group_id: group.id, iteration_cadence_id: cadence.id) }
+ let!(:group_board3) { boards.create!(name: 'Group Dev3', group_id: group.id, iteration_id: -4, iteration_cadence_id: cadence.id) }
+ let!(:group_board4) { boards.create!(name: 'Group Dev4', group_id: group.id, iteration_id: -4, iteration_cadence_id: cadence.id) }
+
+ it 'schedules background migrations' do
+ Sidekiq::Testing.fake! do
+ freeze_time do
+ described_class.new.down
+
+ migration = described_class::MIGRATION
+
+ expect(migration).to be_scheduled_delayed_migration(2.minutes, 'none', 'down', project_board2.id, group_board4.id)
+ expect(BackgroundMigrationWorker.jobs.size).to eq 1
+ end
+ end
+ end
+
+ context 'in batches' do
+ before do
+ stub_const('BackfillCadenceIdForBoardsScopedToIteration::BATCH_SIZE', 2)
+ end
+
+ it 'schedules background migrations' do
+ Sidekiq::Testing.fake! do
+ freeze_time do
+ described_class.new.down
+
+ migration = described_class::MIGRATION
+
+ expect(migration).to be_scheduled_delayed_migration(2.minutes, 'none', 'down', project_board2.id, project_board3.id)
+ expect(migration).to be_scheduled_delayed_migration(4.minutes, 'none', 'down', project_board4.id, group_board2.id)
+ expect(migration).to be_scheduled_delayed_migration(6.minutes, 'none', 'down', group_board3.id, group_board4.id)
+ expect(BackgroundMigrationWorker.jobs.size).to eq 3
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/backfill_stage_event_hash_spec.rb b/spec/migrations/backfill_stage_event_hash_spec.rb
new file mode 100644
index 00000000000..cecaddcd3d4
--- /dev/null
+++ b/spec/migrations/backfill_stage_event_hash_spec.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe BackfillStageEventHash, schema: 20210730103808 do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:labels) { table(:labels) }
+ let(:group_stages) { table(:analytics_cycle_analytics_group_stages) }
+ let(:project_stages) { table(:analytics_cycle_analytics_project_stages) }
+ let(:group_value_streams) { table(:analytics_cycle_analytics_group_value_streams) }
+ let(:project_value_streams) { table(:analytics_cycle_analytics_project_value_streams) }
+ let(:stage_event_hashes) { table(:analytics_cycle_analytics_stage_event_hashes) }
+
+ let(:issue_created) { 1 }
+ let(:issue_closed) { 3 }
+ let(:issue_label_removed) { 9 }
+ let(:unknown_stage_event) { -1 }
+
+ let(:namespace) { namespaces.create!(name: 'ns', path: 'ns', type: 'Group') }
+ let(:project) { projects.create!(name: 'project', path: 'project', namespace_id: namespace.id) }
+ let(:group_label) { labels.create!(title: 'label', type: 'GroupLabel', group_id: namespace.id) }
+ let(:group_value_stream) { group_value_streams.create!(name: 'group vs', group_id: namespace.id) }
+ let(:project_value_stream) { project_value_streams.create!(name: 'project vs', project_id: project.id) }
+
+ let(:group_stage_1) do
+ group_stages.create!(
+ name: 'stage 1',
+ group_id: namespace.id,
+ start_event_identifier: issue_created,
+ end_event_identifier: issue_closed,
+ group_value_stream_id: group_value_stream.id
+ )
+ end
+
+ let(:group_stage_2) do
+ group_stages.create!(
+ name: 'stage 2',
+ group_id: namespace.id,
+ start_event_identifier: issue_created,
+ end_event_identifier: issue_label_removed,
+ end_event_label_id: group_label.id,
+ group_value_stream_id: group_value_stream.id
+ )
+ end
+
+ let(:project_stage_1) do
+ project_stages.create!(
+ name: 'stage 1',
+ project_id: project.id,
+ start_event_identifier: issue_created,
+ end_event_identifier: issue_closed,
+ project_value_stream_id: project_value_stream.id
+ )
+ end
+
+ let(:invalid_group_stage) do
+ group_stages.create!(
+ name: 'stage 3',
+ group_id: namespace.id,
+ start_event_identifier: issue_created,
+ end_event_identifier: unknown_stage_event,
+ group_value_stream_id: group_value_stream.id
+ )
+ end
+
+ describe '#up' do
+ it 'populates stage_event_hash_id column' do
+ group_stage_1
+ group_stage_2
+ project_stage_1
+
+ migrate!
+
+ group_stage_1.reload
+ group_stage_2.reload
+ project_stage_1.reload
+
+ expect(group_stage_1.stage_event_hash_id).not_to be_nil
+ expect(group_stage_2.stage_event_hash_id).not_to be_nil
+ expect(project_stage_1.stage_event_hash_id).not_to be_nil
+
+ expect(stage_event_hashes.count).to eq(2) # group_stage_1 and project_stage_1 has the same hash
+ end
+
+ it 'runs without problem without stages' do
+ expect { migrate! }.not_to raise_error
+ end
+
+ context 'when invalid event identifier is discovered' do
+ it 'removes the stage' do
+ group_stage_1
+ invalid_group_stage
+
+ expect { migrate! }.not_to change { group_stage_1 }
+
+ expect(group_stages.find_by_id(invalid_group_stage.id)).to eq(nil)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/cleanup_remaining_orphan_invites_spec.rb b/spec/migrations/cleanup_remaining_orphan_invites_spec.rb
new file mode 100644
index 00000000000..0eb1f5a578a
--- /dev/null
+++ b/spec/migrations/cleanup_remaining_orphan_invites_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration! 'cleanup_remaining_orphan_invites'
+
+RSpec.describe CleanupRemainingOrphanInvites, :migration do
+ def create_member(**extra_attributes)
+ defaults = {
+ access_level: 10,
+ source_id: 1,
+ source_type: "Project",
+ notification_level: 0,
+ type: 'ProjectMember'
+ }
+
+ table(:members).create!(defaults.merge(extra_attributes))
+ end
+
+ def create_user(**extra_attributes)
+ defaults = { projects_limit: 0 }
+ table(:users).create!(defaults.merge(extra_attributes))
+ end
+
+ describe '#up', :aggregate_failures do
+ it 'removes invite tokens for accepted records' do
+ record1 = create_member(invite_token: 'foo', user_id: nil)
+ record2 = create_member(invite_token: 'foo2', user_id: create_user(username: 'foo', email: 'foo@example.com').id)
+ record3 = create_member(invite_token: nil, user_id: create_user(username: 'bar', email: 'bar@example.com').id)
+
+ migrate!
+
+ expect(table(:members).find(record1.id).invite_token).to eq 'foo'
+ expect(table(:members).find(record2.id).invite_token).to eq nil
+ expect(table(:members).find(record3.id).invite_token).to eq nil
+ end
+ end
+end
diff --git a/spec/migrations/disable_job_token_scope_when_unused_spec.rb b/spec/migrations/disable_job_token_scope_when_unused_spec.rb
new file mode 100644
index 00000000000..d969c98aa0f
--- /dev/null
+++ b/spec/migrations/disable_job_token_scope_when_unused_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe DisableJobTokenScopeWhenUnused do
+ let(:ci_cd_settings) { table(:project_ci_cd_settings) }
+ let(:links) { table(:ci_job_token_project_scope_links) }
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+
+ let(:namespace) { namespaces.create!(name: 'test', path: 'path', type: 'Group') }
+
+ let(:project_with_used_scope) { projects.create!(namespace_id: namespace.id) }
+ let!(:used_scope_settings) { ci_cd_settings.create!(project_id: project_with_used_scope.id, job_token_scope_enabled: true) }
+ let(:target_project) { projects.create!(namespace_id: namespace.id) }
+ let!(:link) { links.create!(source_project_id: project_with_used_scope.id, target_project_id: target_project.id) }
+
+ let(:project_with_unused_scope) { projects.create!(namespace_id: namespace.id) }
+ let!(:unused_scope_settings) { ci_cd_settings.create!(project_id: project_with_unused_scope.id, job_token_scope_enabled: true) }
+
+ let(:project_with_disabled_scope) { projects.create!(namespace_id: namespace.id) }
+ let!(:disabled_scope_settings) { ci_cd_settings.create!(project_id: project_with_disabled_scope.id, job_token_scope_enabled: false) }
+
+ describe '#up' do
+ it 'sets job_token_scope_enabled to false for projects not having job token scope configured' do
+ migrate!
+
+ expect(unused_scope_settings.reload.job_token_scope_enabled).to be_falsey
+ end
+
+ it 'keeps the scope enabled for projects that are using it' do
+ migrate!
+
+ expect(used_scope_settings.reload.job_token_scope_enabled).to be_truthy
+ end
+
+ it 'keeps the scope disabled for projects having it disabled' do
+ migrate!
+
+ expect(disabled_scope_settings.reload.job_token_scope_enabled).to be_falsey
+ end
+ end
+end
diff --git a/spec/migrations/remove_duplicate_dast_site_tokens_spec.rb b/spec/migrations/remove_duplicate_dast_site_tokens_spec.rb
new file mode 100644
index 00000000000..fed9941b2a4
--- /dev/null
+++ b/spec/migrations/remove_duplicate_dast_site_tokens_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe RemoveDuplicateDastSiteTokens do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:dast_site_tokens) { table(:dast_site_tokens) }
+ let!(:namespace) { namespaces.create!(id: 1, name: 'group', path: 'group') }
+ let!(:project1) { projects.create!(id: 1, namespace_id: namespace.id, path: 'project1') }
+ # create non duplicate dast site token
+ let!(:dast_site_token1) { dast_site_tokens.create!(project_id: project1.id, url: 'https://gitlab.com', token: SecureRandom.uuid) }
+
+ context 'when duplicate dast site tokens exists' do
+ # create duplicate dast site token
+ let_it_be(:duplicate_url) { 'https://about.gitlab.com' }
+
+ let!(:project2) { projects.create!(id: 2, namespace_id: namespace.id, path: 'project2') }
+ let!(:dast_site_token2) { dast_site_tokens.create!(project_id: project2.id, url: duplicate_url, token: SecureRandom.uuid) }
+ let!(:dast_site_token3) { dast_site_tokens.create!(project_id: project2.id, url: 'https://temp_url.com', token: SecureRandom.uuid) }
+ let!(:dast_site_token4) { dast_site_tokens.create!(project_id: project2.id, url: 'https://other_temp_url.com', token: SecureRandom.uuid) }
+
+ before 'update URL to bypass uniqueness validation' do
+ dast_site_tokens.where(project_id: 2).update_all(url: duplicate_url)
+ end
+
+ describe 'migration up' do
+ it 'does remove duplicated dast site tokens' do
+ expect(dast_site_tokens.count).to eq(4)
+ expect(dast_site_tokens.where(project_id: 2, url: duplicate_url).size).to eq(3)
+
+ migrate!
+
+ expect(dast_site_tokens.count).to eq(2)
+ expect(dast_site_tokens.where(project_id: 2, url: duplicate_url).size).to eq(1)
+ end
+ end
+ end
+
+ context 'when duplicate dast site tokens does not exists' do
+ before do
+ dast_site_tokens.create!(project_id: 1, url: 'https://about.gitlab.com/handbook', token: SecureRandom.uuid)
+ end
+
+ describe 'migration up' do
+ it 'does remove duplicated dast site tokens' do
+ expect { migrate! }.not_to change(dast_site_tokens, :count)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/remove_duplicate_dast_site_tokens_with_same_token_spec.rb b/spec/migrations/remove_duplicate_dast_site_tokens_with_same_token_spec.rb
new file mode 100644
index 00000000000..57d677af5cf
--- /dev/null
+++ b/spec/migrations/remove_duplicate_dast_site_tokens_with_same_token_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe RemoveDuplicateDastSiteTokensWithSameToken do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:dast_site_tokens) { table(:dast_site_tokens) }
+ let!(:namespace) { namespaces.create!(id: 1, name: 'group', path: 'group') }
+ let!(:project1) { projects.create!(id: 1, namespace_id: namespace.id, path: 'project1') }
+ # create non duplicate dast site token
+ let!(:dast_site_token1) { dast_site_tokens.create!(project_id: project1.id, url: 'https://gitlab.com', token: SecureRandom.uuid) }
+
+ context 'when duplicate dast site tokens exists' do
+ # create duplicate dast site token
+ let_it_be(:duplicate_token) { 'duplicate_token' }
+ let_it_be(:other_duplicate_token) { 'other_duplicate_token' }
+
+ let!(:project2) { projects.create!(id: 2, namespace_id: namespace.id, path: 'project2') }
+ let!(:dast_site_token2) { dast_site_tokens.create!(project_id: project2.id, url: 'https://gitlab2.com', token: duplicate_token) }
+ let!(:dast_site_token3) { dast_site_tokens.create!(project_id: project2.id, url: 'https://gitlab3.com', token: duplicate_token) }
+ let!(:dast_site_token4) { dast_site_tokens.create!(project_id: project2.id, url: 'https://gitlab4.com', token: duplicate_token) }
+
+ let!(:project3) { projects.create!(id: 3, namespace_id: namespace.id, path: 'project3') }
+ let!(:dast_site_token5) { dast_site_tokens.create!(project_id: project3.id, url: 'https://gitlab2.com', token: other_duplicate_token) }
+ let!(:dast_site_token6) { dast_site_tokens.create!(project_id: project3.id, url: 'https://gitlab3.com', token: other_duplicate_token) }
+ let!(:dast_site_token7) { dast_site_tokens.create!(project_id: project3.id, url: 'https://gitlab4.com', token: other_duplicate_token) }
+
+ describe 'migration up' do
+ it 'does remove duplicated dast site tokens with the same token' do
+ expect(dast_site_tokens.count).to eq(7)
+ expect(dast_site_tokens.where(token: duplicate_token).size).to eq(3)
+
+ migrate!
+
+ expect(dast_site_tokens.count).to eq(3)
+ expect(dast_site_tokens.where(token: duplicate_token).size).to eq(1)
+ end
+ end
+ end
+
+ context 'when duplicate dast site tokens do not exist' do
+ let!(:dast_site_token5) { dast_site_tokens.create!(project_id: 1, url: 'https://gitlab5.com', token: SecureRandom.uuid) }
+
+ describe 'migration up' do
+ it 'does not remove any dast site tokens' do
+ expect { migrate! }.not_to change(dast_site_tokens, :count)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/replace_external_wiki_triggers_spec.rb b/spec/migrations/replace_external_wiki_triggers_spec.rb
new file mode 100644
index 00000000000..392ef76c5ba
--- /dev/null
+++ b/spec/migrations/replace_external_wiki_triggers_spec.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe ReplaceExternalWikiTriggers do
+ let(:migration) { described_class.new }
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:integrations) { table(:integrations) }
+
+ before do
+ @namespace = namespaces.create!(name: 'foo', path: 'foo')
+ @project = projects.create!(namespace_id: @namespace.id)
+ end
+
+ def create_external_wiki_integration(**attrs)
+ attrs.merge!(type_info)
+
+ integrations.create!(**attrs)
+ end
+
+ def has_external_wiki
+ !!@project.reload.has_external_wiki
+ end
+
+ shared_examples 'external wiki triggers' do
+ describe 'INSERT trigger' do
+ it 'sets `has_external_wiki` to true when active external wiki integration is inserted' do
+ expect do
+ create_external_wiki_integration(active: true, project_id: @project.id)
+ end.to change { has_external_wiki }.to(true)
+ end
+
+ it 'does not set `has_external_wiki` to true when integration is for a different project' do
+ different_project = projects.create!(namespace_id: @namespace.id)
+
+ expect do
+ create_external_wiki_integration(active: true, project_id: different_project.id)
+ end.not_to change { has_external_wiki }
+ end
+
+ it 'does not set `has_external_wiki` to true when inactive external wiki integration is inserted' do
+ expect do
+ create_external_wiki_integration(active: false, project_id: @project.id)
+ end.not_to change { has_external_wiki }
+ end
+
+ it 'does not set `has_external_wiki` to true when active other service is inserted' do
+ expect do
+ integrations.create!(type_new: 'Integrations::MyService', type: 'MyService', active: true, project_id: @project.id)
+ end.not_to change { has_external_wiki }
+ end
+ end
+
+ describe 'UPDATE trigger' do
+ it 'sets `has_external_wiki` to true when `ExternalWikiService` is made active' do
+ service = create_external_wiki_integration(active: false, project_id: @project.id)
+
+ expect do
+ service.update!(active: true)
+ end.to change { has_external_wiki }.to(true)
+ end
+
+ it 'sets `has_external_wiki` to false when integration is made inactive' do
+ service = create_external_wiki_integration(active: true, project_id: @project.id)
+
+ expect do
+ service.update!(active: false)
+ end.to change { has_external_wiki }.to(false)
+ end
+
+ it 'does not change `has_external_wiki` when integration is for a different project' do
+ different_project = projects.create!(namespace_id: @namespace.id)
+ service = create_external_wiki_integration(active: false, project_id: different_project.id)
+
+ expect do
+ service.update!(active: true)
+ end.not_to change { has_external_wiki }
+ end
+ end
+
+ describe 'DELETE trigger' do
+ it 'sets `has_external_wiki` to false when integration is deleted' do
+ service = create_external_wiki_integration(active: true, project_id: @project.id)
+
+ expect do
+ service.delete
+ end.to change { has_external_wiki }.to(false)
+ end
+
+ it 'does not change `has_external_wiki` when integration is for a different project' do
+ different_project = projects.create!(namespace_id: @namespace.id)
+ service = create_external_wiki_integration(active: true, project_id: different_project.id)
+
+ expect do
+ service.delete
+ end.not_to change { has_external_wiki }
+ end
+ end
+ end
+
+ describe '#up' do
+ before do
+ migrate!
+ end
+
+ context 'when integrations are created with the new STI value' do
+ let(:type_info) { { type_new: 'Integrations::ExternalWiki' } }
+
+ it_behaves_like 'external wiki triggers'
+ end
+
+ context 'when integrations are created with the old STI value' do
+ let(:type_info) { { type: 'ExternalWikiService' } }
+
+ it_behaves_like 'external wiki triggers'
+ end
+ end
+
+ describe '#down' do
+ before do
+ migration.up
+ migration.down
+ end
+
+ let(:type_info) { { type: 'ExternalWikiService' } }
+
+ it_behaves_like 'external wiki triggers'
+ end
+end
diff --git a/spec/migrations/set_default_job_token_scope_true_spec.rb b/spec/migrations/set_default_job_token_scope_true_spec.rb
new file mode 100644
index 00000000000..e7c77357318
--- /dev/null
+++ b/spec/migrations/set_default_job_token_scope_true_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SetDefaultJobTokenScopeTrue, schema: 20210819153805 do
+ let(:ci_cd_settings) { table(:project_ci_cd_settings) }
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+
+ let(:namespace) { namespaces.create!(name: 'test', path: 'path', type: 'Group') }
+ let(:project) { projects.create!(namespace_id: namespace.id) }
+
+ describe '#up' do
+ it 'sets the job_token_scope_enabled default to true' do
+ described_class.new.up
+
+ settings = ci_cd_settings.create!(project_id: project.id)
+
+ expect(settings.job_token_scope_enabled).to be_truthy
+ end
+ end
+
+ describe '#down' do
+ it 'sets the job_token_scope_enabled default to false' do
+ described_class.new.down
+
+ settings = ci_cd_settings.create!(project_id: project.id)
+
+ expect(settings.job_token_scope_enabled).to be_falsey
+ end
+ end
+end
diff --git a/spec/migrations/slice_merge_request_diff_commit_migrations_spec.rb b/spec/migrations/slice_merge_request_diff_commit_migrations_spec.rb
new file mode 100644
index 00000000000..1fd19ee42b4
--- /dev/null
+++ b/spec/migrations/slice_merge_request_diff_commit_migrations_spec.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration! 'slice_merge_request_diff_commit_migrations'
+
+RSpec.describe SliceMergeRequestDiffCommitMigrations, :migration do
+ let(:migration) { described_class.new }
+
+ describe '#up' do
+ context 'when there are no jobs to process' do
+ it 'does nothing' do
+ expect(migration).not_to receive(:migrate_in)
+ expect(Gitlab::Database::BackgroundMigrationJob).not_to receive(:create!)
+
+ migration.up
+ end
+ end
+
+ context 'when there are pending jobs' do
+ let!(:job1) do
+ Gitlab::Database::BackgroundMigrationJob.create!(
+ class_name: described_class::MIGRATION_CLASS,
+ arguments: [1, 10_001]
+ )
+ end
+
+ let!(:job2) do
+ Gitlab::Database::BackgroundMigrationJob.create!(
+ class_name: described_class::MIGRATION_CLASS,
+ arguments: [10_001, 20_001]
+ )
+ end
+
+ it 'marks the old jobs as finished' do
+ migration.up
+
+ job1.reload
+ job2.reload
+
+ expect(job1).to be_succeeded
+ expect(job2).to be_succeeded
+ end
+
+ it 'the jobs are slices into smaller ranges' do
+ migration.up
+
+ new_jobs = Gitlab::Database::BackgroundMigrationJob
+ .for_migration_class(described_class::MIGRATION_CLASS)
+ .pending
+ .to_a
+
+ expect(new_jobs.map(&:arguments)).to eq([
+ [1, 5_001],
+ [5_001, 10_001],
+ [10_001, 15_001],
+ [15_001, 20_001]
+ ])
+ end
+
+ it 'schedules a background migration for the first job' do
+ expect(migration)
+ .to receive(:migrate_in)
+ .with(1.hour, described_class::STEAL_MIGRATION_CLASS, [1, 5_001])
+
+ migration.up
+ end
+ end
+ end
+end
diff --git a/spec/migrations/steal_merge_request_diff_commit_users_migration_spec.rb b/spec/migrations/steal_merge_request_diff_commit_users_migration_spec.rb
new file mode 100644
index 00000000000..3ad0b5a93c2
--- /dev/null
+++ b/spec/migrations/steal_merge_request_diff_commit_users_migration_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration! 'steal_merge_request_diff_commit_users_migration'
+
+RSpec.describe StealMergeRequestDiffCommitUsersMigration, :migration do
+ let(:migration) { described_class.new }
+
+ describe '#up' do
+ it 'schedules a job if there are pending jobs' do
+ Gitlab::Database::BackgroundMigrationJob.create!(
+ class_name: 'MigrateMergeRequestDiffCommitUsers',
+ arguments: [10, 20]
+ )
+
+ expect(migration)
+ .to receive(:migrate_in)
+ .with(1.hour, 'StealMigrateMergeRequestDiffCommitUsers', [10, 20])
+
+ migration.up
+ end
+
+ it 'does not schedule any jobs when all jobs have been completed' do
+ expect(migration).not_to receive(:migrate_in)
+
+ migration.up
+ end
+ end
+end
diff --git a/spec/migrations/update_integrations_trigger_type_new_on_insert_spec.rb b/spec/migrations/update_integrations_trigger_type_new_on_insert_spec.rb
new file mode 100644
index 00000000000..41cf35b40f4
--- /dev/null
+++ b/spec/migrations/update_integrations_trigger_type_new_on_insert_spec.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe UpdateIntegrationsTriggerTypeNewOnInsert do
+ let(:migration) { described_class.new }
+ let(:integrations) { table(:integrations) }
+
+ shared_examples 'transforms known types' do
+ # This matches Gitlab::Integrations::StiType at the time the original trigger
+ # was added in db/migrate/20210721135638_add_triggers_to_integrations_type_new.rb
+ let(:namespaced_integrations) do
+ %w[
+ Asana Assembla Bamboo Bugzilla Buildkite Campfire Confluence CustomIssueTracker Datadog
+ Discord DroneCi EmailsOnPush Ewm ExternalWiki Flowdock HangoutsChat Irker Jenkins Jira Mattermost
+ MattermostSlashCommands MicrosoftTeams MockCi MockMonitoring Packagist PipelinesEmail Pivotaltracker
+ Prometheus Pushover Redmine Slack SlackSlashCommands Teamcity UnifyCircuit WebexTeams Youtrack
+
+ Github GitlabSlackApplication
+ ]
+ end
+
+ it 'sets `type_new` to the transformed `type` class name' do
+ namespaced_integrations.each do |type|
+ integration = integrations.create!(type: "#{type}Service")
+
+ expect(integration.reload).to have_attributes(
+ type: "#{type}Service",
+ type_new: "Integrations::#{type}"
+ )
+ end
+ end
+ end
+
+ describe '#up' do
+ before do
+ migrate!
+ end
+
+ describe 'INSERT trigger with dynamic mapping' do
+ it_behaves_like 'transforms known types'
+
+ it 'transforms unknown types if it ends in "Service"' do
+ integration = integrations.create!(type: 'AcmeService')
+
+ expect(integration.reload).to have_attributes(
+ type: 'AcmeService',
+ type_new: 'Integrations::Acme'
+ )
+ end
+
+ it 'ignores "Service" occurring elsewhere in the type' do
+ integration = integrations.create!(type: 'ServiceAcmeService')
+
+ expect(integration.reload).to have_attributes(
+ type: 'ServiceAcmeService',
+ type_new: 'Integrations::ServiceAcme'
+ )
+ end
+
+ it 'copies unknown types if it does not end with "Service"' do
+ integration = integrations.create!(type: 'Integrations::Acme')
+
+ expect(integration.reload).to have_attributes(
+ type: 'Integrations::Acme',
+ type_new: 'Integrations::Acme'
+ )
+ end
+ end
+ end
+
+ describe '#down' do
+ before do
+ migration.up
+ migration.down
+ end
+
+ describe 'INSERT trigger with static mapping' do
+ it_behaves_like 'transforms known types'
+
+ it 'ignores types that are already namespaced' do
+ integration = integrations.create!(type: 'Integrations::Asana')
+
+ expect(integration.reload).to have_attributes(
+ type: 'Integrations::Asana',
+ type_new: nil
+ )
+ end
+
+ it 'ignores types that are unknown' do
+ integration = integrations.create!(type: 'FooBar')
+
+ expect(integration.reload).to have_attributes(
+ type: 'FooBar',
+ type_new: nil
+ )
+ end
+ end
+ end
+end
diff --git a/spec/migrations/update_minimum_password_length_spec.rb b/spec/migrations/update_minimum_password_length_spec.rb
index 02254ba1343..e40d090fd77 100644
--- a/spec/migrations/update_minimum_password_length_spec.rb
+++ b/spec/migrations/update_minimum_password_length_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe UpdateMinimumPasswordLength do
before do
stub_const('ApplicationSetting::DEFAULT_MINIMUM_PASSWORD_LENGTH', 10)
- allow(Devise.password_length).to receive(:min).and_return(12)
+ allow(Devise).to receive(:password_length).and_return(12..20)
end
it 'correctly migrates minimum_password_length' do
diff --git a/spec/models/analytics/cycle_analytics/issue_stage_event_spec.rb b/spec/models/analytics/cycle_analytics/issue_stage_event_spec.rb
new file mode 100644
index 00000000000..3e6d4ebd0a2
--- /dev/null
+++ b/spec/models/analytics/cycle_analytics/issue_stage_event_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Analytics::CycleAnalytics::IssueStageEvent do
+ it { is_expected.to validate_presence_of(:stage_event_hash_id) }
+ it { is_expected.to validate_presence_of(:issue_id) }
+ it { is_expected.to validate_presence_of(:group_id) }
+ it { is_expected.to validate_presence_of(:project_id) }
+ it { is_expected.to validate_presence_of(:start_event_timestamp) }
+end
diff --git a/spec/models/analytics/cycle_analytics/merge_request_stage_event_spec.rb b/spec/models/analytics/cycle_analytics/merge_request_stage_event_spec.rb
new file mode 100644
index 00000000000..244c5c70286
--- /dev/null
+++ b/spec/models/analytics/cycle_analytics/merge_request_stage_event_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Analytics::CycleAnalytics::MergeRequestStageEvent do
+ it { is_expected.to validate_presence_of(:stage_event_hash_id) }
+ it { is_expected.to validate_presence_of(:merge_request_id) }
+ it { is_expected.to validate_presence_of(:group_id) }
+ it { is_expected.to validate_presence_of(:project_id) }
+ it { is_expected.to validate_presence_of(:start_event_timestamp) }
+end
diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb
index f9a05c720a3..efb92ddaea0 100644
--- a/spec/models/application_record_spec.rb
+++ b/spec/models/application_record_spec.rb
@@ -39,7 +39,7 @@ RSpec.describe ApplicationRecord do
let(:suggestion_attributes) { attributes_for(:suggestion).merge!(note_id: note.id) }
- shared_examples '.safe_find_or_create_by' do
+ describe '.safe_find_or_create_by' do
it 'creates the suggestion avoiding race conditions' do
existing_suggestion = double(:Suggestion)
@@ -63,7 +63,7 @@ RSpec.describe ApplicationRecord do
end
end
- shared_examples '.safe_find_or_create_by!' do
+ describe '.safe_find_or_create_by!' do
it 'creates a record using safe_find_or_create_by' do
expect(Suggestion.safe_find_or_create_by!(suggestion_attributes))
.to be_a(Suggestion)
@@ -88,24 +88,6 @@ RSpec.describe ApplicationRecord do
.to raise_error(ActiveRecord::RecordNotFound)
end
end
-
- context 'when optimized_safe_find_or_create_by is enabled' do
- before do
- stub_feature_flags(optimized_safe_find_or_create_by: true)
- end
-
- it_behaves_like '.safe_find_or_create_by'
- it_behaves_like '.safe_find_or_create_by!'
- end
-
- context 'when optimized_safe_find_or_create_by is disabled' do
- before do
- stub_feature_flags(optimized_safe_find_or_create_by: false)
- end
-
- it_behaves_like '.safe_find_or_create_by'
- it_behaves_like '.safe_find_or_create_by!'
- end
end
describe '.underscore' do
@@ -164,6 +146,23 @@ RSpec.describe ApplicationRecord do
end
end
end
+
+ # rubocop:disable Database/MultipleDatabases
+ it 'increments a counter when a transaction is created in ActiveRecord' do
+ expect(described_class.connection.transaction_open?).to be false
+
+ expect(::Gitlab::Database::Metrics)
+ .to receive(:subtransactions_increment)
+ .with('ActiveRecord::Base')
+ .once
+
+ ActiveRecord::Base.transaction do
+ ActiveRecord::Base.transaction(requires_new: true) do
+ expect(ActiveRecord::Base.connection.transaction_open?).to be true
+ end
+ end
+ end
+ # rubocop:enable Database/MultipleDatabases
end
describe '.with_fast_read_statement_timeout' do
@@ -236,4 +235,46 @@ RSpec.describe ApplicationRecord do
end
end
end
+
+ describe '.default_select_columns' do
+ shared_examples_for 'selects identically to the default' do
+ it 'generates the same sql as the default' do
+ expected_sql = test_model.all.to_sql
+ generated_sql = test_model.all.select(test_model.default_select_columns).to_sql
+
+ expect(expected_sql).to eq(generated_sql)
+ end
+ end
+
+ before do
+ ApplicationRecord.connection.execute(<<~SQL)
+ create table tests (
+ id bigserial primary key not null,
+ ignore_me text
+ )
+ SQL
+ end
+ context 'without an ignored column' do
+ let(:test_model) do
+ Class.new(ApplicationRecord) do
+ self.table_name = 'tests'
+ end
+ end
+
+ it_behaves_like 'selects identically to the default'
+ end
+
+ context 'with an ignored column' do
+ let(:test_model) do
+ Class.new(ApplicationRecord) do
+ include IgnorableColumns
+ self.table_name = 'tests'
+
+ ignore_columns :ignore_me, remove_after: '2100-01-01', remove_with: '99.12'
+ end
+ end
+
+ it_behaves_like 'selects identically to the default'
+ end
+ end
end
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index e9c5ffef210..3e264867703 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -7,6 +7,8 @@ RSpec.describe ApplicationSetting do
subject(:setting) { described_class.create_from_defaults }
+ it_behaves_like 'sanitizable', :application_setting, %i[default_branch_name]
+
it { include(CacheableAttributes) }
it { include(ApplicationSettingImplementation) }
it { expect(described_class.current_without_cache).to eq(described_class.last) }
@@ -79,12 +81,19 @@ RSpec.describe ApplicationSetting do
it { is_expected.to validate_numericality_of(:wiki_page_max_content_bytes).only_integer.is_greater_than_or_equal_to(1024) }
it { is_expected.to validate_presence_of(:max_artifacts_size) }
it { is_expected.to validate_numericality_of(:max_artifacts_size).only_integer.is_greater_than(0) }
+ it { is_expected.to validate_presence_of(:max_yaml_size_bytes) }
+ it { is_expected.to validate_numericality_of(:max_yaml_size_bytes).only_integer.is_greater_than(0) }
+ it { is_expected.to validate_presence_of(:max_yaml_depth) }
+ it { is_expected.to validate_numericality_of(:max_yaml_depth).only_integer.is_greater_than(0) }
it { is_expected.to validate_presence_of(:max_pages_size) }
it 'ensures max_pages_size is an integer greater than 0 (or equal to 0 to indicate unlimited/maximum)' do
is_expected.to validate_numericality_of(:max_pages_size).only_integer.is_greater_than_or_equal_to(0)
.is_less_than(::Gitlab::Pages::MAX_SIZE / 1.megabyte)
end
+ it { is_expected.to validate_presence_of(:jobs_per_stage_page_size) }
+ it { is_expected.to validate_numericality_of(:jobs_per_stage_page_size).only_integer.is_greater_than_or_equal_to(0) }
+
it { is_expected.not_to allow_value(7).for(:minimum_password_length) }
it { is_expected.not_to allow_value(129).for(:minimum_password_length) }
it { is_expected.not_to allow_value(nil).for(:minimum_password_length) }
@@ -921,6 +930,8 @@ RSpec.describe ApplicationSetting do
context 'throttle_* settings' do
where(:throttle_setting) do
%i[
+ throttle_unauthenticated_api_requests_per_period
+ throttle_unauthenticated_api_period_in_seconds
throttle_unauthenticated_requests_per_period
throttle_unauthenticated_period_in_seconds
throttle_authenticated_api_requests_per_period
@@ -931,6 +942,12 @@ RSpec.describe ApplicationSetting do
throttle_unauthenticated_packages_api_period_in_seconds
throttle_authenticated_packages_api_requests_per_period
throttle_authenticated_packages_api_period_in_seconds
+ throttle_unauthenticated_files_api_requests_per_period
+ throttle_unauthenticated_files_api_period_in_seconds
+ throttle_authenticated_files_api_requests_per_period
+ throttle_authenticated_files_api_period_in_seconds
+ throttle_authenticated_git_lfs_requests_per_period
+ throttle_authenticated_git_lfs_period_in_seconds
]
end
@@ -942,6 +959,20 @@ RSpec.describe ApplicationSetting do
it { is_expected.not_to allow_value(nil).for(throttle_setting) }
end
end
+
+ context 'sidekiq job limiter settings' do
+ it 'has the right defaults', :aggregate_failures do
+ expect(setting.sidekiq_job_limiter_mode).to eq('compress')
+ expect(setting.sidekiq_job_limiter_compression_threshold_bytes)
+ .to eq(Gitlab::SidekiqMiddleware::SizeLimiter::Validator::DEFAULT_COMPRESSION_THRESHOLD_BYTES)
+ expect(setting.sidekiq_job_limiter_limit_bytes)
+ .to eq(Gitlab::SidekiqMiddleware::SizeLimiter::Validator::DEFAULT_SIZE_LIMIT)
+ end
+
+ it { is_expected.to allow_value('track').for(:sidekiq_job_limiter_mode) }
+ it { is_expected.to validate_numericality_of(:sidekiq_job_limiter_compression_threshold_bytes).only_integer.is_greater_than_or_equal_to(0) }
+ it { is_expected.to validate_numericality_of(:sidekiq_job_limiter_limit_bytes).only_integer.is_greater_than_or_equal_to(0) }
+ end
end
context 'restrict creating duplicates' do
diff --git a/spec/models/bulk_imports/entity_spec.rb b/spec/models/bulk_imports/entity_spec.rb
index 11a3e53dd16..c1cbe61885f 100644
--- a/spec/models/bulk_imports/entity_spec.rb
+++ b/spec/models/bulk_imports/entity_spec.rb
@@ -154,4 +154,57 @@ RSpec.describe BulkImports::Entity, type: :model do
expect(described_class.all_human_statuses).to contain_exactly('created', 'started', 'finished', 'failed')
end
end
+
+ describe '#pipelines' do
+ context 'when entity is group' do
+ it 'returns group pipelines' do
+ entity = build(:bulk_import_entity, :group_entity)
+
+ expect(entity.pipelines.flatten).to include(BulkImports::Groups::Pipelines::GroupPipeline)
+ end
+ end
+
+ context 'when entity is project' do
+ it 'returns project pipelines' do
+ entity = build(:bulk_import_entity, :project_entity)
+
+ expect(entity.pipelines.flatten).to include(BulkImports::Projects::Pipelines::ProjectPipeline)
+ end
+ end
+ end
+
+ describe '#create_pipeline_trackers!' do
+ context 'when entity is group' do
+ it 'creates trackers for group entity' do
+ entity = create(:bulk_import_entity, :group_entity)
+ entity.create_pipeline_trackers!
+
+ expect(entity.trackers.count).to eq(BulkImports::Groups::Stage.pipelines.count)
+ expect(entity.trackers.map(&:pipeline_name)).to include(BulkImports::Groups::Pipelines::GroupPipeline.to_s)
+ end
+ end
+
+ context 'when entity is project' do
+ it 'creates trackers for project entity' do
+ entity = create(:bulk_import_entity, :project_entity)
+ entity.create_pipeline_trackers!
+
+ expect(entity.trackers.count).to eq(BulkImports::Projects::Stage.pipelines.count)
+ expect(entity.trackers.map(&:pipeline_name)).to include(BulkImports::Projects::Pipelines::ProjectPipeline.to_s)
+ end
+ end
+ end
+
+ describe '#pipeline_exists?' do
+ let_it_be(:entity) { create(:bulk_import_entity, :group_entity) }
+
+ it 'returns true when the given pipeline name exists in the pipelines list' do
+ expect(entity.pipeline_exists?(BulkImports::Groups::Pipelines::GroupPipeline)).to eq(true)
+ expect(entity.pipeline_exists?('BulkImports::Groups::Pipelines::GroupPipeline')).to eq(true)
+ end
+
+ it 'returns false when the given pipeline name exists in the pipelines list' do
+ expect(entity.pipeline_exists?('BulkImports::Groups::Pipelines::InexistentPipeline')).to eq(false)
+ end
+ end
end
diff --git a/spec/models/bulk_imports/tracker_spec.rb b/spec/models/bulk_imports/tracker_spec.rb
index 0f00aeb9c1d..7f0a7d4f1ae 100644
--- a/spec/models/bulk_imports/tracker_spec.rb
+++ b/spec/models/bulk_imports/tracker_spec.rb
@@ -66,7 +66,7 @@ RSpec.describe BulkImports::Tracker, type: :model do
describe '#pipeline_class' do
it 'returns the pipeline class' do
- pipeline_class = BulkImports::Stage.pipelines.first[1]
+ pipeline_class = BulkImports::Groups::Stage.pipelines.first[1]
tracker = create(:bulk_import_tracker, pipeline_name: pipeline_class)
expect(tracker.pipeline_class).to eq(pipeline_class)
@@ -77,7 +77,7 @@ RSpec.describe BulkImports::Tracker, type: :model do
expect { tracker.pipeline_class }
.to raise_error(
- NameError,
+ BulkImports::Error,
"'InexistingPipeline' is not a valid BulkImport Pipeline"
)
end
diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb
index db956b26b6b..6dd3c40f228 100644
--- a/spec/models/ci/bridge_spec.rb
+++ b/spec/models/ci/bridge_spec.rb
@@ -74,18 +74,18 @@ RSpec.describe Ci::Bridge do
it "schedules downstream pipeline creation when the status is #{status}" do
bridge.status = status
- expect(bridge).to receive(:schedule_downstream_pipeline!)
-
bridge.enqueue!
+
+ expect(::Ci::CreateCrossProjectPipelineWorker.jobs.last['args']).to eq([bridge.id])
end
end
it "schedules downstream pipeline creation when the status is waiting for resource" do
bridge.status = :waiting_for_resource
- expect(bridge).to receive(:schedule_downstream_pipeline!)
-
bridge.enqueue_waiting_for_resource!
+
+ expect(::Ci::CreateCrossProjectPipelineWorker.jobs.last['args']).to eq([bridge.id])
end
it 'raises error when the status is failed' do
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 26abc98656e..1e06d566c80 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -1307,7 +1307,9 @@ RSpec.describe Ci::Build do
shared_examples_for 'avoid deadlock' do
it 'executes UPDATE in the right order' do
- recorded = ActiveRecord::QueryRecorder.new { subject }
+ recorded = with_cross_database_modification_prevented do
+ ActiveRecord::QueryRecorder.new { subject }
+ end
index_for_build = recorded.log.index { |l| l.include?("UPDATE \"ci_builds\"") }
index_for_deployment = recorded.log.index { |l| l.include?("UPDATE \"deployments\"") }
@@ -1322,7 +1324,9 @@ RSpec.describe Ci::Build do
it_behaves_like 'avoid deadlock'
it 'transits deployment status to running' do
- subject
+ with_cross_database_modification_prevented do
+ subject
+ end
expect(deployment).to be_running
end
@@ -1340,7 +1344,9 @@ RSpec.describe Ci::Build do
it_behaves_like 'calling proper BuildFinishedWorker'
it 'transits deployment status to success' do
- subject
+ with_cross_database_modification_prevented do
+ subject
+ end
expect(deployment).to be_success
end
@@ -1353,7 +1359,9 @@ RSpec.describe Ci::Build do
it_behaves_like 'calling proper BuildFinishedWorker'
it 'transits deployment status to failed' do
- subject
+ with_cross_database_modification_prevented do
+ subject
+ end
expect(deployment).to be_failed
end
@@ -1365,7 +1373,9 @@ RSpec.describe Ci::Build do
it_behaves_like 'avoid deadlock'
it 'transits deployment status to skipped' do
- subject
+ with_cross_database_modification_prevented do
+ subject
+ end
expect(deployment).to be_skipped
end
@@ -1378,7 +1388,9 @@ RSpec.describe Ci::Build do
it_behaves_like 'calling proper BuildFinishedWorker'
it 'transits deployment status to canceled' do
- subject
+ with_cross_database_modification_prevented do
+ subject
+ end
expect(deployment).to be_canceled
end
@@ -2632,6 +2644,10 @@ RSpec.describe Ci::Build do
value: "#{Gitlab.host_with_port}/#{project.namespace.root_ancestor.path.downcase}#{DependencyProxy::URL_SUFFIX}",
public: true,
masked: false },
+ { key: 'CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX',
+ value: "#{Gitlab.host_with_port}/#{project.namespace.full_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 },
@@ -5243,4 +5259,33 @@ RSpec.describe Ci::Build do
expect(described_class.with_coverage_regex).to eq([build_with_coverage_regex])
end
end
+
+ describe '#ensure_trace_metadata!' do
+ it 'delegates to Ci::BuildTraceMetadata' do
+ expect(Ci::BuildTraceMetadata)
+ .to receive(:find_or_upsert_for!)
+ .with(build.id)
+
+ build.ensure_trace_metadata!
+ end
+ end
+
+ describe '#doom!' do
+ subject { build.doom! }
+
+ let_it_be(:build) { create(:ci_build, :queued) }
+
+ it 'updates status and failure_reason', :aggregate_failures do
+ subject
+
+ expect(build.status).to eq("failed")
+ expect(build.failure_reason).to eq("data_integrity_failure")
+ end
+
+ it 'drops associated pending build' do
+ subject
+
+ expect(build.reload.queuing_entry).not_to be_present
+ end
+ end
end
diff --git a/spec/models/ci/build_trace_chunks/fog_spec.rb b/spec/models/ci/build_trace_chunks/fog_spec.rb
index 21dab6fad60..bbf04ef9430 100644
--- a/spec/models/ci/build_trace_chunks/fog_spec.rb
+++ b/spec/models/ci/build_trace_chunks/fog_spec.rb
@@ -107,37 +107,22 @@ RSpec.describe Ci::BuildTraceChunks::Fog do
let(:model) { create(:ci_build_trace_chunk, :fog_with_data, initial_data: initial_data) }
let(:data) { data_store.data(model) }
- context 'when ci_job_trace_force_encode is enabled' do
- it 'appends ASCII data' do
- data_store.append_data(model, +'hello world', 4)
+ it 'appends ASCII data' do
+ data_store.append_data(model, +'hello world', 4)
- expect(data.encoding).to eq(Encoding::ASCII_8BIT)
- expect(data.force_encoding(Encoding::UTF_8)).to eq('😺hello world')
- end
-
- it 'appends UTF-8 data' do
- data_store.append_data(model, +'Résumé', 4)
-
- expect(data.encoding).to eq(Encoding::ASCII_8BIT)
- expect(data.force_encoding(Encoding::UTF_8)).to eq("😺Résumé")
- end
-
- context 'when initial data is UTF-8' do
- let(:initial_data) { +'😺' }
+ expect(data.encoding).to eq(Encoding::ASCII_8BIT)
+ expect(data.force_encoding(Encoding::UTF_8)).to eq('😺hello world')
+ end
- it 'appends ASCII data' do
- data_store.append_data(model, +'hello world', 4)
+ it 'appends UTF-8 data' do
+ data_store.append_data(model, +'Résumé', 4)
- expect(data.encoding).to eq(Encoding::ASCII_8BIT)
- expect(data.force_encoding(Encoding::UTF_8)).to eq('😺hello world')
- end
- end
+ expect(data.encoding).to eq(Encoding::ASCII_8BIT)
+ expect(data.force_encoding(Encoding::UTF_8)).to eq("😺Résumé")
end
- context 'when ci_job_trace_force_encode is disabled' do
- before do
- stub_feature_flags(ci_job_trace_force_encode: false)
- end
+ context 'when initial data is UTF-8' do
+ let(:initial_data) { +'😺' }
it 'appends ASCII data' do
data_store.append_data(model, +'hello world', 4)
@@ -145,11 +130,6 @@ RSpec.describe Ci::BuildTraceChunks::Fog do
expect(data.encoding).to eq(Encoding::ASCII_8BIT)
expect(data.force_encoding(Encoding::UTF_8)).to eq('😺hello world')
end
-
- it 'throws an exception when appending UTF-8 data' do
- expect(Gitlab::ErrorTracking).to receive(:track_and_raise_exception).and_call_original
- expect { data_store.append_data(model, +'Résumé', 4) }.to raise_exception(Encoding::CompatibilityError)
- end
end
end
diff --git a/spec/models/ci/build_trace_metadata_spec.rb b/spec/models/ci/build_trace_metadata_spec.rb
index 42b9d5d34b6..5e4645c5dc4 100644
--- a/spec/models/ci/build_trace_metadata_spec.rb
+++ b/spec/models/ci/build_trace_metadata_spec.rb
@@ -7,4 +7,128 @@ RSpec.describe Ci::BuildTraceMetadata do
it { is_expected.to belong_to(:trace_artifact) }
it { is_expected.to validate_presence_of(:build) }
+ it { is_expected.to validate_presence_of(:archival_attempts) }
+
+ describe '#can_attempt_archival_now?' do
+ let(:metadata) do
+ build(:ci_build_trace_metadata,
+ archival_attempts: archival_attempts,
+ last_archival_attempt_at: last_archival_attempt_at)
+ end
+
+ subject { metadata.can_attempt_archival_now? }
+
+ context 'when archival_attempts is over the limit' do
+ let(:archival_attempts) { described_class::MAX_ATTEMPTS + 1 }
+ let(:last_archival_attempt_at) {}
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when last_archival_attempt_at is not set' do
+ let(:archival_attempts) { described_class::MAX_ATTEMPTS }
+ let(:last_archival_attempt_at) {}
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when last_archival_attempt_at is set' do
+ let(:archival_attempts) { described_class::MAX_ATTEMPTS }
+ let(:last_archival_attempt_at) { 6.days.ago }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when last_archival_attempt_at is too close' do
+ let(:archival_attempts) { described_class::MAX_ATTEMPTS }
+ let(:last_archival_attempt_at) { 1.hour.ago }
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
+ describe '#archival_attempts_available?' do
+ let(:metadata) do
+ build(:ci_build_trace_metadata, archival_attempts: archival_attempts)
+ end
+
+ subject { metadata.archival_attempts_available? }
+
+ context 'when archival_attempts is over the limit' do
+ let(:archival_attempts) { described_class::MAX_ATTEMPTS + 1 }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when archival_attempts is at the limit' do
+ let(:archival_attempts) { described_class::MAX_ATTEMPTS }
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ describe '#increment_archival_attempts!' do
+ let_it_be(:metadata) do
+ create(:ci_build_trace_metadata,
+ archival_attempts: 2,
+ last_archival_attempt_at: 1.day.ago)
+ end
+
+ it 'increments the attempts' do
+ expect { metadata.increment_archival_attempts! }
+ .to change { metadata.reload.archival_attempts }
+ end
+
+ it 'updates the last_archival_attempt_at timestamp' do
+ expect { metadata.increment_archival_attempts! }
+ .to change { metadata.reload.last_archival_attempt_at }
+ end
+ end
+
+ describe '#track_archival!' do
+ let(:trace_artifact) { create(:ci_job_artifact) }
+ let(:metadata) { create(:ci_build_trace_metadata) }
+
+ it 'stores the artifact id and timestamp' do
+ expect(metadata.trace_artifact_id).to be_nil
+
+ metadata.track_archival!(trace_artifact.id)
+ metadata.reload
+
+ expect(metadata.trace_artifact_id).to eq(trace_artifact.id)
+ expect(metadata.archived_at).to be_like_time(Time.current)
+ end
+ end
+
+ describe '.find_or_upsert_for!' do
+ let_it_be(:build) { create(:ci_build) }
+
+ subject(:execute) do
+ described_class.find_or_upsert_for!(build.id)
+ end
+
+ it 'creates a new record' do
+ metadata = execute
+
+ expect(metadata).to be_a(described_class)
+ expect(metadata.id).to eq(build.id)
+ expect(metadata.archival_attempts).to eq(0)
+ end
+
+ context 'with existing records' do
+ before do
+ create(:ci_build_trace_metadata,
+ build: build,
+ archival_attempts: described_class::MAX_ATTEMPTS)
+ end
+
+ it 'returns the existing record' do
+ metadata = execute
+
+ expect(metadata).to be_a(described_class)
+ expect(metadata.id).to eq(build.id)
+ expect(metadata.archival_attempts).to eq(described_class::MAX_ATTEMPTS)
+ end
+ end
+ end
end
diff --git a/spec/models/ci/pending_build_spec.rb b/spec/models/ci/pending_build_spec.rb
index 0518c9a1652..ad711f5622f 100644
--- a/spec/models/ci/pending_build_spec.rb
+++ b/spec/models/ci/pending_build_spec.rb
@@ -34,6 +34,47 @@ RSpec.describe Ci::PendingBuild do
end
end
end
+
+ describe '.for_tags' do
+ subject(:pending_builds) { described_class.for_tags(tag_ids) }
+
+ let_it_be(:pending_build_with_tags) { create(:ci_pending_build, tag_ids: [1, 2]) }
+ let_it_be(:pending_build_without_tags) { create(:ci_pending_build) }
+
+ context 'when tag_ids match pending builds' do
+ let(:tag_ids) { [1, 2] }
+
+ it 'returns matching pending builds' do
+ expect(pending_builds).to contain_exactly(pending_build_with_tags, pending_build_without_tags)
+ end
+ end
+
+ context 'when tag_ids does not match pending builds' do
+ let(:tag_ids) { [non_existing_record_id] }
+
+ it 'returns matching pending builds without tags' do
+ expect(pending_builds).to contain_exactly(pending_build_without_tags)
+ end
+ end
+
+ context 'when tag_ids is not provided' do
+ context 'with a nil value' do
+ let(:tag_ids) { nil }
+
+ it 'returns matching pending builds without tags' do
+ expect(pending_builds).to contain_exactly(pending_build_without_tags)
+ end
+ end
+
+ context 'with an empty array' do
+ let(:tag_ids) { [] }
+
+ it 'returns matching pending builds without tags' do
+ expect(pending_builds).to contain_exactly(pending_build_without_tags)
+ end
+ end
+ end
+ end
end
describe '.upsert_from_build!' do
@@ -58,7 +99,11 @@ RSpec.describe Ci::PendingBuild do
end
end
- context 'when project does not have shared runner' do
+ context 'when project does not have shared runners enabled' do
+ before do
+ project.shared_runners_enabled = false
+ end
+
it 'sets instance_runners_enabled to false' do
described_class.upsert_from_build!(build)
@@ -69,6 +114,10 @@ RSpec.describe Ci::PendingBuild do
context 'when project has shared runner' do
let_it_be(:runner) { create(:ci_runner, :instance) }
+ before do
+ project.shared_runners_enabled = true
+ end
+
context 'when ci_pending_builds_maintain_shared_runners_data is enabled' do
it 'sets instance_runners_enabled to true' do
described_class.upsert_from_build!(build)
@@ -113,5 +162,65 @@ RSpec.describe Ci::PendingBuild do
end
end
end
+
+ context 'when build has tags' do
+ let!(:build) { create(:ci_build, :tags) }
+
+ subject(:ci_pending_build) { described_class.last }
+
+ context 'when ci_pending_builds_maintain_tags_data is enabled' do
+ it 'sets tag_ids' do
+ described_class.upsert_from_build!(build)
+
+ expect(ci_pending_build.tag_ids).to eq(build.tags_ids)
+ end
+ end
+
+ context 'when ci_pending_builds_maintain_tags_data is disabled' do
+ before do
+ stub_feature_flags(ci_pending_builds_maintain_tags_data: false)
+ end
+
+ it 'does not set tag_ids' do
+ described_class.upsert_from_build!(build)
+
+ expect(ci_pending_build.tag_ids).to be_empty
+ end
+ end
+ end
+
+ context 'when a build project is nested in a subgroup' do
+ let(:group) { create(:group, :with_hierarchy, depth: 2, children: 1) }
+ let(:project) { create(:project, namespace: group.descendants.first) }
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:build) { create(:ci_build, :created, pipeline: pipeline) }
+
+ subject { described_class.last }
+
+ context 'when build can be picked by a group runner' do
+ before do
+ project.group_runners_enabled = true
+ end
+
+ it 'denormalizes namespace traversal ids' do
+ described_class.upsert_from_build!(build)
+
+ expect(subject.namespace_traversal_ids).not_to be_empty
+ expect(subject.namespace_traversal_ids).to eq [group.id, project.namespace.id]
+ end
+ end
+
+ context 'when build can not be picked by a group runner' do
+ before do
+ project.group_runners_enabled = false
+ end
+
+ it 'creates an empty namespace traversal ids array' do
+ described_class.upsert_from_build!(build)
+
+ expect(subject.namespace_traversal_ids).to be_empty
+ end
+ end
+ end
end
end
diff --git a/spec/models/ci/pipeline_schedule_spec.rb b/spec/models/ci/pipeline_schedule_spec.rb
index 8de3ebb18b9..c7e1fe91b1e 100644
--- a/spec/models/ci/pipeline_schedule_spec.rb
+++ b/spec/models/ci/pipeline_schedule_spec.rb
@@ -107,31 +107,24 @@ RSpec.describe Ci::PipelineSchedule do
describe '#set_next_run_at' do
using RSpec::Parameterized::TableSyntax
- where(:worker_cron, :schedule_cron, :plan_limit, :ff_enabled, :now, :result) do
- '0 1 2 3 *' | '0 1 * * *' | nil | true | Time.zone.local(2021, 3, 2, 1, 0) | Time.zone.local(2022, 3, 2, 1, 0)
- '0 1 2 3 *' | '0 1 * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | true | Time.zone.local(2021, 3, 2, 1, 0) | Time.zone.local(2022, 3, 2, 1, 0)
- '0 1 2 3 *' | '0 1 * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | false | Time.zone.local(2021, 3, 2, 1, 0) | Time.zone.local(2022, 3, 2, 1, 0)
- '*/5 * * * *' | '*/1 * * * *' | nil | true | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 11, 5)
- '*/5 * * * *' | '*/1 * * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | true | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 0)
- '*/5 * * * *' | '*/1 * * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | false | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 11, 5)
- '*/5 * * * *' | '*/1 * * * *' | (1.day.in_minutes / 10).to_i | true | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 11, 10)
- '*/5 * * * *' | '*/1 * * * *' | 200 | true | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 11, 10)
- '*/5 * * * *' | '*/1 * * * *' | 200 | false | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 11, 5)
- '*/5 * * * *' | '0 * * * *' | nil | true | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 5)
- '*/5 * * * *' | '0 * * * *' | (1.day.in_minutes / 10).to_i | true | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 0)
- '*/5 * * * *' | '0 * * * *' | (1.day.in_minutes / 10).to_i | false | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 5)
- '*/5 * * * *' | '0 * * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | true | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 0)
- '*/5 * * * *' | '0 * * * *' | (1.day.in_minutes / 2.hours.in_minutes).to_i | true | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 5)
- '*/5 * * * *' | '0 1 * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | true | Time.zone.local(2021, 5, 27, 1, 0) | Time.zone.local(2021, 5, 28, 1, 0)
- '*/5 * * * *' | '0 1 * * *' | (1.day.in_minutes / 10).to_i | true | Time.zone.local(2021, 5, 27, 1, 0) | Time.zone.local(2021, 5, 28, 1, 0)
- '*/5 * * * *' | '0 1 * * *' | (1.day.in_minutes / 8).to_i | true | Time.zone.local(2021, 5, 27, 1, 0) | Time.zone.local(2021, 5, 28, 1, 0)
- '*/5 * * * *' | '0 1 1 * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | true | Time.zone.local(2021, 5, 1, 1, 0) | Time.zone.local(2021, 6, 1, 1, 0)
- '*/9 * * * *' | '0 1 1 * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | true | Time.zone.local(2021, 5, 1, 1, 9) | Time.zone.local(2021, 6, 1, 1, 0)
- '*/9 * * * *' | '0 1 1 * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | false | Time.zone.local(2021, 5, 1, 1, 9) | Time.zone.local(2021, 6, 1, 1, 9)
- '*/5 * * * *' | '59 14 * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | true | Time.zone.local(2021, 5, 1, 15, 0) | Time.zone.local(2021, 5, 2, 15, 0)
- '*/5 * * * *' | '59 14 * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | false | Time.zone.local(2021, 5, 1, 15, 0) | Time.zone.local(2021, 5, 2, 15, 0)
- '*/5 * * * *' | '45 21 1 2 *' | (1.day.in_minutes / 5).to_i | true | Time.zone.local(2021, 2, 1, 21, 45) | Time.zone.local(2022, 2, 1, 21, 45)
- '*/5 * * * *' | '45 21 1 2 *' | (1.day.in_minutes / 5).to_i | false | Time.zone.local(2021, 2, 1, 21, 45) | Time.zone.local(2022, 2, 1, 21, 50)
+ where(:worker_cron, :schedule_cron, :plan_limit, :now, :result) do
+ '0 1 2 3 *' | '0 1 * * *' | nil | Time.zone.local(2021, 3, 2, 1, 0) | Time.zone.local(2022, 3, 2, 1, 0)
+ '0 1 2 3 *' | '0 1 * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | Time.zone.local(2021, 3, 2, 1, 0) | Time.zone.local(2022, 3, 2, 1, 0)
+ '*/5 * * * *' | '*/1 * * * *' | nil | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 11, 5)
+ '*/5 * * * *' | '*/1 * * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 0)
+ '*/5 * * * *' | '*/1 * * * *' | (1.day.in_minutes / 10).to_i | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 11, 10)
+ '*/5 * * * *' | '*/1 * * * *' | 200 | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 11, 10)
+ '*/5 * * * *' | '0 * * * *' | nil | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 5)
+ '*/5 * * * *' | '0 * * * *' | (1.day.in_minutes / 10).to_i | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 0)
+ '*/5 * * * *' | '0 * * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 0)
+ '*/5 * * * *' | '0 * * * *' | (1.day.in_minutes / 2.hours.in_minutes).to_i | Time.zone.local(2021, 5, 27, 11, 0) | Time.zone.local(2021, 5, 27, 12, 5)
+ '*/5 * * * *' | '0 1 * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | Time.zone.local(2021, 5, 27, 1, 0) | Time.zone.local(2021, 5, 28, 1, 0)
+ '*/5 * * * *' | '0 1 * * *' | (1.day.in_minutes / 10).to_i | Time.zone.local(2021, 5, 27, 1, 0) | Time.zone.local(2021, 5, 28, 1, 0)
+ '*/5 * * * *' | '0 1 * * *' | (1.day.in_minutes / 8).to_i | Time.zone.local(2021, 5, 27, 1, 0) | Time.zone.local(2021, 5, 28, 1, 0)
+ '*/5 * * * *' | '0 1 1 * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | Time.zone.local(2021, 5, 1, 1, 0) | Time.zone.local(2021, 6, 1, 1, 0)
+ '*/9 * * * *' | '0 1 1 * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | Time.zone.local(2021, 5, 1, 1, 9) | Time.zone.local(2021, 6, 1, 1, 0)
+ '*/5 * * * *' | '59 14 * * *' | (1.day.in_minutes / 1.hour.in_minutes).to_i | Time.zone.local(2021, 5, 1, 15, 0) | Time.zone.local(2021, 5, 2, 15, 0)
+ '*/5 * * * *' | '45 21 1 2 *' | (1.day.in_minutes / 5).to_i | Time.zone.local(2021, 2, 1, 21, 45) | Time.zone.local(2022, 2, 1, 21, 45)
end
with_them do
@@ -143,7 +136,6 @@ RSpec.describe Ci::PipelineSchedule do
end
create(:plan_limits, :default_plan, ci_daily_pipeline_schedule_triggers: plan_limit) if plan_limit
- stub_feature_flags(ci_daily_limit_for_pipeline_schedules: false) unless ff_enabled
# Setting this here to override initial save with the current time
pipeline_schedule.next_run_at = now
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index da89eccc3b2..1007d64438f 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -183,6 +183,28 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
end
+ describe '.where_not_sha' do
+ let_it_be(:pipeline) { create(:ci_pipeline, sha: 'abcx') }
+ let_it_be(:pipeline_2) { create(:ci_pipeline, sha: 'abc') }
+
+ let(:sha) { 'abc' }
+
+ subject { described_class.where_not_sha(sha) }
+
+ it 'returns the pipeline without the specified sha' do
+ is_expected.to contain_exactly(pipeline)
+ end
+
+ context 'when argument is array' do
+ let(:sha) { %w[abc abcx] }
+
+ it 'returns the pipelines without the specified shas' do
+ pipeline_3 = create(:ci_pipeline, sha: 'abcy')
+ is_expected.to contain_exactly(pipeline_3)
+ end
+ end
+ end
+
describe '.for_source_sha' do
subject { described_class.for_source_sha(source_sha) }
@@ -2015,16 +2037,6 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
it 'returns external pull request modified paths' do
expect(pipeline.modified_paths).to match(external_pull_request.modified_paths)
end
-
- context 'when the FF ci_modified_paths_of_external_prs is disabled' do
- before do
- stub_feature_flags(ci_modified_paths_of_external_prs: false)
- end
-
- it 'returns nil' do
- expect(pipeline.modified_paths).to be_nil
- end
- end
end
end
@@ -4524,51 +4536,6 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
subject(:reset_bridge) { pipeline.reset_source_bridge!(project.owner) }
- # This whole block will be removed by https://gitlab.com/gitlab-org/gitlab/-/issues/329194
- # It contains some duplicate checks.
- context 'when the FF ci_reset_bridge_with_subsequent_jobs is disabled' do
- before do
- stub_feature_flags(ci_reset_bridge_with_subsequent_jobs: false)
- end
-
- context 'when the pipeline is a child pipeline and the bridge is depended' do
- let!(:parent_pipeline) { create(:ci_pipeline) }
- let!(:bridge) { create_bridge(parent_pipeline, pipeline, true) }
-
- it 'marks source bridge as pending' do
- reset_bridge
-
- expect(bridge.reload).to be_pending
- end
-
- context 'when the parent pipeline has subsequent jobs after the bridge' do
- let!(:after_bridge_job) { create(:ci_build, :skipped, pipeline: parent_pipeline, stage_idx: bridge.stage_idx + 1) }
-
- it 'does not touch subsequent jobs of the bridge' do
- reset_bridge
-
- expect(after_bridge_job.reload).to be_skipped
- end
- end
-
- context 'when the parent pipeline has a dependent upstream pipeline' do
- let(:upstream_pipeline) { create(:ci_pipeline, project: create(:project)) }
- let!(:upstream_bridge) { create_bridge(upstream_pipeline, parent_pipeline, true) }
-
- let(:upstream_upstream_pipeline) { create(:ci_pipeline, project: create(:project)) }
- let!(:upstream_upstream_bridge) { create_bridge(upstream_upstream_pipeline, upstream_pipeline, true) }
-
- it 'marks all source bridges as pending' do
- reset_bridge
-
- expect(bridge.reload).to be_pending
- expect(upstream_bridge.reload).to be_pending
- expect(upstream_upstream_bridge.reload).to be_pending
- end
- end
- end
- end
-
context 'when the pipeline is a child pipeline and the bridge is depended' do
let!(:parent_pipeline) { create(:ci_pipeline) }
let!(:bridge) { create_bridge(parent_pipeline, pipeline, true) }
diff --git a/spec/models/ci/pipeline_variable_spec.rb b/spec/models/ci/pipeline_variable_spec.rb
index 04fcaab4c2d..4e8d49585d0 100644
--- a/spec/models/ci/pipeline_variable_spec.rb
+++ b/spec/models/ci/pipeline_variable_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Ci::PipelineVariable do
it_behaves_like "CI variable"
- it { is_expected.to validate_uniqueness_of(:key).scoped_to(:pipeline_id) }
+ it { is_expected.to validate_presence_of(:key) }
describe '#hook_attrs' do
let(:variable) { create(:ci_pipeline_variable, key: 'foo', value: 'bar') }
diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb
index ffc8ab4cf8b..31e854c852e 100644
--- a/spec/models/ci/runner_spec.rb
+++ b/spec/models/ci/runner_spec.rb
@@ -531,6 +531,10 @@ RSpec.describe Ci::Runner do
it 'can handle builds' do
expect(runner.can_pick?(build)).to be_truthy
end
+
+ it 'knows namespace id it is assigned to' do
+ expect(runner.namespace_ids).to eq [group.id]
+ end
end
end
diff --git a/spec/models/clusters/agent_spec.rb b/spec/models/clusters/agent_spec.rb
index ea7a55480a8..f9df84e8ff4 100644
--- a/spec/models/clusters/agent_spec.rb
+++ b/spec/models/clusters/agent_spec.rb
@@ -9,6 +9,10 @@ RSpec.describe Clusters::Agent do
it { is_expected.to belong_to(:project).class_name('::Project') }
it { is_expected.to have_many(:agent_tokens).class_name('Clusters::AgentToken') }
it { is_expected.to have_many(:last_used_agent_tokens).class_name('Clusters::AgentToken') }
+ it { is_expected.to have_many(:group_authorizations).class_name('Clusters::Agents::GroupAuthorization') }
+ it { is_expected.to have_many(:authorized_groups).through(:group_authorizations) }
+ it { is_expected.to have_many(:project_authorizations).class_name('Clusters::Agents::ProjectAuthorization') }
+ it { is_expected.to have_many(:authorized_projects).through(:project_authorizations).class_name('::Project') }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_length_of(:name).is_at_most(63) }
diff --git a/spec/models/clusters/agents/group_authorization_spec.rb b/spec/models/clusters/agents/group_authorization_spec.rb
new file mode 100644
index 00000000000..2a99fb26e3f
--- /dev/null
+++ b/spec/models/clusters/agents/group_authorization_spec.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::GroupAuthorization do
+ it { is_expected.to belong_to(:agent).class_name('Clusters::Agent').required }
+ it { is_expected.to belong_to(:group).class_name('::Group').required }
+
+ it { expect(described_class).to validate_jsonb_schema(['config']) }
+end
diff --git a/spec/models/clusters/agents/implicit_authorization_spec.rb b/spec/models/clusters/agents/implicit_authorization_spec.rb
new file mode 100644
index 00000000000..69aa55a350e
--- /dev/null
+++ b/spec/models/clusters/agents/implicit_authorization_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::ImplicitAuthorization do
+ let_it_be(:agent) { create(:cluster_agent) }
+
+ subject { described_class.new(agent: agent) }
+
+ it { expect(subject.agent).to eq(agent) }
+ it { expect(subject.agent_id).to eq(agent.id) }
+ it { expect(subject.project).to eq(agent.project) }
+ it { expect(subject.config).to be_nil }
+end
diff --git a/spec/models/clusters/agents/project_authorization_spec.rb b/spec/models/clusters/agents/project_authorization_spec.rb
new file mode 100644
index 00000000000..134c70739ac
--- /dev/null
+++ b/spec/models/clusters/agents/project_authorization_spec.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::ProjectAuthorization do
+ it { is_expected.to belong_to(:agent).class_name('Clusters::Agent').required }
+ it { is_expected.to belong_to(:project).class_name('Project').required }
+
+ it { expect(described_class).to validate_jsonb_schema(['config']) }
+end
diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb
index 278e200b05c..9d305e31bad 100644
--- a/spec/models/clusters/cluster_spec.rb
+++ b/spec/models/clusters/cluster_spec.rb
@@ -268,6 +268,16 @@ RSpec.describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
it { is_expected.to contain_exactly(cluster) }
end
+ describe '.with_name' do
+ subject { described_class.with_name(name) }
+
+ let(:name) { 'this-cluster' }
+ let!(:cluster) { create(:cluster, :project, name: name) }
+ let!(:another_cluster) { create(:cluster, :project) }
+
+ it { is_expected.to contain_exactly(cluster) }
+ end
+
describe 'validations' do
subject { cluster.valid? }
@@ -902,8 +912,8 @@ RSpec.describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
subject { cluster.kubernetes_namespace_for(environment, deployable: build) }
let(:environment_name) { 'the-environment-name' }
- let(:environment) { create(:environment, name: environment_name, project: cluster.project, last_deployable: build) }
- let(:build) { create(:ci_build, environment: environment_name, project: cluster.project) }
+ let(:environment) { create(:environment, name: environment_name, project: cluster.project) }
+ let(:build) { create(:ci_build, environment: environment, project: cluster.project) }
let(:cluster) { create(:cluster, :project, managed: managed_cluster) }
let(:managed_cluster) { true }
let(:default_namespace) { Gitlab::Kubernetes::DefaultNamespace.new(cluster, project: cluster.project).from_environment_slug(environment.slug) }
diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb
index a951af4cc4f..7134a387e65 100644
--- a/spec/models/commit_status_spec.rb
+++ b/spec/models/commit_status_spec.rb
@@ -88,6 +88,15 @@ RSpec.describe CommitStatus do
end
end
+ describe '.created_at_before' do
+ it 'finds the relevant records' do
+ status = create(:commit_status, created_at: 1.day.ago, project: project)
+ create(:commit_status, created_at: 1.day.since, project: project)
+
+ expect(described_class.created_at_before(Time.current)).to eq([status])
+ end
+ end
+
describe '.updated_before' do
let!(:lookback) { 5.days.ago }
let!(:timeout) { 1.day.ago }
diff --git a/spec/models/concerns/approvable_base_spec.rb b/spec/models/concerns/approvable_base_spec.rb
index c7ea2631a24..79053e98db7 100644
--- a/spec/models/concerns/approvable_base_spec.rb
+++ b/spec/models/concerns/approvable_base_spec.rb
@@ -60,6 +60,34 @@ RSpec.describe ApprovableBase do
end
end
+ describe '#can_be_unapproved_by?' do
+ subject { merge_request.can_be_unapproved_by?(user) }
+
+ before do
+ merge_request.project.add_developer(user)
+ end
+
+ it 'returns false' do
+ is_expected.to be_falsy
+ end
+
+ context 'when a user has approved' do
+ let!(:approval) { create(:approval, merge_request: merge_request, user: user) }
+
+ it 'returns true' do
+ is_expected.to be_truthy
+ end
+ end
+
+ context 'when a user is nil' do
+ let(:user) { nil }
+
+ it 'returns false' do
+ is_expected.to be_falsy
+ end
+ end
+ end
+
describe '.not_approved_by_users_with_usernames' do
subject { MergeRequest.not_approved_by_users_with_usernames([user.username, user2.username]) }
diff --git a/spec/models/concerns/calloutable_spec.rb b/spec/models/concerns/calloutable_spec.rb
new file mode 100644
index 00000000000..d847413de88
--- /dev/null
+++ b/spec/models/concerns/calloutable_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Calloutable do
+ subject { build(:user_callout) }
+
+ describe "Associations" do
+ it { is_expected.to belong_to(:user) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:user) }
+ end
+
+ describe '#dismissed_after?' do
+ let(:some_feature_name) { UserCallout.feature_names.keys.second }
+ let(:callout_dismissed_month_ago) { create(:user_callout, feature_name: some_feature_name, dismissed_at: 1.month.ago )}
+ let(:callout_dismissed_day_ago) { create(:user_callout, feature_name: some_feature_name, dismissed_at: 1.day.ago )}
+
+ it 'returns whether a callout dismissed after specified date' do
+ expect(callout_dismissed_month_ago.dismissed_after?(15.days.ago)).to eq(false)
+ expect(callout_dismissed_day_ago.dismissed_after?(15.days.ago)).to eq(true)
+ end
+ end
+end
diff --git a/spec/models/concerns/featurable_spec.rb b/spec/models/concerns/featurable_spec.rb
index 295f3523dd5..453b6f7f29a 100644
--- a/spec/models/concerns/featurable_spec.rb
+++ b/spec/models/concerns/featurable_spec.rb
@@ -30,8 +30,11 @@ RSpec.describe Featurable do
describe '.set_available_features' do
let!(:klass) do
- Class.new do
+ Class.new(ApplicationRecord) do
include Featurable
+
+ self.table_name = 'project_features'
+
set_available_features %i(feature1 feature2)
def feature1_access_level
diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb
index 071e0dcba44..2a3f639a8ac 100644
--- a/spec/models/concerns/issuable_spec.rb
+++ b/spec/models/concerns/issuable_spec.rb
@@ -368,6 +368,23 @@ RSpec.describe Issuable do
expect(sorted_issue_ids).to eq(sorted_issue_ids.uniq)
end
end
+
+ context 'by title' do
+ let!(:issue1) { create(:issue, project: project, title: 'foo') }
+ let!(:issue2) { create(:issue, project: project, title: 'bar') }
+ let!(:issue3) { create(:issue, project: project, title: 'baz') }
+ let!(:issue4) { create(:issue, project: project, title: 'Baz 2') }
+
+ it 'sorts asc' do
+ issues = project.issues.sort_by_attribute('title_asc')
+ expect(issues).to eq([issue2, issue3, issue4, issue1])
+ end
+
+ it 'sorts desc' do
+ issues = project.issues.sort_by_attribute('title_desc')
+ expect(issues).to eq([issue1, issue4, issue3, issue2])
+ end
+ end
end
describe '#subscribed?' do
diff --git a/spec/models/concerns/loose_foreign_key_spec.rb b/spec/models/concerns/loose_foreign_key_spec.rb
new file mode 100644
index 00000000000..ce5e33261a9
--- /dev/null
+++ b/spec/models/concerns/loose_foreign_key_spec.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe LooseForeignKey do
+ let(:project_klass) do
+ Class.new(ApplicationRecord) do
+ include LooseForeignKey
+
+ self.table_name = 'projects'
+
+ loose_foreign_key :issues, :project_id, on_delete: :async_delete, gitlab_schema: :gitlab_main
+ loose_foreign_key 'merge_requests', 'project_id', 'on_delete' => 'async_nullify', 'gitlab_schema' => :gitlab_main
+ end
+ end
+
+ it 'exposes the loose foreign key definitions' do
+ definitions = project_klass.loose_foreign_key_definitions
+
+ tables = definitions.map(&:to_table)
+ expect(tables).to eq(%w[issues merge_requests])
+ end
+
+ it 'casts strings to symbol' do
+ definition = project_klass.loose_foreign_key_definitions.last
+
+ expect(definition.from_table).to eq('projects')
+ expect(definition.to_table).to eq('merge_requests')
+ expect(definition.column).to eq('project_id')
+ expect(definition.on_delete).to eq(:async_nullify)
+ expect(definition.options[:gitlab_schema]).to eq(:gitlab_main)
+ end
+
+ context 'validation' do
+ context 'on_delete validation' do
+ let(:invalid_class) do
+ Class.new(ApplicationRecord) do
+ include LooseForeignKey
+
+ self.table_name = 'projects'
+
+ loose_foreign_key :issues, :project_id, on_delete: :async_delete, gitlab_schema: :gitlab_main
+ loose_foreign_key :merge_requests, :project_id, on_delete: :async_nullify, gitlab_schema: :gitlab_main
+ loose_foreign_key :merge_requests, :project_id, on_delete: :destroy, gitlab_schema: :gitlab_main
+ end
+ end
+
+ it 'raises error when invalid `on_delete` option was given' do
+ expect { invalid_class }.to raise_error /Invalid on_delete option given: destroy/
+ end
+ end
+
+ context 'gitlab_schema validation' do
+ let(:invalid_class) do
+ Class.new(ApplicationRecord) do
+ include LooseForeignKey
+
+ self.table_name = 'projects'
+
+ loose_foreign_key :merge_requests, :project_id, on_delete: :async_nullify, gitlab_schema: :unknown
+ end
+ end
+
+ it 'raises error when invalid `gitlab_schema` option was given' do
+ expect { invalid_class }.to raise_error /Invalid gitlab_schema option given: unknown/
+ end
+ end
+
+ context 'inheritance validation' do
+ let(:inherited_project_class) do
+ Class.new(Project) do
+ include LooseForeignKey
+
+ loose_foreign_key :issues, :project_id, on_delete: :async_delete, gitlab_schema: :gitlab_main
+ end
+ end
+
+ it 'raises error when loose_foreign_key is defined in a child ActiveRecord model' do
+ expect { inherited_project_class }.to raise_error /Please define the loose_foreign_key on the Project class/
+ end
+ end
+ end
+end
diff --git a/spec/models/concerns/partitioned_table_spec.rb b/spec/models/concerns/partitioned_table_spec.rb
index c37fb81a1cf..714db4e21bd 100644
--- a/spec/models/concerns/partitioned_table_spec.rb
+++ b/spec/models/concerns/partitioned_table_spec.rb
@@ -35,11 +35,5 @@ RSpec.describe PartitionedTable do
expect(my_class.partitioning_strategy.partitioning_key).to eq(key)
end
-
- it 'registers itself with the PartitionCreator' do
- expect(Gitlab::Database::Partitioning::PartitionManager).to receive(:register).with(my_class)
-
- subject
- end
end
end
diff --git a/spec/models/concerns/sanitizable_spec.rb b/spec/models/concerns/sanitizable_spec.rb
new file mode 100644
index 00000000000..4a1d463d666
--- /dev/null
+++ b/spec/models/concerns/sanitizable_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Sanitizable do
+ let_it_be(:klass) do
+ Class.new do
+ include ActiveModel::Model
+ include ActiveModel::Attributes
+ include ActiveModel::Validations
+ include ActiveModel::Validations::Callbacks
+ include Sanitizable
+
+ attribute :id, :integer
+ attribute :name, :string
+ attribute :description, :string
+ attribute :html_body, :string
+
+ sanitizes! :name, :description
+
+ def self.model_name
+ ActiveModel::Name.new(self, nil, 'SomeModel')
+ end
+ end
+ end
+
+ shared_examples 'noop' do
+ it 'has no effect' do
+ expect(subject).to eq(input)
+ end
+ end
+
+ shared_examples 'a sanitizable field' do |field|
+ let(:record) { klass.new(id: 1, name: input, description: input, html_body: input) }
+
+ before do
+ record.valid?
+ end
+
+ subject { record.public_send(field) }
+
+ describe field do
+ context 'when input is nil' do
+ let_it_be(:input) { nil }
+
+ it_behaves_like 'noop'
+ end
+
+ context 'when input does not contain any html' do
+ let_it_be(:input) { 'hello, world!' }
+
+ it_behaves_like 'noop'
+ end
+
+ context 'when input contains html' do
+ let_it_be(:input) { 'hello<script>alert(1)</script>' }
+
+ it 'sanitizes the input' do
+ expect(subject).to eq('hello')
+ end
+
+ context 'when input includes html entities' do
+ let(:input) { '<div>hello&world</div>' }
+
+ it 'does not escape them' do
+ expect(subject).to eq(' hello&world ')
+ end
+ end
+ end
+
+ context 'when input contains pre-escaped html entities' do
+ let_it_be(:input) { '&lt;script&gt;alert(1)&lt;/script&gt;' }
+
+ it_behaves_like 'noop'
+
+ it 'is not valid', :aggregate_failures do
+ expect(record).not_to be_valid
+ expect(record.errors.full_messages).to include('Name cannot contain escaped HTML entities')
+ end
+ end
+ end
+ end
+
+ shared_examples 'a non-sanitizable field' do |field, input|
+ describe field do
+ subject { klass.new(field => input).valid? }
+
+ it 'has no effect' do
+ expect(Sanitize).not_to receive(:fragment)
+
+ subject
+ end
+ end
+ end
+
+ it_behaves_like 'a non-sanitizable field', :id, 1
+ it_behaves_like 'a non-sanitizable field', :html_body, 'hello<script>alert(1)</script>'
+
+ it_behaves_like 'a sanitizable field', :name
+ it_behaves_like 'a sanitizable field', :description
+end
diff --git a/spec/models/concerns/taggable_queries_spec.rb b/spec/models/concerns/taggable_queries_spec.rb
new file mode 100644
index 00000000000..0d248c4636e
--- /dev/null
+++ b/spec/models/concerns/taggable_queries_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe TaggableQueries do
+ it 'keeps MAX_TAGS_IDS in sync with TAGS_LIMIT' do
+ expect(described_class::MAX_TAGS_IDS).to eq(Gitlab::Ci::Config::Entry::Tags::TAGS_LIMIT)
+ end
+end
diff --git a/spec/models/customer_relations/contact_spec.rb b/spec/models/customer_relations/contact_spec.rb
new file mode 100644
index 00000000000..b19554dd67e
--- /dev/null
+++ b/spec/models/customer_relations/contact_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe CustomerRelations::Contact, type: :model do
+ describe 'associations' do
+ it { is_expected.to belong_to(:group) }
+ it { is_expected.to belong_to(:organization).optional }
+ end
+
+ describe 'validations' do
+ subject { build(:contact) }
+
+ it { is_expected.to validate_presence_of(:group) }
+ it { is_expected.to validate_presence_of(:first_name) }
+ it { is_expected.to validate_presence_of(:last_name) }
+
+ it { is_expected.to validate_length_of(:phone).is_at_most(32) }
+ it { is_expected.to validate_length_of(:first_name).is_at_most(255) }
+ it { is_expected.to validate_length_of(:last_name).is_at_most(255) }
+ it { is_expected.to validate_length_of(:email).is_at_most(255) }
+ it { is_expected.to validate_length_of(:description).is_at_most(1024) }
+
+ it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :email
+ end
+
+ describe '#before_validation' do
+ it 'strips leading and trailing whitespace' do
+ contact = described_class.new(first_name: ' First ', last_name: ' Last ', phone: ' 123456 ')
+ contact.valid?
+
+ expect(contact.first_name).to eq('First')
+ expect(contact.last_name).to eq('Last')
+ expect(contact.phone).to eq('123456')
+ end
+ end
+end
diff --git a/spec/models/customer_relations/organization_spec.rb b/spec/models/customer_relations/organization_spec.rb
index b79b5748156..71b455ae8c8 100644
--- a/spec/models/customer_relations/organization_spec.rb
+++ b/spec/models/customer_relations/organization_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe CustomerRelations::Organization, type: :model do
end
describe 'validations' do
- subject { create(:organization) }
+ subject { build(:organization) }
it { is_expected.to validate_presence_of(:group) }
it { is_expected.to validate_presence_of(:name) }
diff --git a/spec/models/dependency_proxy/blob_spec.rb b/spec/models/dependency_proxy/blob_spec.rb
index 7c8a1eb95e8..3797f6184fe 100644
--- a/spec/models/dependency_proxy/blob_spec.rb
+++ b/spec/models/dependency_proxy/blob_spec.rb
@@ -6,10 +6,13 @@ RSpec.describe DependencyProxy::Blob, type: :model do
it { is_expected.to belong_to(:group) }
end
+ it_behaves_like 'having unique enum values'
+
describe 'validations' do
it { is_expected.to validate_presence_of(:group) }
it { is_expected.to validate_presence_of(:file) }
it { is_expected.to validate_presence_of(:file_name) }
+ it { is_expected.to validate_presence_of(:status) }
end
describe '.total_size' do
diff --git a/spec/models/dependency_proxy/image_ttl_group_policy_spec.rb b/spec/models/dependency_proxy/image_ttl_group_policy_spec.rb
new file mode 100644
index 00000000000..2906ea7b774
--- /dev/null
+++ b/spec/models/dependency_proxy/image_ttl_group_policy_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe DependencyProxy::ImageTtlGroupPolicy, type: :model do
+ describe 'relationships' do
+ it { is_expected.to belong_to(:group) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:group) }
+
+ describe '#enabled' do
+ it { is_expected.to allow_value(true).for(:enabled) }
+ it { is_expected.to allow_value(false).for(:enabled) }
+ it { is_expected.not_to allow_value(nil).for(:enabled) }
+ end
+
+ describe '#ttl' do
+ it { is_expected.to validate_numericality_of(:ttl).allow_nil.is_greater_than(0) }
+ end
+ end
+end
diff --git a/spec/models/dependency_proxy/manifest_spec.rb b/spec/models/dependency_proxy/manifest_spec.rb
index 4203644c003..2a085b3613b 100644
--- a/spec/models/dependency_proxy/manifest_spec.rb
+++ b/spec/models/dependency_proxy/manifest_spec.rb
@@ -6,11 +6,14 @@ RSpec.describe DependencyProxy::Manifest, type: :model do
it { is_expected.to belong_to(:group) }
end
+ it_behaves_like 'having unique enum values'
+
describe 'validations' do
it { is_expected.to validate_presence_of(:group) }
it { is_expected.to validate_presence_of(:file) }
it { is_expected.to validate_presence_of(:file_name) }
it { is_expected.to validate_presence_of(:digest) }
+ it { is_expected.to validate_presence_of(:status) }
end
describe 'file is being stored' do
diff --git a/spec/models/design_management/action_spec.rb b/spec/models/design_management/action_spec.rb
index 59c58191718..0a8bbc8d26e 100644
--- a/spec/models/design_management/action_spec.rb
+++ b/spec/models/design_management/action_spec.rb
@@ -8,37 +8,55 @@ RSpec.describe DesignManagement::Action do
end
describe 'scopes' do
- describe '.most_recent' do
- let_it_be(:design_a) { create(:design) }
- let_it_be(:design_b) { create(:design) }
- let_it_be(:design_c) { create(:design) }
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:design_a) { create(:design, issue: issue) }
+ let_it_be(:design_b) { create(:design, issue: issue) }
- let(:designs) { [design_a, design_b, design_c] }
+ context 'with 3 designs' do
+ let_it_be(:design_c) { create(:design, issue: issue) }
- before_all do
- create(:design_version, designs: [design_a, design_b, design_c])
- create(:design_version, designs: [design_a, design_b])
- create(:design_version, designs: [design_a])
- end
+ let_it_be(:action_a_1) { create(:design_action, design: design_a) }
+ let_it_be(:action_a_2) { create(:design_action, design: design_a, event: :deletion) }
+ let_it_be(:action_b) { create(:design_action, design: design_b) }
+ let_it_be(:action_c) { create(:design_action, design: design_c, event: :deletion) }
+
+ describe '.most_recent' do
+ let(:designs) { [design_a, design_b, design_c] }
+
+ before_all do
+ create(:design_version, designs: [design_a, design_b, design_c])
+ create(:design_version, designs: [design_a, design_b])
+ create(:design_version, designs: [design_a])
+ end
+
+ it 'finds the correct version for each design' do
+ dvs = described_class.where(design: designs)
+
+ expected = designs
+ .map(&:id)
+ .zip(dvs.order("version_id DESC").pluck(:version_id).uniq)
- it 'finds the correct version for each design' do
- dvs = described_class.where(design: designs)
+ actual = dvs.most_recent.map { |dv| [dv.design_id, dv.version_id] }
- expected = designs
- .map(&:id)
- .zip(dvs.order("version_id DESC").pluck(:version_id).uniq)
+ expect(actual).to eq(expected)
+ end
+ end
- actual = dvs.most_recent.map { |dv| [dv.design_id, dv.version_id] }
+ describe '.by_design' do
+ it 'returns the actions by design_id' do
+ expect(described_class.by_design([design_a.id, design_b.id]))
+ .to match_array([action_a_1, action_a_2, action_b])
+ end
+ end
- expect(actual).to eq(expected)
+ describe '.by_event' do
+ it 'returns the actions by event type' do
+ expect(described_class.by_event(:deletion)).to match_array([action_a_2, action_c])
+ end
end
end
describe '.up_to_version' do
- let_it_be(:issue) { create(:issue) }
- let_it_be(:design_a) { create(:design, issue: issue) }
- let_it_be(:design_b) { create(:design, issue: issue) }
-
# let bindings are not available in before(:all) contexts,
# so we need to redefine the array on each construction.
let_it_be(:oldest) { create(:design_version, designs: [design_a, design_b]) }
diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb
index 11652d9841b..f377b34679c 100644
--- a/spec/models/diff_note_spec.rb
+++ b/spec/models/diff_note_spec.rb
@@ -558,4 +558,31 @@ RSpec.describe DiffNote do
it { is_expected.to eq('note') }
end
+
+ describe '#shas' do
+ it 'returns list of SHAs based on original_position' do
+ expect(subject.shas).to match_array([
+ position.base_sha,
+ position.start_sha,
+ position.head_sha
+ ])
+ end
+
+ context 'when position changes' do
+ before do
+ subject.position = new_position
+ end
+
+ it 'includes the new position SHAs' do
+ expect(subject.shas).to match_array([
+ position.base_sha,
+ position.start_sha,
+ position.head_sha,
+ new_position.base_sha,
+ new_position.start_sha,
+ new_position.head_sha
+ ])
+ end
+ end
+ end
end
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index 53561586d61..e3e9d1f7a71 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -135,6 +135,20 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
environment.stop
end
+
+ context 'when environment has auto stop period' do
+ let!(:environment) { create(:environment, :available, :auto_stoppable, project: project) }
+
+ it 'clears auto stop period when the environment has stopped' do
+ environment.stop!
+
+ expect(environment.auto_stop_at).to be_nil
+ end
+
+ it 'does not clear auto stop period when the environment has not stopped' do
+ expect(environment.auto_stop_at).to be_present
+ end
+ end
end
describe '.for_name_like' do
@@ -233,55 +247,6 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end
end
- describe '.stop_actions' do
- subject { environments.stop_actions }
-
- let_it_be(:project) { create(:project, :repository) }
- let_it_be(:user) { create(:user) }
-
- let(:environments) { Environment.all }
-
- before_all do
- project.add_developer(user)
- project.repository.add_branch(user, 'review/feature-1', 'master')
- project.repository.add_branch(user, 'review/feature-2', 'master')
- end
-
- shared_examples_for 'correct filtering' do
- it 'returns stop actions for available environments only' do
- expect(subject.count).to eq(1)
- expect(subject.first.name).to eq('stop_review_app')
- expect(subject.first.ref).to eq('review/feature-1')
- end
- end
-
- before do
- create_review_app(user, project, 'review/feature-1')
- create_review_app(user, project, 'review/feature-2')
- end
-
- it 'returns stop actions for environments' do
- expect(subject.count).to eq(2)
- expect(subject).to match_array(Ci::Build.where(name: 'stop_review_app'))
- end
-
- context 'when one of the stop actions has already been executed' do
- before do
- Ci::Build.where(ref: 'review/feature-2').find_by_name('stop_review_app').enqueue!
- end
-
- it_behaves_like 'correct filtering'
- end
-
- context 'when one of the deployments does not have stop action' do
- before do
- Deployment.where(ref: 'review/feature-2').update_all(on_stop: nil)
- end
-
- it_behaves_like 'correct filtering'
- end
- end
-
describe '.pluck_names' do
subject { described_class.pluck_names }
@@ -726,6 +691,28 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end
end
+ describe '#last_deployable' do
+ subject { environment.last_deployable }
+
+ context 'does not join across databases' do
+ let(:pipeline_a) { create(:ci_pipeline, project: project) }
+ let(:pipeline_b) { create(:ci_pipeline, project: project) }
+ let(:ci_build_a) { create(:ci_build, project: project, pipeline: pipeline_a) }
+ let(:ci_build_b) { create(:ci_build, project: project, pipeline: pipeline_b) }
+
+ before do
+ create(:deployment, :success, project: project, environment: environment, deployable: ci_build_a)
+ create(:deployment, :failed, project: project, environment: environment, deployable: ci_build_b)
+ end
+
+ it 'when called' do
+ with_cross_joins_prevented do
+ expect(subject.id).to eq(ci_build_a.id)
+ end
+ end
+ end
+ end
+
describe '#last_visible_deployment' do
subject { environment.last_visible_deployment }
@@ -768,6 +755,86 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end
end
+ describe '#last_visible_deployable' do
+ subject { environment.last_visible_deployable }
+
+ context 'does not join across databases' do
+ let(:pipeline_a) { create(:ci_pipeline, project: project) }
+ let(:pipeline_b) { create(:ci_pipeline, project: project) }
+ let(:ci_build_a) { create(:ci_build, project: project, pipeline: pipeline_a) }
+ let(:ci_build_b) { create(:ci_build, project: project, pipeline: pipeline_b) }
+
+ before do
+ create(:deployment, :success, project: project, environment: environment, deployable: ci_build_a)
+ create(:deployment, :failed, project: project, environment: environment, deployable: ci_build_b)
+ end
+
+ it 'for direct call' do
+ with_cross_joins_prevented do
+ expect(subject.id).to eq(ci_build_b.id)
+ end
+ end
+
+ it 'for preload' do
+ environment.reload
+
+ with_cross_joins_prevented do
+ ActiveRecord::Associations::Preloader.new.preload(environment, [last_visible_deployable: []])
+ expect(subject.id).to eq(ci_build_b.id)
+ end
+ end
+ end
+
+ context 'call after preload' do
+ it 'fetches from association cache' do
+ pipeline = create(:ci_pipeline, project: project)
+ ci_build = create(:ci_build, project: project, pipeline: pipeline)
+ create(:deployment, :failed, project: project, environment: environment, deployable: ci_build)
+
+ environment.reload
+ ActiveRecord::Associations::Preloader.new.preload(environment, [last_visible_deployable: []])
+
+ query_count = ActiveRecord::QueryRecorder.new do
+ expect(subject.id).to eq(ci_build.id)
+ end.count
+
+ expect(query_count).to eq(0)
+ end
+ end
+
+ context 'when the feature for disable_join is disabled' do
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:ci_build) { create(:ci_build, project: project, pipeline: pipeline) }
+
+ before do
+ stub_feature_flags(environment_last_visible_pipeline_disable_joins: false)
+ create(:deployment, :failed, project: project, environment: environment, deployable: ci_build)
+ end
+
+ context 'for preload' do
+ it 'executes the original association instead of override' do
+ environment.reload
+ ActiveRecord::Associations::Preloader.new.preload(environment, [last_visible_deployable: []])
+
+ expect_any_instance_of(Deployment).not_to receive(:deployable)
+
+ query_count = ActiveRecord::QueryRecorder.new do
+ expect(subject.id).to eq(ci_build.id)
+ end.count
+
+ expect(query_count).to eq(0)
+ end
+ end
+
+ context 'for direct call' do
+ it 'executes the original association instead of override' do
+ expect_any_instance_of(Deployment).not_to receive(:deployable)
+ expect(subject.id).to eq(ci_build.id)
+ end
+ end
+ end
+ end
+
describe '#last_visible_pipeline' do
let(:user) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
@@ -812,6 +879,35 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
expect(last_pipeline).to eq(failed_pipeline)
end
+ context 'does not join across databases' do
+ let(:pipeline_a) { create(:ci_pipeline, project: project) }
+ let(:pipeline_b) { create(:ci_pipeline, project: project) }
+ let(:ci_build_a) { create(:ci_build, project: project, pipeline: pipeline_a) }
+ let(:ci_build_b) { create(:ci_build, project: project, pipeline: pipeline_b) }
+
+ before do
+ create(:deployment, :success, project: project, environment: environment, deployable: ci_build_a)
+ create(:deployment, :failed, project: project, environment: environment, deployable: ci_build_b)
+ end
+
+ subject { environment.last_visible_pipeline }
+
+ it 'for direct call' do
+ with_cross_joins_prevented do
+ expect(subject.id).to eq(pipeline_b.id)
+ end
+ end
+
+ it 'for preload' do
+ environment.reload
+
+ with_cross_joins_prevented do
+ ActiveRecord::Associations::Preloader.new.preload(environment, [last_visible_pipeline: []])
+ expect(subject.id).to eq(pipeline_b.id)
+ end
+ end
+ end
+
context 'for the environment' do
it 'returns the last pipeline' do
pipeline = create(:ci_pipeline, project: project, user: user, sha: commit.sha)
@@ -850,6 +946,57 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end
end
end
+
+ context 'call after preload' do
+ it 'fetches from association cache' do
+ pipeline = create(:ci_pipeline, project: project)
+ ci_build = create(:ci_build, project: project, pipeline: pipeline)
+ create(:deployment, :failed, project: project, environment: environment, deployable: ci_build)
+
+ environment.reload
+ ActiveRecord::Associations::Preloader.new.preload(environment, [last_visible_pipeline: []])
+
+ query_count = ActiveRecord::QueryRecorder.new do
+ expect(environment.last_visible_pipeline.id).to eq(pipeline.id)
+ end.count
+
+ expect(query_count).to eq(0)
+ end
+ end
+
+ context 'when the feature for disable_join is disabled' do
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:ci_build) { create(:ci_build, project: project, pipeline: pipeline) }
+
+ before do
+ stub_feature_flags(environment_last_visible_pipeline_disable_joins: false)
+ create(:deployment, :failed, project: project, environment: environment, deployable: ci_build)
+ end
+
+ subject { environment.last_visible_pipeline }
+
+ context 'for preload' do
+ it 'executes the original association instead of override' do
+ environment.reload
+ ActiveRecord::Associations::Preloader.new.preload(environment, [last_visible_pipeline: []])
+
+ expect_any_instance_of(Ci::Build).not_to receive(:pipeline)
+
+ query_count = ActiveRecord::QueryRecorder.new do
+ expect(subject.id).to eq(pipeline.id)
+ end.count
+
+ expect(query_count).to eq(0)
+ end
+ end
+
+ context 'for direct call' do
+ it 'executes the original association instead of override' do
+ expect_any_instance_of(Ci::Build).not_to receive(:pipeline)
+ expect(subject.id).to eq(pipeline.id)
+ end
+ end
+ end
end
describe '#upcoming_deployment' do
diff --git a/spec/models/error_tracking/error_spec.rb b/spec/models/error_tracking/error_spec.rb
index 57899985daf..5543392b624 100644
--- a/spec/models/error_tracking/error_spec.rb
+++ b/spec/models/error_tracking/error_spec.rb
@@ -16,6 +16,62 @@ RSpec.describe ErrorTracking::Error, type: :model do
it { is_expected.to validate_presence_of(:actor) }
end
+ describe '.report_error' do
+ it 'updates existing record with a new timestamp' do
+ timestamp = Time.zone.now
+
+ reported_error = described_class.report_error(
+ name: error.name,
+ description: 'Lorem ipsum',
+ actor: error.actor,
+ platform: error.platform,
+ timestamp: timestamp
+ )
+
+ expect(reported_error.id).to eq(error.id)
+ expect(reported_error.last_seen_at).to eq(timestamp)
+ expect(reported_error.description).to eq('Lorem ipsum')
+ end
+ end
+
+ describe '.sort_by_attribute' do
+ let!(:error2) { create(:error_tracking_error, first_seen_at: Time.zone.now - 2.weeks, last_seen_at: Time.zone.now - 1.week) }
+ let!(:error3) { create(:error_tracking_error, first_seen_at: Time.zone.now - 3.weeks, last_seen_at: Time.zone.now.yesterday) }
+ let!(:errors) { [error, error2, error3] }
+
+ subject { described_class.where(id: errors).sort_by_attribute(sort) }
+
+ context 'id desc by default' do
+ let(:sort) { nil }
+
+ it { is_expected.to eq([error3, error2, error]) }
+ end
+
+ context 'first_seen' do
+ let(:sort) { 'first_seen' }
+
+ it { is_expected.to eq([error, error2, error3]) }
+ end
+
+ context 'last_seen' do
+ let(:sort) { 'last_seen' }
+
+ it { is_expected.to eq([error, error3, error2]) }
+ end
+
+ context 'frequency' do
+ let(:sort) { 'frequency' }
+
+ before do
+ create(:error_tracking_error_event, error: error2)
+ create(:error_tracking_error_event, error: error2)
+ create(:error_tracking_error_event, error: error3)
+ end
+
+ it { is_expected.to eq([error2, error3, error]) }
+ end
+ end
+
describe '#title' do
it { expect(error.title).to eq('ActionView::MissingTemplate Missing template posts/edit') }
end
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 7be61f4950e..29255e53fcf 100644
--- a/spec/models/error_tracking/project_error_tracking_setting_spec.rb
+++ b/spec/models/error_tracking/project_error_tracking_setting_spec.rb
@@ -478,18 +478,17 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
describe '#sentry_enabled' do
using RSpec::Parameterized::TableSyntax
- where(:enabled, :integrated, :feature_flag, :sentry_enabled) do
- true | false | false | true
- true | true | false | true
- true | true | true | false
- false | false | false | false
+ where(:enabled, :integrated, :sentry_enabled) do
+ true | false | true
+ true | true | false
+ true | true | false
+ false | false | false
end
with_them do
before do
subject.enabled = enabled
subject.integrated = integrated
- stub_feature_flags(integrated_error_tracking: feature_flag)
end
it { expect(subject.sentry_enabled).to eq(sentry_enabled) }
diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb
index ddf12c8e4c4..d536a0783bc 100644
--- a/spec/models/group_spec.rb
+++ b/spec/models/group_spec.rb
@@ -30,10 +30,12 @@ RSpec.describe Group do
it { is_expected.to have_many(:group_deploy_keys) }
it { is_expected.to have_many(:integrations) }
it { is_expected.to have_one(:dependency_proxy_setting) }
+ it { is_expected.to have_one(:dependency_proxy_image_ttl_policy) }
it { is_expected.to have_many(:dependency_proxy_blobs) }
it { is_expected.to have_many(:dependency_proxy_manifests) }
it { is_expected.to have_many(:debian_distributions).class_name('Packages::Debian::GroupDistribution').dependent(:destroy) }
it { is_expected.to have_many(:daily_build_group_report_results).class_name('Ci::DailyBuildGroupReportResult') }
+ it { is_expected.to have_many(:group_callouts).class_name('Users::GroupCallout').with_foreign_key(:group_id) }
describe '#members & #requesters' do
let(:requester) { create(:user) }
@@ -80,7 +82,7 @@ RSpec.describe Group 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')
+ expect(group.errors[:parent_id].first).to eq('user namespace cannot be the parent of another namespace')
end
it 'allows a group to have another group as its parent' do
@@ -2273,19 +2275,27 @@ RSpec.describe Group do
end
describe '.groups_including_descendants_by' do
- it 'returns the expected groups for a group and its descendants' do
- parent_group1 = create(:group)
- child_group1 = create(:group, parent: parent_group1)
- child_group2 = create(:group, parent: parent_group1)
+ let_it_be(:parent_group1) { create(:group) }
+ let_it_be(:parent_group2) { create(:group) }
+ let_it_be(:extra_group) { create(:group) }
+ let_it_be(:child_group1) { create(:group, parent: parent_group1) }
+ let_it_be(:child_group2) { create(:group, parent: parent_group1) }
+ let_it_be(:child_group3) { create(:group, parent: parent_group2) }
- parent_group2 = create(:group)
- child_group3 = create(:group, parent: parent_group2)
+ subject { described_class.groups_including_descendants_by([parent_group2.id, parent_group1.id]) }
- create(:group)
+ shared_examples 'returns the expected groups for a group and its descendants' do
+ specify { is_expected.to contain_exactly(parent_group1, parent_group2, child_group1, child_group2, child_group3) }
+ end
+
+ it_behaves_like 'returns the expected groups for a group and its descendants'
- groups = described_class.groups_including_descendants_by([parent_group2.id, parent_group1.id])
+ context 'when :linear_group_including_descendants_by feature flag is disabled' do
+ before do
+ stub_feature_flags(linear_group_including_descendants_by: false)
+ end
- expect(groups).to contain_exactly(parent_group1, parent_group2, child_group1, child_group2, child_group3)
+ it_behaves_like 'returns the expected groups for a group and its descendants'
end
end
@@ -2477,6 +2487,12 @@ RSpec.describe Group do
end
end
+ describe '#membership_locked?' do
+ it 'returns false' do
+ expect(build(:group)).not_to be_membership_locked
+ end
+ end
+
describe '#default_owner' do
let(:group) { build(:group) }
@@ -2619,6 +2635,26 @@ RSpec.describe Group do
end
end
+ describe '.organizations' do
+ it 'returns organizations belonging to the group' do
+ organization1 = create(:organization, group: group)
+ create(:organization)
+ organization3 = create(:organization, group: group)
+
+ expect(group.organizations).to contain_exactly(organization1, organization3)
+ end
+ end
+
+ describe '.contacts' do
+ it 'returns contacts belonging to the group' do
+ contact1 = create(:contact, group: group)
+ create(:contact)
+ contact3 = create(:contact, group: group)
+
+ expect(group.contacts).to contain_exactly(contact1, contact3)
+ end
+ end
+
describe '#to_ability_name' do
it 'returns group' do
group = build(:group)
@@ -2696,4 +2732,40 @@ RSpec.describe Group do
group.open_merge_requests_count
end
end
+
+ describe '#dependency_proxy_image_prefix' do
+ let_it_be(:group) { build_stubbed(:group, path: 'GroupWithUPPERcaseLetters') }
+
+ it 'converts uppercase letters to lowercase' do
+ expect(group.dependency_proxy_image_prefix).to end_with("/groupwithuppercaseletters#{DependencyProxy::URL_SUFFIX}")
+ end
+
+ it 'removes the protocol' do
+ expect(group.dependency_proxy_image_prefix).not_to include('http')
+ end
+ end
+
+ describe '#dependency_proxy_image_ttl_policy' do
+ subject(:ttl_policy) { group.dependency_proxy_image_ttl_policy }
+
+ it 'builds a new policy if one does not exist', :aggregate_failures do
+ expect(ttl_policy.ttl).to eq(90)
+ expect(ttl_policy.enabled).to eq(false)
+ expect(ttl_policy.created_at).to be_nil
+ expect(ttl_policy.updated_at).to be_nil
+ end
+
+ context 'with existing policy' do
+ before do
+ group.dependency_proxy_image_ttl_policy.update!(ttl: 30, enabled: true)
+ end
+
+ it 'returns the policy if it already exists', :aggregate_failures do
+ expect(ttl_policy.ttl).to eq(30)
+ expect(ttl_policy.enabled).to eq(true)
+ expect(ttl_policy.created_at).not_to be_nil
+ expect(ttl_policy.updated_at).not_to be_nil
+ end
+ end
+ end
end
diff --git a/spec/models/hooks/web_hook_spec.rb b/spec/models/hooks/web_hook_spec.rb
index c68ad3bf0c4..59f4533a6c1 100644
--- a/spec/models/hooks/web_hook_spec.rb
+++ b/spec/models/hooks/web_hook_spec.rb
@@ -10,7 +10,11 @@ RSpec.describe WebHook do
let(:hook) { build(:project_hook, project: project) }
around do |example|
- freeze_time { example.run }
+ if example.metadata[:skip_freeze_time]
+ example.run
+ else
+ freeze_time { example.run }
+ end
end
describe 'associations' do
@@ -326,10 +330,28 @@ RSpec.describe WebHook do
expect { hook.backoff! }.to change(hook, :backoff_count).by(1)
end
- it 'does not let the backoff count exceed the maximum failure count' do
- hook.backoff_count = described_class::MAX_FAILURES
+ context 'when we have backed off MAX_FAILURES times' do
+ before do
+ stub_const("#{described_class}::MAX_FAILURES", 5)
+ 5.times { hook.backoff! }
+ end
+
+ it 'does not let the backoff count exceed the maximum failure count' do
+ expect { hook.backoff! }.not_to change(hook, :backoff_count)
+ end
+
+ it 'does not change disabled_until', :skip_freeze_time do
+ travel_to(hook.disabled_until - 1.minute) do
+ expect { hook.backoff! }.not_to change(hook, :disabled_until)
+ end
+ end
- expect { hook.backoff! }.not_to change(hook, :backoff_count)
+ it 'changes disabled_until when it has elapsed', :skip_freeze_time do
+ travel_to(hook.disabled_until + 1.minute) do
+ expect { hook.backoff! }.to change { hook.disabled_until }
+ expect(hook.backoff_count).to eq(described_class::MAX_FAILURES)
+ end
+ end
end
include_examples 'is tolerant of invalid records' do
diff --git a/spec/models/instance_configuration_spec.rb b/spec/models/instance_configuration_spec.rb
index 9544f0fe6ec..551e6e7572c 100644
--- a/spec/models/instance_configuration_spec.rb
+++ b/spec/models/instance_configuration_spec.rb
@@ -76,24 +76,46 @@ RSpec.describe InstanceConfiguration do
end
end
- describe '#gitlab_ci' do
- let(:gitlab_ci) { subject.settings[:gitlab_ci] }
+ describe '#size_limits' do
+ before do
+ Gitlab::CurrentSettings.current_application_settings.update!(
+ max_attachment_size: 10,
+ receive_max_input_size: 20,
+ max_import_size: 30,
+ diff_max_patch_bytes: 409600,
+ max_artifacts_size: 50,
+ max_pages_size: 60,
+ snippet_size_limit: 70
+ )
+ end
- it 'returns Settings.gitalb_ci' do
- gitlab_ci.delete(:artifacts_max_size)
+ it 'returns size limits from application settings' do
+ size_limits = subject.settings[:size_limits]
- expect(gitlab_ci).to eq(Settings.gitlab_ci.symbolize_keys)
+ expect(size_limits[:max_attachment_size]).to eq(10.megabytes)
+ expect(size_limits[:receive_max_input_size]).to eq(20.megabytes)
+ expect(size_limits[:max_import_size]).to eq(30.megabytes)
+ expect(size_limits[:diff_max_patch_bytes]).to eq(400.kilobytes)
+ expect(size_limits[:max_artifacts_size]).to eq(50.megabytes)
+ expect(size_limits[:max_pages_size]).to eq(60.megabytes)
+ expect(size_limits[:snippet_size_limit]).to eq(70.bytes)
end
- it 'returns the key artifacts_max_size' do
- expect(gitlab_ci.keys).to include(:artifacts_max_size)
+ it 'returns nil if receive_max_input_size not set' do
+ Gitlab::CurrentSettings.current_application_settings.update!(receive_max_input_size: nil)
+
+ size_limits = subject.settings[:size_limits]
+
+ expect(size_limits[:receive_max_input_size]).to be_nil
end
- it 'returns the key artifacts_max_size with values' do
- stub_application_setting(max_artifacts_size: 200)
+ it 'returns nil if set to 0 (unlimited)' do
+ Gitlab::CurrentSettings.current_application_settings.update!(max_import_size: 0, max_pages_size: 0)
+
+ size_limits = subject.settings[:size_limits]
- expect(gitlab_ci[:artifacts_max_size][:default]).to eq(100.megabytes)
- expect(gitlab_ci[:artifacts_max_size][:value]).to eq(200.megabytes)
+ expect(size_limits[:max_import_size]).to be_nil
+ expect(size_limits[:max_pages_size]).to be_nil
end
end
diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb
index f5f6a425fdd..8a06f7fac99 100644
--- a/spec/models/integration_spec.rb
+++ b/spec/models/integration_spec.rb
@@ -825,4 +825,20 @@ RSpec.describe Integration do
.to include(*described_class::PROJECT_SPECIFIC_INTEGRATION_NAMES)
end
end
+
+ describe '#password_fields' do
+ it 'returns all fields with type `password`' do
+ allow(subject).to receive(:fields).and_return([
+ { name: 'password', type: 'password' },
+ { name: 'secret', type: 'password' },
+ { name: 'public', type: 'text' }
+ ])
+
+ expect(subject.password_fields).to match_array(%w[password secret])
+ end
+
+ it 'returns an empty array if no password fields exist' do
+ expect(subject.password_fields).to eq([])
+ end
+ end
end
diff --git a/spec/models/integrations/datadog_spec.rb b/spec/models/integrations/datadog_spec.rb
index 7049e64c2ce..9c3ff7aa35b 100644
--- a/spec/models/integrations/datadog_spec.rb
+++ b/spec/models/integrations/datadog_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe Integrations::Datadog do
let(:active) { true }
let(:dd_site) { 'datadoghq.com' }
- let(:default_url) { 'https://webhooks-http-intake.logs.datadoghq.com/api/v2/webhook' }
+ let(:default_url) { 'https://webhook-intake.datadoghq.com/api/v2/webhook' }
let(:api_url) { '' }
let(:api_key) { SecureRandom.hex(32) }
let(:dd_env) { 'ci' }
@@ -66,7 +66,7 @@ RSpec.describe Integrations::Datadog do
context 'with custom api_url' do
let(:dd_site) { '' }
- let(:api_url) { 'https://webhooks-http-intake.logs.datad0g.com/api/v2/webhook' }
+ let(:api_url) { 'https://webhook-intake.datad0g.com/api/v2/webhook' }
it { is_expected.not_to validate_presence_of(:datadog_site) }
it { is_expected.to validate_presence_of(:api_url) }
@@ -108,7 +108,7 @@ RSpec.describe Integrations::Datadog do
end
context 'with custom URL' do
- let(:api_url) { 'https://webhooks-http-intake.logs.datad0g.com/api/v2/webhook' }
+ let(:api_url) { 'https://webhook-intake.datad0g.com/api/v2/webhook' }
it { is_expected.to eq(api_url + "?dd-api-key=#{api_key}&env=#{dd_env}&service=#{dd_service}") }
diff --git a/spec/models/integrations/pipelines_email_spec.rb b/spec/models/integrations/pipelines_email_spec.rb
index 761049f25fe..afd9d71ebc4 100644
--- a/spec/models/integrations/pipelines_email_spec.rb
+++ b/spec/models/integrations/pipelines_email_spec.rb
@@ -48,7 +48,7 @@ RSpec.describe Integrations::PipelinesEmail, :mailer do
end
it 'sends email' do
- emails = receivers.map { |r| double(notification_email: r) }
+ emails = receivers.map { |r| double(notification_email_or_default: r) }
should_only_email(*emails, kind: :bcc)
end
diff --git a/spec/models/integrations/prometheus_spec.rb b/spec/models/integrations/prometheus_spec.rb
index f6f242bf58e..76e20f20a00 100644
--- a/spec/models/integrations/prometheus_spec.rb
+++ b/spec/models/integrations/prometheus_spec.rb
@@ -516,7 +516,7 @@ RSpec.describe Integrations::Prometheus, :use_clean_rails_memory_store_caching,
name: 'google_iap_audience_client_id',
title: 'Google IAP Audience Client ID',
placeholder: s_('PrometheusService|IAP_CLIENT_ID.apps.googleusercontent.com'),
- help: s_('PrometheusService|PrometheusService|The ID of the IAP-secured resource.'),
+ help: s_('PrometheusService|The ID of the IAP-secured resource.'),
autocomplete: 'off',
required: false
},
diff --git a/spec/models/integrations/zentao_spec.rb b/spec/models/integrations/zentao_spec.rb
new file mode 100644
index 00000000000..a1503ecc092
--- /dev/null
+++ b/spec/models/integrations/zentao_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::Zentao do
+ let(:url) { 'https://jihudemo.zentao.net' }
+ let(:api_url) { 'https://jihudemo.zentao.net' }
+ let(:api_token) { 'ZENTAO_TOKEN' }
+ let(:zentao_product_xid) { '3' }
+ let(:zentao_integration) { create(:zentao_integration) }
+
+ describe '#create' do
+ let(:project) { create(:project, :repository) }
+ let(:params) do
+ {
+ project: project,
+ url: url,
+ api_url: api_url,
+ api_token: api_token,
+ zentao_product_xid: zentao_product_xid
+ }
+ end
+
+ it 'stores data in data_fields correctly' do
+ tracker_data = described_class.create!(params).zentao_tracker_data
+
+ expect(tracker_data.url).to eq(url)
+ expect(tracker_data.api_url).to eq(api_url)
+ expect(tracker_data.api_token).to eq(api_token)
+ expect(tracker_data.zentao_product_xid).to eq(zentao_product_xid)
+ end
+ end
+
+ describe '#fields' do
+ it 'returns custom fields' do
+ expect(zentao_integration.fields.pluck(:name)).to eq(%w[url api_url api_token zentao_product_xid])
+ end
+ end
+
+ describe '#test' do
+ let(:test_response) { { success: true } }
+
+ before do
+ allow_next_instance_of(Gitlab::Zentao::Client) do |client|
+ allow(client).to receive(:ping).and_return(test_response)
+ end
+ end
+
+ it 'gets response from Gitlab::Zentao::Client#ping' do
+ expect(zentao_integration.test).to eq(test_response)
+ end
+ end
+end
diff --git a/spec/models/integrations/zentao_tracker_data_spec.rb b/spec/models/integrations/zentao_tracker_data_spec.rb
new file mode 100644
index 00000000000..b078c57830b
--- /dev/null
+++ b/spec/models/integrations/zentao_tracker_data_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::ZentaoTrackerData do
+ describe 'factory available' do
+ let(:zentao_tracker_data) { create(:zentao_tracker_data) }
+
+ it { expect(zentao_tracker_data.valid?).to eq true }
+ end
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:integration) }
+ end
+
+ describe 'encrypted attributes' do
+ subject { described_class.encrypted_attributes.keys }
+
+ it { is_expected.to contain_exactly(:url, :api_url, :zentao_product_xid, :api_token) }
+ end
+end
diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb
index 6aba91d9471..51b27151ba2 100644
--- a/spec/models/internal_id_spec.rb
+++ b/spec/models/internal_id_spec.rb
@@ -39,266 +39,199 @@ RSpec.describe InternalId do
end
end
- shared_examples_for 'a monotonically increasing id generator' do
- describe '.generate_next' do
- subject { described_class.generate_next(id_subject, scope, usage, init) }
+ describe '.generate_next' do
+ subject { described_class.generate_next(id_subject, scope, usage, init) }
- context 'in the absence of a record' do
- it 'creates a record if not yet present' do
- expect { subject }.to change { described_class.count }.from(0).to(1)
- end
-
- it 'stores record attributes' do
- subject
-
- described_class.first.tap do |record|
- expect(record.project).to eq(project)
- expect(record.usage).to eq(usage.to_s)
- end
- end
-
- context 'with existing issues' do
- before do
- create_list(:issue, 2, project: project)
- described_class.delete_all
- end
-
- it 'calculates last_value values automatically' do
- expect(subject).to eq(project.issues.size + 1)
- end
- end
- end
-
- it 'generates a strictly monotone, gapless sequence' do
- seq = Array.new(10).map do
- described_class.generate_next(issue, scope, usage, init)
- end
- normalized = seq.map { |i| i - seq.min }
-
- expect(normalized).to eq((0..seq.size - 1).to_a)
+ context 'in the absence of a record' do
+ it 'creates a record if not yet present' do
+ expect { subject }.to change { described_class.count }.from(0).to(1)
end
- context 'there are no instances to pass in' do
- let(:id_subject) { Issue }
+ it 'stores record attributes' do
+ subject
- it 'accepts classes instead' do
- expect(subject).to eq(1)
+ described_class.first.tap do |record|
+ expect(record.project).to eq(project)
+ expect(record.usage).to eq(usage.to_s)
end
end
- context 'when executed outside of transaction' do
- it 'increments counter with in_transaction: "false"' do
- allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases
-
- expect(InternalId.internal_id_transactions_total).to receive(:increment)
- .with(operation: :generate, usage: 'issues', in_transaction: 'false').and_call_original
-
- subject
+ context 'with existing issues' do
+ before do
+ create_list(:issue, 2, project: project)
+ described_class.delete_all
end
- end
- context 'when executed within transaction' do
- it 'increments counter with in_transaction: "true"' do
- expect(InternalId.internal_id_transactions_total).to receive(:increment)
- .with(operation: :generate, usage: 'issues', in_transaction: 'true').and_call_original
-
- InternalId.transaction { subject }
+ it 'calculates last_value values automatically' do
+ expect(subject).to eq(project.issues.size + 1)
end
end
end
- describe '.reset' do
- subject { described_class.reset(issue, scope, usage, value) }
-
- context 'in the absence of a record' do
- let(:value) { 2 }
-
- it 'does not revert back the value' do
- expect { subject }.not_to change { described_class.count }
- expect(subject).to be_falsey
- end
+ it 'generates a strictly monotone, gapless sequence' do
+ seq = Array.new(10).map do
+ described_class.generate_next(issue, scope, usage, init)
end
+ normalized = seq.map { |i| i - seq.min }
- context 'when valid iid is used to reset' do
- let!(:value) { generate_next }
-
- context 'and iid is a latest one' do
- it 'does rewind and next generated value is the same' do
- expect(subject).to be_truthy
- expect(generate_next).to eq(value)
- end
- end
+ expect(normalized).to eq((0..seq.size - 1).to_a)
+ end
- context 'and iid is not a latest one' do
- it 'does not rewind' do
- generate_next
+ context 'there are no instances to pass in' do
+ let(:id_subject) { Issue }
- expect(subject).to be_falsey
- expect(generate_next).to be > value
- end
- end
-
- def generate_next
- described_class.generate_next(issue, scope, usage, init)
- end
+ it 'accepts classes instead' do
+ expect(subject).to eq(1)
end
+ end
- context 'when executed outside of transaction' do
- let(:value) { 2 }
-
- it 'increments counter with in_transaction: "false"' do
- allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases
+ context 'when executed outside of transaction' do
+ it 'increments counter with in_transaction: "false"' do
+ allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases
- expect(InternalId.internal_id_transactions_total).to receive(:increment)
- .with(operation: :reset, usage: 'issues', in_transaction: 'false').and_call_original
+ expect(InternalId.internal_id_transactions_total).to receive(:increment)
+ .with(operation: :generate, usage: 'issues', in_transaction: 'false').and_call_original
- subject
- end
+ subject
end
+ end
- context 'when executed within transaction' do
- let(:value) { 2 }
-
- it 'increments counter with in_transaction: "true"' do
- expect(InternalId.internal_id_transactions_total).to receive(:increment)
- .with(operation: :reset, usage: 'issues', in_transaction: 'true').and_call_original
+ context 'when executed within transaction' do
+ it 'increments counter with in_transaction: "true"' do
+ expect(InternalId.internal_id_transactions_total).to receive(:increment)
+ .with(operation: :generate, usage: 'issues', in_transaction: 'true').and_call_original
- InternalId.transaction { subject }
- end
+ InternalId.transaction { subject }
end
end
+ end
- describe '.track_greatest' do
- let(:value) { 9001 }
-
- subject { described_class.track_greatest(id_subject, scope, usage, value, init) }
-
- context 'in the absence of a record' do
- it 'creates a record if not yet present' do
- expect { subject }.to change { described_class.count }.from(0).to(1)
- end
- end
+ describe '.reset' do
+ subject { described_class.reset(issue, scope, usage, value) }
- it 'stores record attributes' do
- subject
+ context 'in the absence of a record' do
+ let(:value) { 2 }
- described_class.first.tap do |record|
- expect(record.project).to eq(project)
- expect(record.usage).to eq(usage.to_s)
- expect(record.last_value).to eq(value)
- end
+ it 'does not revert back the value' do
+ expect { subject }.not_to change { described_class.count }
+ expect(subject).to be_falsey
end
+ end
- context 'with existing issues' do
- before do
- create(:issue, project: project)
- described_class.delete_all
- end
+ context 'when valid iid is used to reset' do
+ let!(:value) { generate_next }
- it 'still returns the last value to that of the given value' do
- expect(subject).to eq(value)
+ context 'and iid is a latest one' do
+ it 'does rewind and next generated value is the same' do
+ expect(subject).to be_truthy
+ expect(generate_next).to eq(value)
end
end
- context 'when value is less than the current last_value' do
- it 'returns the current last_value' do
- described_class.create!(**scope, usage: usage, last_value: 10_001)
+ context 'and iid is not a latest one' do
+ it 'does not rewind' do
+ generate_next
- expect(subject).to eq 10_001
+ expect(subject).to be_falsey
+ expect(generate_next).to be > value
end
end
- context 'there are no instances to pass in' do
- let(:id_subject) { Issue }
-
- it 'accepts classes instead' do
- expect(subject).to eq(value)
- end
+ def generate_next
+ described_class.generate_next(issue, scope, usage, init)
end
+ end
- context 'when executed outside of transaction' do
- it 'increments counter with in_transaction: "false"' do
- allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases
-
- expect(InternalId.internal_id_transactions_total).to receive(:increment)
- .with(operation: :track_greatest, usage: 'issues', in_transaction: 'false').and_call_original
+ context 'when executed outside of transaction' do
+ let(:value) { 2 }
- subject
- end
- end
+ it 'increments counter with in_transaction: "false"' do
+ allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases
- context 'when executed within transaction' do
- it 'increments counter with in_transaction: "true"' do
- expect(InternalId.internal_id_transactions_total).to receive(:increment)
- .with(operation: :track_greatest, usage: 'issues', in_transaction: 'true').and_call_original
+ expect(InternalId.internal_id_transactions_total).to receive(:increment)
+ .with(operation: :reset, usage: 'issues', in_transaction: 'false').and_call_original
- InternalId.transaction { subject }
- end
+ subject
end
end
- end
- context 'when the feature flag is disabled' do
- stub_feature_flags(generate_iids_without_explicit_locking: false)
+ context 'when executed within transaction' do
+ let(:value) { 2 }
- it_behaves_like 'a monotonically increasing id generator'
- end
-
- context 'when the feature flag is enabled' do
- stub_feature_flags(generate_iids_without_explicit_locking: true)
+ it 'increments counter with in_transaction: "true"' do
+ expect(InternalId.internal_id_transactions_total).to receive(:increment)
+ .with(operation: :reset, usage: 'issues', in_transaction: 'true').and_call_original
- it_behaves_like 'a monotonically increasing id generator'
+ InternalId.transaction { subject }
+ end
+ end
end
- describe '#increment_and_save!' do
- let(:id) { create(:internal_id) }
-
- subject { id.increment_and_save! }
+ describe '.track_greatest' do
+ let(:value) { 9001 }
- it 'returns incremented iid' do
- value = id.last_value
+ subject { described_class.track_greatest(id_subject, scope, usage, value, init) }
- expect(subject).to eq(value + 1)
+ context 'in the absence of a record' do
+ it 'creates a record if not yet present' do
+ expect { subject }.to change { described_class.count }.from(0).to(1)
+ end
end
- it 'saves the record' do
+ it 'stores record attributes' do
subject
- expect(id.changed?).to be_falsey
+ described_class.first.tap do |record|
+ expect(record.project).to eq(project)
+ expect(record.usage).to eq(usage.to_s)
+ expect(record.last_value).to eq(value)
+ end
end
- context 'with last_value=nil' do
- let(:id) { build(:internal_id, last_value: nil) }
+ context 'with existing issues' do
+ before do
+ create(:issue, project: project)
+ described_class.delete_all
+ end
- it 'returns 1' do
- expect(subject).to eq(1)
+ it 'still returns the last value to that of the given value' do
+ expect(subject).to eq(value)
end
end
- end
-
- describe '#track_greatest_and_save!' do
- let(:id) { create(:internal_id) }
- let(:new_last_value) { 9001 }
- subject { id.track_greatest_and_save!(new_last_value) }
+ context 'when value is less than the current last_value' do
+ it 'returns the current last_value' do
+ described_class.create!(**scope, usage: usage, last_value: 10_001)
- it 'returns new last value' do
- expect(subject).to eq new_last_value
+ expect(subject).to eq 10_001
+ end
end
- it 'saves the record' do
- subject
+ context 'there are no instances to pass in' do
+ let(:id_subject) { Issue }
- expect(id.changed?).to be_falsey
+ it 'accepts classes instead' do
+ expect(subject).to eq(value)
+ end
end
- context 'when new last value is lower than the max' do
- it 'does not update the last value' do
- id.update!(last_value: 10_001)
+ context 'when executed outside of transaction' do
+ it 'increments counter with in_transaction: "false"' do
+ allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases
+
+ expect(InternalId.internal_id_transactions_total).to receive(:increment)
+ .with(operation: :track_greatest, usage: 'issues', in_transaction: 'false').and_call_original
subject
+ end
+ end
+
+ context 'when executed within transaction' do
+ it 'increments counter with in_transaction: "true"' do
+ expect(InternalId.internal_id_transactions_total).to receive(:increment)
+ .with(operation: :track_greatest, usage: 'issues', in_transaction: 'true').and_call_original
- expect(id.reload.last_value).to eq 10_001
+ InternalId.transaction { subject }
end
end
end
diff --git a/spec/models/issue/metrics_spec.rb b/spec/models/issue/metrics_spec.rb
index 49c891c20da..2fdf1f09f80 100644
--- a/spec/models/issue/metrics_spec.rb
+++ b/spec/models/issue/metrics_spec.rb
@@ -34,7 +34,7 @@ RSpec.describe Issue::Metrics do
end
end
- describe "when recording the default set of issue metrics on issue save" do
+ context "when recording the default set of issue metrics on issue save" do
context "milestones" do
it "records the first time an issue is associated with a milestone" do
time = Time.current
@@ -80,20 +80,5 @@ RSpec.describe Issue::Metrics do
expect(metrics.first_added_to_board_at).to be_like_time(time)
end
end
-
- describe "#record!" do
- it "does not cause an N+1 query" do
- label = create(:label)
- subject.update!(label_ids: [label.id])
-
- control_count = ActiveRecord::QueryRecorder.new { Issue::Metrics.find_by(issue: subject).record! }.count
-
- additional_labels = create_list(:label, 4)
-
- subject.update!(label_ids: additional_labels.map(&:id))
-
- expect { Issue::Metrics.find_by(issue: subject).record! }.not_to exceed_query_limit(control_count)
- end
- end
end
end
diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb
index 116bda7a18b..1747972e8ae 100644
--- a/spec/models/issue_spec.rb
+++ b/spec/models/issue_spec.rb
@@ -102,7 +102,7 @@ RSpec.describe Issue do
end
it 'records current metrics' do
- expect_any_instance_of(Issue::Metrics).to receive(:record!)
+ expect(Issue::Metrics).to receive(:record!)
create(:issue, project: reusable_project)
end
@@ -111,7 +111,6 @@ RSpec.describe Issue do
before do
subject.metrics.delete
subject.reload
- subject.metrics # make sure metrics association is cached (currently nil)
end
it 'creates the metrics record' do
@@ -166,8 +165,8 @@ RSpec.describe Issue do
expect(described_class.simple_sorts.keys).to include(
*%w(created_asc created_at_asc created_date created_desc created_at_desc
closest_future_date closest_future_date_asc due_date due_date_asc due_date_desc
- id_asc id_desc relative_position relative_position_asc
- updated_desc updated_asc updated_at_asc updated_at_desc))
+ id_asc id_desc relative_position relative_position_asc updated_desc updated_asc
+ updated_at_asc updated_at_desc title_asc title_desc))
end
end
@@ -204,6 +203,25 @@ RSpec.describe Issue do
end
end
+ describe '.order_title' do
+ let_it_be(:issue1) { create(:issue, title: 'foo') }
+ let_it_be(:issue2) { create(:issue, title: 'bar') }
+ let_it_be(:issue3) { create(:issue, title: 'baz') }
+ let_it_be(:issue4) { create(:issue, title: 'Baz 2') }
+
+ context 'sorting ascending' do
+ subject { described_class.order_title_asc }
+
+ it { is_expected.to eq([issue2, issue3, issue4, issue1]) }
+ end
+
+ context 'sorting descending' do
+ subject { described_class.order_title_desc }
+
+ it { is_expected.to eq([issue1, issue4, issue3, issue2]) }
+ end
+ end
+
describe '#order_by_position_and_priority' do
let(:project) { reusable_project }
let(:p1) { create(:label, title: 'P1', project: project, priority: 1) }
@@ -1177,18 +1195,33 @@ RSpec.describe Issue do
it 'refreshes the number of open issues of the project' do
project = subject.project
- expect { subject.destroy! }
- .to change { project.open_issues_count }.from(1).to(0)
+ expect do
+ subject.destroy!
+
+ BatchLoader::Executor.clear_current
+ end.to change { project.open_issues_count }.from(1).to(0)
end
end
describe '.public_only' do
- it 'only returns public issues' do
- public_issue = create(:issue, project: reusable_project)
- create(:issue, project: reusable_project, confidential: true)
+ let_it_be(:banned_user) { create(:user, :banned) }
+ let_it_be(:public_issue) { create(:issue, project: reusable_project) }
+ let_it_be(:confidential_issue) { create(:issue, project: reusable_project, confidential: true) }
+ let_it_be(:hidden_issue) { create(:issue, project: reusable_project, author: banned_user) }
+ it 'only returns public issues' do
expect(described_class.public_only).to eq([public_issue])
end
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(ban_user_feature_flag: false)
+ end
+
+ it 'returns public and hidden issues' do
+ expect(described_class.public_only).to eq([public_issue, hidden_issue])
+ end
+ end
end
describe '.confidential_only' do
@@ -1402,19 +1435,19 @@ RSpec.describe Issue do
describe 'scheduling rebalancing' do
before do
allow_next_instance_of(RelativePositioning::Mover) do |mover|
- allow(mover).to receive(:move) { raise ActiveRecord::QueryCanceled }
+ allow(mover).to receive(:move) { raise RelativePositioning::NoSpaceLeft }
end
end
shared_examples 'schedules issues rebalancing' do
let(:issue) { build_stubbed(:issue, relative_position: 100, project: project) }
- it 'schedules rebalancing if we time-out when moving' do
+ it 'schedules rebalancing if there is no space left' do
lhs = build_stubbed(:issue, relative_position: 99, project: project)
to_move = build(:issue, project: project)
expect(IssueRebalancingWorker).to receive(:perform_async).with(nil, project_id, namespace_id)
- expect { to_move.move_between(lhs, issue) }.to raise_error(ActiveRecord::QueryCanceled)
+ expect { to_move.move_between(lhs, issue) }.to raise_error(RelativePositioning::NoSpaceLeft)
end
end
diff --git a/spec/models/loose_foreign_keys/deleted_record_spec.rb b/spec/models/loose_foreign_keys/deleted_record_spec.rb
new file mode 100644
index 00000000000..db2f8b4d2d3
--- /dev/null
+++ b/spec/models/loose_foreign_keys/deleted_record_spec.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe LooseForeignKeys::DeletedRecord do
+ let_it_be(:deleted_record_1) { described_class.create!(created_at: 1.day.ago, deleted_table_name: 'projects', deleted_table_primary_key_value: 5) }
+ let_it_be(:deleted_record_2) { described_class.create!(created_at: 3.days.ago, deleted_table_name: 'projects', deleted_table_primary_key_value: 1) }
+ let_it_be(:deleted_record_3) { described_class.create!(created_at: 5.days.ago, deleted_table_name: 'projects', deleted_table_primary_key_value: 3) }
+ let_it_be(:deleted_record_4) { described_class.create!(created_at: 10.days.ago, deleted_table_name: 'projects', deleted_table_primary_key_value: 1) } # duplicate
+
+ # skip created_at because it gets truncated after insert
+ def map_attributes(records)
+ records.pluck(:deleted_table_name, :deleted_table_primary_key_value)
+ end
+
+ describe 'partitioning strategy' do
+ it 'has retain_non_empty_partitions option' do
+ expect(described_class.partitioning_strategy.retain_non_empty_partitions).to eq(true)
+ end
+ end
+
+ describe '.load_batch' do
+ it 'loads records and orders them by creation date' do
+ records = described_class.load_batch(4)
+
+ expect(map_attributes(records)).to eq([['projects', 1], ['projects', 3], ['projects', 1], ['projects', 5]])
+ end
+
+ it 'supports configurable batch size' do
+ records = described_class.load_batch(2)
+
+ expect(map_attributes(records)).to eq([['projects', 1], ['projects', 3]])
+ end
+ end
+
+ describe '.delete_records' do
+ it 'deletes exactly one record' do
+ described_class.delete_records([deleted_record_2])
+
+ expect(described_class.count).to eq(3)
+ expect(described_class.find_by(created_at: deleted_record_2.created_at)).to eq(nil)
+ end
+
+ it 'deletes two records' do
+ described_class.delete_records([deleted_record_2, deleted_record_4])
+
+ expect(described_class.count).to eq(2)
+ end
+
+ it 'deletes all records' do
+ described_class.delete_records([deleted_record_1, deleted_record_2, deleted_record_3, deleted_record_4])
+
+ expect(described_class.count).to eq(0)
+ end
+ end
+end
diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb
index 067b3c25645..3f7f69ff34e 100644
--- a/spec/models/member_spec.rb
+++ b/spec/models/member_spec.rb
@@ -645,6 +645,16 @@ RSpec.describe Member do
expect(user.authorized_projects.reload).to include(project)
end
+
+ it 'does not accept the invite if saving a new user fails' do
+ invalid_user = User.new(first_name: '', last_name: '')
+
+ member.accept_invite! invalid_user
+
+ expect(member.invite_accepted_at).to be_nil
+ expect(member.invite_token).not_to be_nil
+ expect_any_instance_of(Member).not_to receive(:after_accept_invite)
+ end
end
describe "#decline_invite!" do
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 4a8a2909891..06ca88644b7 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -151,43 +151,6 @@ RSpec.describe MergeRequest, factory_default: :keep do
end
end
- describe '#squash_in_progress?' do
- let(:repo_path) do
- Gitlab::GitalyClient::StorageSettings.allow_disk_access do
- subject.source_project.repository.path
- end
- end
-
- let(:squash_path) { File.join(repo_path, "gitlab-worktree", "squash-#{subject.id}") }
-
- before do
- system(*%W(#{Gitlab.config.git.bin_path} -C #{repo_path} worktree add --detach #{squash_path} master))
- end
-
- it 'returns true when there is a current squash directory' do
- expect(subject.squash_in_progress?).to be_truthy
- end
-
- it 'returns false when there is no squash directory' do
- FileUtils.rm_rf(squash_path)
-
- expect(subject.squash_in_progress?).to be_falsey
- end
-
- it 'returns false when the squash directory has expired' do
- time = 20.minutes.ago.to_time
- File.utime(time, time, squash_path)
-
- expect(subject.squash_in_progress?).to be_falsey
- end
-
- it 'returns false when the source project has been removed' do
- allow(subject).to receive(:source_project).and_return(nil)
-
- expect(subject.squash_in_progress?).to be_falsey
- end
- end
-
describe '#squash?' do
let(:merge_request) { build(:merge_request, squash: squash) }
diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb
index f14b9c57eb1..bc592acc80f 100644
--- a/spec/models/milestone_spec.rb
+++ b/spec/models/milestone_spec.rb
@@ -538,15 +538,6 @@ RSpec.describe Milestone do
it { is_expected.to match('gitlab-org/gitlab-ce%123') }
it { is_expected.to match('gitlab-org/gitlab-ce%"my-milestone"') }
-
- context 'when milestone_reference_pattern feature flag is false' do
- before do
- stub_feature_flags(milestone_reference_pattern: false)
- end
-
- it { is_expected.to match('gitlab-org/gitlab-ce%123') }
- it { is_expected.to match('gitlab-org/gitlab-ce%"my-milestone"') }
- end
end
describe '.link_reference_pattern' do
diff --git a/spec/models/namespace_setting_spec.rb b/spec/models/namespace_setting_spec.rb
index e8ed6f1a460..c1cc8fc3e88 100644
--- a/spec/models/namespace_setting_spec.rb
+++ b/spec/models/namespace_setting_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe NamespaceSetting, type: :model do
+ it_behaves_like 'sanitizable', :namespace_settings, %i[default_branch_name]
+
# Relationships
#
describe "Associations" do
@@ -41,14 +43,6 @@ RSpec.describe NamespaceSetting, type: :model do
it_behaves_like "doesn't return an error"
end
-
- context "when it contains javascript tags" do
- it "gets sanitized properly" do
- namespace_settings.update!(default_branch_name: "hello<script>alert(1)</script>")
-
- expect(namespace_settings.default_branch_name).to eq('hello')
- end
- end
end
describe '#allow_mfa_for_group' do
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index e2700378f5f..51a26d82daa 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -36,27 +36,34 @@ RSpec.describe Namespace do
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
+ using RSpec::Parameterized::TableSyntax
+
+ where(:parent_type, :child_type, :error) do
+ nil | 'User' | nil
+ nil | 'Group' | nil
+ nil | 'Project' | 'must be set for a project namespace'
+ 'Project' | 'User' | 'project namespace cannot be the parent of another namespace'
+ 'Project' | 'Group' | 'project namespace cannot be the parent of another namespace'
+ 'Project' | 'Project' | 'project namespace cannot be the parent of another namespace'
+ 'Group' | 'User' | 'cannot not be used for user namespace'
+ 'Group' | 'Group' | nil
+ 'Group' | 'Project' | nil
+ 'User' | 'User' | 'cannot not be used for user namespace'
+ 'User' | 'Group' | 'user namespace cannot be the parent of another namespace'
+ 'User' | 'Project' | nil
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')
+ with_them do
+ it 'validates namespace parent' do
+ parent = build(:namespace, type: parent_type) if parent_type
+ namespace = build(:namespace, type: child_type, parent: parent)
+
+ if error
+ expect(namespace).not_to be_valid
+ expect(namespace.errors[:parent_id].first).to eq(error)
+ else
+ expect(namespace).to be_valid
+ end
end
end
@@ -157,6 +164,65 @@ RSpec.describe Namespace do
end
end
+ describe 'handling STI', :aggregate_failures do
+ let(:namespace_type) { nil }
+ let(:parent) { nil }
+ let(:namespace) { Namespace.find(create(:namespace, type: namespace_type, parent: parent).id) }
+
+ context 'creating a Group' do
+ let(:namespace_type) { 'Group' }
+
+ it 'is valid' do
+ expect(namespace).to be_a(Group)
+ expect(namespace.kind).to eq('group')
+ expect(namespace.group?).to be_truthy
+ end
+ end
+
+ context 'creating a ProjectNamespace' do
+ let(:namespace_type) { 'Project' }
+ let(:parent) { create(:group) }
+
+ it 'is valid' do
+ expect(Namespace.find(namespace.id)).to be_a(Namespaces::ProjectNamespace)
+ expect(namespace.kind).to eq('project')
+ expect(namespace.project?).to be_truthy
+ end
+ end
+
+ context 'creating a UserNamespace' do
+ let(:namespace_type) { 'User' }
+
+ it 'is valid' do
+ # TODO: We create a normal Namespace until
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68894 is ready
+ expect(Namespace.find(namespace.id)).to be_a(Namespace)
+ expect(namespace.kind).to eq('user')
+ expect(namespace.user?).to be_truthy
+ end
+ end
+
+ context 'creating a default Namespace' do
+ let(:namespace_type) { nil }
+
+ it 'is valid' do
+ expect(Namespace.find(namespace.id)).to be_a(Namespace)
+ expect(namespace.kind).to eq('user')
+ expect(namespace.user?).to be_truthy
+ end
+ end
+
+ context 'creating an unknown Namespace type' do
+ let(:namespace_type) { 'One' }
+
+ it 'defaults to a Namespace' do
+ expect(Namespace.find(namespace.id)).to be_a(Namespace)
+ expect(namespace.kind).to eq('user')
+ expect(namespace.user?).to be_truthy
+ end
+ end
+ end
+
describe 'scopes', :aggregate_failures do
let_it_be(:namespace1) { create(:group, name: 'Namespace 1', path: 'namespace-1') }
let_it_be(:namespace2) { create(:group, name: 'Namespace 2', path: 'namespace-2') }
@@ -287,6 +353,12 @@ RSpec.describe Namespace do
end
end
+ describe '#owner_required?' do
+ specify { expect(build(:project_namespace).owner_required?).to be_falsey }
+ specify { expect(build(:group).owner_required?).to be_falsey }
+ specify { expect(build(:namespace).owner_required?).to be_truthy }
+ end
+
describe '#visibility_level_field' do
it { expect(namespace.visibility_level_field).to eq(:visibility_level) }
end
@@ -1377,6 +1449,13 @@ RSpec.describe Namespace do
expect { root_group.root_ancestor }.not_to exceed_query_limit(0)
end
+ it 'returns root_ancestor for nested group with a single query' do
+ nested_group = create(:group, parent: root_group)
+ nested_group.reload
+
+ expect { nested_group.root_ancestor }.not_to exceed_query_limit(1)
+ end
+
it 'returns the top most ancestor' do
nested_group = create(:group, parent: root_group)
deep_nested_group = create(:group, parent: nested_group)
diff --git a/spec/models/namespaces/project_namespace_spec.rb b/spec/models/namespaces/project_namespace_spec.rb
new file mode 100644
index 00000000000..f38e8aa85d0
--- /dev/null
+++ b/spec/models/namespaces/project_namespace_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Namespaces::ProjectNamespace, type: :model do
+ describe 'relationships' do
+ it { is_expected.to have_one(:project).with_foreign_key(:project_namespace_id).inverse_of(:project_namespace) }
+ end
+
+ describe 'validations' do
+ it { is_expected.not_to validate_presence_of :owner }
+ end
+
+ context 'when deleting project namespace' do
+ # using delete rather than destroy due to `delete` skipping AR hooks/callbacks
+ # so it's ensured to work at the DB level. Uses ON DELETE CASCADE on foreign key
+ let_it_be(:project) { create(:project) }
+ let_it_be(:project_namespace) { create(:project_namespace, project: project) }
+
+ it 'also deletes the associated project' do
+ project_namespace.delete
+
+ expect { project_namespace.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { project.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+end
diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb
index 0afdae2fc93..5e3773513f1 100644
--- a/spec/models/note_spec.rb
+++ b/spec/models/note_spec.rb
@@ -500,15 +500,15 @@ RSpec.describe Note do
let_it_be(:ext_issue) { create(:issue, project: ext_proj) }
shared_examples "checks references" do
- it "returns true" do
+ it "returns false" do
expect(note.system_note_with_references_visible_for?(ext_issue.author)).to be_falsy
end
- it "returns false" do
+ it "returns true" do
expect(note.system_note_with_references_visible_for?(private_user)).to be_truthy
end
- it "returns false if user visible reference count set" do
+ it "returns true if user visible reference count set" do
note.user_visible_reference_count = 1
note.total_reference_count = 1
@@ -516,7 +516,15 @@ RSpec.describe Note do
expect(note.system_note_with_references_visible_for?(ext_issue.author)).to be_truthy
end
- it "returns true if ref count is 0" do
+ it "returns false if user visible reference count set but does not match total reference count" do
+ note.user_visible_reference_count = 1
+ note.total_reference_count = 2
+
+ expect(note).not_to receive(:reference_mentionables)
+ expect(note.system_note_with_references_visible_for?(ext_issue.author)).to be_falsy
+ end
+
+ it "returns false if ref count is 0" do
note.user_visible_reference_count = 0
expect(note).not_to receive(:reference_mentionables)
@@ -562,13 +570,35 @@ RSpec.describe Note do
end
it_behaves_like "checks references"
+ end
- it "returns true if user visible reference count set and there is a private reference" do
- note.user_visible_reference_count = 1
- note.total_reference_count = 2
+ context "when there is a private issue and user reference" do
+ let_it_be(:ext_issue2) { create(:issue, project: ext_proj) }
- expect(note).not_to receive(:reference_mentionables)
- expect(note.system_note_with_references_visible_for?(ext_issue.author)).to be_falsy
+ let(:note) do
+ create :note,
+ noteable: ext_issue2, project: ext_proj,
+ note: "mentioned in #{private_issue.to_reference(ext_proj)} and pinged user #{private_user.to_reference}",
+ system: true
+ end
+
+ it_behaves_like "checks references"
+ end
+
+ context "when there is a publicly visible user reference" do
+ let(:note) do
+ create :note,
+ noteable: ext_issue, project: ext_proj,
+ note: "mentioned in #{ext_proj.owner.to_reference}",
+ system: true
+ end
+
+ it "returns true for other users" do
+ expect(note.system_note_with_references_visible_for?(ext_issue.author)).to be_truthy
+ end
+
+ it "returns true for anonymous users" do
+ expect(note.system_note_with_references_visible_for?(nil)).to be_truthy
end
end
end
@@ -1543,7 +1573,15 @@ RSpec.describe Note do
let(:note) { build(:note) }
it 'returns cache key and author cache key by default' do
- expect(note.post_processed_cache_key).to eq("#{note.cache_key}:#{note.author.cache_key}")
+ expect(note.post_processed_cache_key).to eq("#{note.cache_key}:#{note.author.cache_key}:#{note.project.team.human_max_access(note.author_id)}")
+ end
+
+ context 'when note has no author' do
+ let(:note) { build(:note, author: nil) }
+
+ it 'returns cache key only' do
+ expect(note.post_processed_cache_key).to eq("#{note.cache_key}:")
+ end
end
context 'when note has redacted_note_html' do
@@ -1554,7 +1592,7 @@ RSpec.describe Note do
end
it 'returns cache key with redacted_note_html sha' do
- expect(note.post_processed_cache_key).to eq("#{note.cache_key}:#{note.author.cache_key}:#{Digest::SHA1.hexdigest(redacted_note_html)}")
+ expect(note.post_processed_cache_key).to eq("#{note.cache_key}:#{note.author.cache_key}:#{note.project.team.human_max_access(note.author_id)}:#{Digest::SHA1.hexdigest(redacted_note_html)}")
end
end
end
diff --git a/spec/models/operations/feature_flag_scope_spec.rb b/spec/models/operations/feature_flag_scope_spec.rb
deleted file mode 100644
index dc83789fade..00000000000
--- a/spec/models/operations/feature_flag_scope_spec.rb
+++ /dev/null
@@ -1,391 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Operations::FeatureFlagScope do
- describe 'associations' do
- it { is_expected.to belong_to(:feature_flag) }
- end
-
- describe 'validations' do
- context 'when duplicate environment scope is going to be created' do
- let!(:existing_feature_flag_scope) do
- create(:operations_feature_flag_scope)
- end
-
- let(:new_feature_flag_scope) do
- build(:operations_feature_flag_scope,
- feature_flag: existing_feature_flag_scope.feature_flag,
- environment_scope: existing_feature_flag_scope.environment_scope)
- end
-
- it 'validates uniqueness of environment scope' do
- new_feature_flag_scope.save
-
- expect(new_feature_flag_scope.errors[:environment_scope])
- .to include("(#{existing_feature_flag_scope.environment_scope})" \
- " has already been taken")
- end
- end
-
- context 'when environment scope of a default scope is updated' do
- let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag) }
- let!(:scope_default) { feature_flag.default_scope }
-
- it 'keeps default scope intact' do
- scope_default.update(environment_scope: 'review/*')
-
- expect(scope_default.errors[:environment_scope])
- .to include("cannot be changed from default scope")
- end
- end
-
- context 'when a default scope is destroyed' do
- let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag) }
- let!(:scope_default) { feature_flag.default_scope }
-
- it 'prevents from destroying the default scope' do
- expect { scope_default.destroy! }.to raise_error(ActiveRecord::ReadOnlyRecord)
- end
- end
-
- describe 'strategy validations' do
- it 'handles null strategies which can occur while adding the column during migration' do
- scope = create(:operations_feature_flag_scope, active: true)
- allow(scope).to receive(:strategies).and_return(nil)
-
- scope.active = false
- scope.save
-
- expect(scope.errors[:strategies]).to be_empty
- end
-
- it 'validates multiple strategies' do
- feature_flag = create(:operations_feature_flag)
- scope = described_class.create(feature_flag: feature_flag,
- environment_scope: 'production', active: true,
- strategies: [{ name: "default", parameters: {} },
- { name: "invalid", parameters: {} }])
-
- expect(scope.errors[:strategies]).not_to be_empty
- end
-
- where(:invalid_value) do
- [{}, 600, "bad", [{ name: 'default', parameters: {} }, 300]]
- end
- with_them do
- it 'must be an array of strategy hashes' do
- scope = create(:operations_feature_flag_scope)
-
- scope.strategies = invalid_value
- scope.save
-
- expect(scope.errors[:strategies]).to eq(['must be an array of strategy hashes'])
- end
- end
-
- describe 'name' do
- using RSpec::Parameterized::TableSyntax
-
- where(:name, :params, :expected) do
- 'default' | {} | []
- 'gradualRolloutUserId' | { groupId: 'mygroup', percentage: '50' } | []
- 'userWithId' | { userIds: 'sam' } | []
- 5 | nil | ['strategy name is invalid']
- nil | nil | ['strategy name is invalid']
- "nothing" | nil | ['strategy name is invalid']
- "" | nil | ['strategy name is invalid']
- 40.0 | nil | ['strategy name is invalid']
- {} | nil | ['strategy name is invalid']
- [] | nil | ['strategy name is invalid']
- end
- with_them do
- it 'must be one of "default", "gradualRolloutUserId", or "userWithId"' do
- feature_flag = create(:operations_feature_flag)
- scope = described_class.create(feature_flag: feature_flag,
- environment_scope: 'production', active: true,
- strategies: [{ name: name, parameters: params }])
-
- expect(scope.errors[:strategies]).to eq(expected)
- end
- end
- end
-
- describe 'parameters' do
- context 'when the strategy name is gradualRolloutUserId' do
- it 'must have parameters' do
- feature_flag = create(:operations_feature_flag)
- scope = described_class.create(feature_flag: feature_flag,
- environment_scope: 'production', active: true,
- strategies: [{ name: 'gradualRolloutUserId' }])
-
- expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
- end
-
- where(:invalid_parameters) do
- [nil, {}, { percentage: '40', groupId: 'mygroup', userIds: '4' }, { percentage: '40' },
- { percentage: '40', groupId: 'mygroup', extra: nil }, { groupId: 'mygroup' }]
- end
- with_them do
- it 'must have valid parameters for the strategy' do
- feature_flag = create(:operations_feature_flag)
- scope = described_class.create(feature_flag: feature_flag,
- environment_scope: 'production', active: true,
- strategies: [{ name: 'gradualRolloutUserId',
- parameters: invalid_parameters }])
-
- expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
- end
- end
-
- it 'allows the parameters in any order' do
- feature_flag = create(:operations_feature_flag)
- scope = described_class.create(feature_flag: feature_flag,
- environment_scope: 'production', active: true,
- strategies: [{ name: 'gradualRolloutUserId',
- parameters: { percentage: '10', groupId: 'mygroup' } }])
-
- expect(scope.errors[:strategies]).to be_empty
- end
-
- describe 'percentage' do
- where(:invalid_value) do
- [50, 40.0, { key: "value" }, "garbage", "00", "01", "101", "-1", "-10", "0100",
- "1000", "10.0", "5%", "25%", "100hi", "e100", "30m", " ", "\r\n", "\n", "\t",
- "\n10", "20\n", "\n100", "100\n", "\n ", nil]
- end
- with_them do
- it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do
- feature_flag = create(:operations_feature_flag)
- scope = described_class.create(feature_flag: feature_flag,
- environment_scope: 'production', active: true,
- strategies: [{ name: 'gradualRolloutUserId',
- parameters: { groupId: 'mygroup', percentage: invalid_value } }])
-
- expect(scope.errors[:strategies]).to eq(['percentage must be a string between 0 and 100 inclusive'])
- end
- end
-
- where(:valid_value) do
- %w[0 1 10 38 100 93]
- end
- with_them do
- it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do
- feature_flag = create(:operations_feature_flag)
- scope = described_class.create(feature_flag: feature_flag,
- environment_scope: 'production', active: true,
- strategies: [{ name: 'gradualRolloutUserId',
- parameters: { groupId: 'mygroup', percentage: valid_value } }])
-
- expect(scope.errors[:strategies]).to eq([])
- end
- end
- end
-
- describe 'groupId' do
- where(:invalid_value) do
- [nil, 4, 50.0, {}, 'spaces bad', 'bad$', '%bad', '<bad', 'bad>', '!bad',
- '.bad', 'Bad', 'bad1', "", " ", "b" * 33, "ba_d", "ba\nd"]
- end
- with_them do
- it 'must be a string value of up to 32 lowercase characters' do
- feature_flag = create(:operations_feature_flag)
- scope = described_class.create(feature_flag: feature_flag,
- environment_scope: 'production', active: true,
- strategies: [{ name: 'gradualRolloutUserId',
- parameters: { groupId: invalid_value, percentage: '40' } }])
-
- expect(scope.errors[:strategies]).to eq(['groupId parameter is invalid'])
- end
- end
-
- where(:valid_value) do
- ["somegroup", "anothergroup", "okay", "g", "a" * 32]
- end
- with_them do
- it 'must be a string value of up to 32 lowercase characters' do
- feature_flag = create(:operations_feature_flag)
- scope = described_class.create(feature_flag: feature_flag,
- environment_scope: 'production', active: true,
- strategies: [{ name: 'gradualRolloutUserId',
- parameters: { groupId: valid_value, percentage: '40' } }])
-
- expect(scope.errors[:strategies]).to eq([])
- end
- end
- end
- end
-
- context 'when the strategy name is userWithId' do
- it 'must have parameters' do
- feature_flag = create(:operations_feature_flag)
- scope = described_class.create(feature_flag: feature_flag,
- environment_scope: 'production', active: true,
- strategies: [{ name: 'userWithId' }])
-
- expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
- end
-
- where(:invalid_parameters) do
- [nil, { userIds: 'sam', percentage: '40' }, { userIds: 'sam', some: 'param' }, { percentage: '40' }, {}]
- end
- with_them do
- it 'must have valid parameters for the strategy' do
- feature_flag = create(:operations_feature_flag)
- scope = described_class.create(feature_flag: feature_flag,
- environment_scope: 'production', active: true,
- strategies: [{ name: 'userWithId', parameters: invalid_parameters }])
-
- expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
- end
- end
-
- describe 'userIds' do
- where(:valid_value) do
- ["", "sam", "1", "a", "uuid-of-some-kind", "sam,fred,tom,jane,joe,mike",
- "gitlab@example.com", "123,4", "UPPER,Case,charActeRS", "0",
- "$valid$email#2345#$%..{}+=-)?\\/@example.com", "spaces allowed",
- "a" * 256, "a,#{'b' * 256},ccc", "many spaces"]
- end
- with_them do
- it 'is valid with a string of comma separated values' do
- feature_flag = create(:operations_feature_flag)
- scope = described_class.create(feature_flag: feature_flag,
- environment_scope: 'production', active: true,
- strategies: [{ name: 'userWithId', parameters: { userIds: valid_value } }])
-
- expect(scope.errors[:strategies]).to be_empty
- end
- end
-
- where(:invalid_value) do
- [1, 2.5, {}, [], nil, "123\n456", "1,2,3,12\t3", "\n", "\n\r",
- "joe\r,sam", "1,2,2", "1,,2", "1,2,,,,", "b" * 257, "1, ,2", "tim, ,7", " ",
- " ", " ,1", "1, ", " leading,1", "1,trailing ", "1, both ,2"]
- end
- with_them do
- it 'is invalid' do
- feature_flag = create(:operations_feature_flag)
- scope = described_class.create(feature_flag: feature_flag,
- environment_scope: 'production', active: true,
- strategies: [{ name: 'userWithId', parameters: { userIds: invalid_value } }])
-
- expect(scope.errors[:strategies]).to include(
- 'userIds must be a string of unique comma separated values each 256 characters or less'
- )
- end
- end
- end
- end
-
- context 'when the strategy name is default' do
- it 'must have parameters' do
- feature_flag = create(:operations_feature_flag)
- scope = described_class.create(feature_flag: feature_flag,
- environment_scope: 'production', active: true,
- strategies: [{ name: 'default' }])
-
- expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
- end
-
- where(:invalid_value) do
- [{ groupId: "hi", percentage: "7" }, "", "nothing", 7, nil, [], 2.5]
- end
- with_them do
- it 'must be empty' do
- feature_flag = create(:operations_feature_flag)
- scope = described_class.create(feature_flag: feature_flag,
- environment_scope: 'production', active: true,
- strategies: [{ name: 'default',
- parameters: invalid_value }])
-
- expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
- end
- end
-
- it 'must be empty' do
- feature_flag = create(:operations_feature_flag)
- scope = described_class.create(feature_flag: feature_flag,
- environment_scope: 'production', active: true,
- strategies: [{ name: 'default',
- parameters: {} }])
-
- expect(scope.errors[:strategies]).to be_empty
- end
- end
- end
- end
- end
-
- describe '.enabled' do
- subject { described_class.enabled }
-
- let!(:feature_flag_scope) do
- create(:operations_feature_flag_scope, active: active)
- end
-
- context 'when scope is active' do
- let(:active) { true }
-
- it 'returns the scope' do
- is_expected.to include(feature_flag_scope)
- end
- end
-
- context 'when scope is inactive' do
- let(:active) { false }
-
- it 'returns an empty array' do
- is_expected.not_to include(feature_flag_scope)
- end
- end
- end
-
- describe '.disabled' do
- subject { described_class.disabled }
-
- let!(:feature_flag_scope) do
- create(:operations_feature_flag_scope, active: active)
- end
-
- context 'when scope is active' do
- let(:active) { true }
-
- it 'returns an empty array' do
- is_expected.not_to include(feature_flag_scope)
- end
- end
-
- context 'when scope is inactive' do
- let(:active) { false }
-
- it 'returns the scope' do
- is_expected.to include(feature_flag_scope)
- end
- end
- end
-
- describe '.for_unleash_client' do
- it 'returns scopes for the specified project' do
- project1 = create(:project)
- project2 = create(:project)
- expected_feature_flag = create(:operations_feature_flag, project: project1)
- create(:operations_feature_flag, project: project2)
-
- scopes = described_class.for_unleash_client(project1, 'sandbox').to_a
-
- expect(scopes).to contain_exactly(*expected_feature_flag.scopes)
- end
-
- it 'returns a scope that matches exactly over a match with a wild card' do
- project = create(:project)
- feature_flag = create(:operations_feature_flag, project: project)
- create(:operations_feature_flag_scope, feature_flag: feature_flag, environment_scope: 'production*')
- expected_scope = create(:operations_feature_flag_scope, feature_flag: feature_flag, environment_scope: 'production')
-
- scopes = described_class.for_unleash_client(project, 'production').to_a
-
- expect(scopes).to contain_exactly(expected_scope)
- end
- end
-end
diff --git a/spec/models/operations/feature_flag_spec.rb b/spec/models/operations/feature_flag_spec.rb
index cb9da2aea34..d689632e2b4 100644
--- a/spec/models/operations/feature_flag_spec.rb
+++ b/spec/models/operations/feature_flag_spec.rb
@@ -49,28 +49,7 @@ RSpec.describe Operations::FeatureFlag do
it { is_expected.to validate_presence_of(:project) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) }
- it { is_expected.to define_enum_for(:version).with_values(legacy_flag: 1, new_version_flag: 2) }
-
- context 'a version 1 feature flag' do
- it 'is valid if associated with Operations::FeatureFlagScope models' do
- project = create(:project)
- feature_flag = described_class.create!({ name: 'test', project: project, version: 1,
- scopes_attributes: [{ environment_scope: '*', active: false }] })
-
- expect(feature_flag).to be_valid
- end
-
- it 'is invalid if associated with Operations::FeatureFlags::Strategy models' do
- project = create(:project)
- feature_flag = described_class.new({ name: 'test', project: project, version: 1,
- strategies_attributes: [{ name: 'default', parameters: {} }] })
-
- expect(feature_flag.valid?).to eq(false)
- expect(feature_flag.errors.messages).to eq({
- version_associations: ["version 1 feature flags may not have strategies"]
- })
- end
- end
+ it { is_expected.to define_enum_for(:version).with_values(new_version_flag: 2) }
context 'a version 2 feature flag' do
it 'is invalid if associated with Operations::FeatureFlagScope models' do
@@ -102,64 +81,9 @@ RSpec.describe Operations::FeatureFlag do
end
end
- describe 'feature flag version' do
- it 'defaults to 1 if unspecified' do
- project = create(:project)
-
- feature_flag = described_class.create!(name: 'my_flag', project: project, active: true)
-
- expect(feature_flag).to be_valid
- expect(feature_flag.version_before_type_cast).to eq(1)
- end
- end
-
- describe 'Scope creation' do
- subject { described_class.new(**params) }
-
- let(:project) { create(:project) }
-
- let(:params) do
- { name: 'test', project: project, scopes_attributes: scopes_attributes }
- end
-
- let(:scopes_attributes) do
- [{ environment_scope: '*', active: false },
- { environment_scope: 'review/*', active: true }]
- end
-
- it { is_expected.to be_valid }
-
- context 'when the first scope is not wildcard' do
- let(:scopes_attributes) do
- [{ environment_scope: 'review/*', active: true },
- { environment_scope: '*', active: false }]
- end
-
- it { is_expected.not_to be_valid }
- end
- end
-
describe 'the default scope' do
let_it_be(:project) { create(:project) }
- context 'with a version 1 feature flag' do
- it 'creates a default scope' do
- feature_flag = described_class.create!({ name: 'test', project: project, scopes_attributes: [], version: 1 })
-
- expect(feature_flag.scopes.count).to eq(1)
- expect(feature_flag.scopes.first.environment_scope).to eq('*')
- end
-
- it 'allows specifying the default scope in the parameters' do
- feature_flag = described_class.create!({ name: 'test', project: project,
- scopes_attributes: [{ environment_scope: '*', active: false },
- { environment_scope: 'review/*', active: true }], version: 1 })
-
- expect(feature_flag.scopes.count).to eq(2)
- expect(feature_flag.scopes.first.environment_scope).to eq('*')
- end
- end
-
context 'with a version 2 feature flag' do
it 'does not create a default scope' do
feature_flag = described_class.create!({ name: 'test', project: project, scopes_attributes: [], version: 2 })
@@ -180,16 +104,6 @@ RSpec.describe Operations::FeatureFlag do
end
end
- context 'when the feature flag is active and all scopes are inactive' do
- let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag, active: true) }
-
- it 'returns the flag' do
- feature_flag.default_scope.update!(active: false)
-
- is_expected.to eq([feature_flag])
- end
- end
-
context 'when the feature flag is inactive' do
let!(:feature_flag) { create(:operations_feature_flag, active: false) }
@@ -197,16 +111,6 @@ RSpec.describe Operations::FeatureFlag do
is_expected.to be_empty
end
end
-
- context 'when the feature flag is inactive and all scopes are active' do
- let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag, active: false) }
-
- it 'does not return the flag' do
- feature_flag.default_scope.update!(active: true)
-
- is_expected.to be_empty
- end
- end
end
describe '.disabled' do
@@ -220,16 +124,6 @@ RSpec.describe Operations::FeatureFlag do
end
end
- context 'when the feature flag is active and all scopes are inactive' do
- let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag, active: true) }
-
- it 'does not return the flag' do
- feature_flag.default_scope.update!(active: false)
-
- is_expected.to be_empty
- end
- end
-
context 'when the feature flag is inactive' do
let!(:feature_flag) { create(:operations_feature_flag, active: false) }
@@ -237,16 +131,6 @@ RSpec.describe Operations::FeatureFlag do
is_expected.to eq([feature_flag])
end
end
-
- context 'when the feature flag is inactive and all scopes are active' do
- let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag, active: false) }
-
- it 'returns the flag' do
- feature_flag.default_scope.update!(active: true)
-
- is_expected.to eq([feature_flag])
- end
- end
end
describe '.for_unleash_client' do
diff --git a/spec/models/packages/package_file_spec.rb b/spec/models/packages/package_file_spec.rb
index 90910fcb7ce..450656e3e9c 100644
--- a/spec/models/packages/package_file_spec.rb
+++ b/spec/models/packages/package_file_spec.rb
@@ -2,6 +2,8 @@
require 'spec_helper'
RSpec.describe Packages::PackageFile, type: :model do
+ using RSpec::Parameterized::TableSyntax
+
let_it_be(:project) { create(:project) }
let_it_be(:package_file1) { create(:package_file, :xml, file_name: 'FooBar') }
let_it_be(:package_file2) { create(:package_file, :xml, file_name: 'ThisIsATest') }
@@ -139,6 +141,71 @@ RSpec.describe Packages::PackageFile, type: :model do
end
end
+ describe '.most_recent!' do
+ it { expect(described_class.most_recent!).to eq(debian_package.package_files.last) }
+ end
+
+ describe '.most_recent_for' do
+ let_it_be(:package1) { create(:npm_package) }
+ let_it_be(:package2) { create(:npm_package) }
+ let_it_be(:package3) { create(:npm_package) }
+ let_it_be(:package4) { create(:npm_package) }
+
+ let_it_be(:package_file2_2) { create(:package_file, :npm, package: package2) }
+
+ let_it_be(:package_file3_2) { create(:package_file, :npm, package: package3) }
+ let_it_be(:package_file3_3) { create(:package_file, :npm, package: package3) }
+
+ let_it_be(:package_file4_2) { create(:package_file, :npm, package: package2) }
+ let_it_be(:package_file4_3) { create(:package_file, :npm, package: package2) }
+ let_it_be(:package_file4_4) { create(:package_file, :npm, package: package2) }
+
+ let(:most_recent_package_file1) { package1.package_files.recent.first }
+ let(:most_recent_package_file2) { package2.package_files.recent.first }
+ let(:most_recent_package_file3) { package3.package_files.recent.first }
+ let(:most_recent_package_file4) { package4.package_files.recent.first }
+
+ subject { described_class.most_recent_for(packages) }
+
+ where(
+ package_input1: [1, nil],
+ package_input2: [2, nil],
+ package_input3: [3, nil],
+ package_input4: [4, nil]
+ )
+
+ with_them do
+ let(:compact_inputs) { [package_input1, package_input2, package_input3, package_input4].compact }
+ let(:packages) do
+ ::Packages::Package.id_in(
+ compact_inputs.map { |pkg_number| public_send("package#{pkg_number}") }
+ .map(&:id)
+ )
+ end
+
+ let(:expected_package_files) { compact_inputs.map { |pkg_number| public_send("most_recent_package_file#{pkg_number}") } }
+
+ it { is_expected.to contain_exactly(*expected_package_files) }
+ end
+
+ context 'extra join and extra where' do
+ let_it_be(:helm_package) { create(:helm_package, without_package_files: true) }
+ let_it_be(:helm_package_file1) { create(:helm_package_file, channel: 'alpha') }
+ let_it_be(:helm_package_file2) { create(:helm_package_file, channel: 'alpha', package: helm_package) }
+ let_it_be(:helm_package_file3) { create(:helm_package_file, channel: 'beta', package: helm_package) }
+ let_it_be(:helm_package_file4) { create(:helm_package_file, channel: 'beta', package: helm_package) }
+
+ let(:extra_join) { :helm_file_metadatum }
+ let(:extra_where) { { packages_helm_file_metadata: { channel: 'alpha' } } }
+
+ subject { described_class.most_recent_for(Packages::Package.id_in(helm_package.id), extra_join: extra_join, extra_where: extra_where) }
+
+ it 'returns the most recent package for the selected channel' do
+ expect(subject).to contain_exactly(helm_package_file2)
+ end
+ 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 4d4d4ad4fa9..99e5769fc1f 100644
--- a/spec/models/packages/package_spec.rb
+++ b/spec/models/packages/package_spec.rb
@@ -1165,4 +1165,47 @@ RSpec.describe Packages::Package, type: :model do
it_behaves_like 'not enqueuing a sync worker job'
end
end
+
+ describe '#create_build_infos!' do
+ let_it_be(:package) { create(:package) }
+ let_it_be(:pipeline) { create(:ci_pipeline) }
+
+ let(:build) { double(pipeline: pipeline) }
+
+ subject { package.create_build_infos!(build) }
+
+ context 'with a valid build' do
+ it 'creates a build info' do
+ expect { subject }.to change { ::Packages::BuildInfo.count }.by(1)
+
+ last_build = ::Packages::BuildInfo.last
+ expect(last_build.package).to eq(package)
+ expect(last_build.pipeline).to eq(pipeline)
+ end
+
+ context 'with an already existing build info' do
+ let_it_be(:build_info) { create(:packages_build_info, package: package, pipeline: pipeline) }
+
+ it 'does not create a build info' do
+ expect { subject }.not_to change { ::Packages::BuildInfo.count }
+ end
+ end
+ end
+
+ context 'with a nil build' do
+ let(:build) { nil }
+
+ it 'does not create a build info' do
+ expect { subject }.not_to change { ::Packages::BuildInfo.count }
+ end
+ end
+
+ context 'with a build without a pipeline' do
+ let(:build) { double(pipeline: nil) }
+
+ it 'does not create a build info' do
+ expect { subject }.not_to change { ::Packages::BuildInfo.count }
+ end
+ end
+ end
end
diff --git a/spec/models/preloaders/commit_status_preloader_spec.rb b/spec/models/preloaders/commit_status_preloader_spec.rb
new file mode 100644
index 00000000000..85ea784335c
--- /dev/null
+++ b/spec/models/preloaders/commit_status_preloader_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Preloaders::CommitStatusPreloader do
+ let_it_be(:pipeline) { create(:ci_pipeline) }
+
+ let_it_be(:build1) { create(:ci_build, :tags, pipeline: pipeline) }
+ let_it_be(:build2) { create(:ci_build, :tags, pipeline: pipeline) }
+ let_it_be(:bridge1) { create(:ci_bridge, pipeline: pipeline) }
+ let_it_be(:bridge2) { create(:ci_bridge, pipeline: pipeline) }
+ let_it_be(:generic_commit_status1) { create(:generic_commit_status, pipeline: pipeline) }
+ let_it_be(:generic_commit_status2) { create(:generic_commit_status, pipeline: pipeline) }
+
+ describe '#execute' do
+ let(:relations) { %i[pipeline metadata tags job_artifacts_archive downstream_pipeline] }
+ let(:statuses) { CommitStatus.where(commit_id: pipeline.id).all }
+
+ subject(:execute) { described_class.new(statuses).execute(relations) }
+
+ it 'prevents N+1 for specified relations', :use_sql_query_cache do
+ execute
+
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ call_each_relation(statuses.sample(3))
+ end
+
+ expect do
+ call_each_relation(statuses)
+ end.to issue_same_number_of_queries_as(control_count)
+ end
+
+ private
+
+ def call_each_relation(statuses)
+ statuses.each do |status|
+ relations.each { |relation| status.public_send(relation) if status.respond_to?(relation) }
+ end
+ end
+ end
+end
diff --git a/spec/models/preloaders/merge_requests_preloader_spec.rb b/spec/models/preloaders/merge_requests_preloader_spec.rb
new file mode 100644
index 00000000000..7108de2e491
--- /dev/null
+++ b/spec/models/preloaders/merge_requests_preloader_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Preloaders::MergeRequestsPreloader do
+ describe '#execute' do
+ let_it_be_with_refind(:merge_requests) { create_list(:merge_request, 3) }
+ let_it_be(:upvotes) { merge_requests.each { |m| create(:award_emoji, :upvote, awardable: m) } }
+
+ it 'does not make n+1 queries' do
+ described_class.new(merge_requests).execute
+
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ # expectations make sure the queries execute
+ merge_requests.each do |m|
+ expect(m.target_project.project_feature).not_to be_nil
+ expect(m.lazy_upvotes_count).to eq(1)
+ end
+ end
+
+ # 1 query for BatchLoader to load all upvotes at once
+ expect(control.count).to eq(1)
+ end
+
+ it 'runs extra queries without preloading' do
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ # expectations make sure the queries execute
+ merge_requests.each do |m|
+ expect(m.target_project.project_feature).not_to be_nil
+ expect(m.lazy_upvotes_count).to eq(1)
+ end
+ end
+
+ # 4 queries per merge request =
+ # 1 to load merge request
+ # 1 to load project
+ # 1 to load project_feature
+ # 1 to load upvotes count
+ expect(control.count).to eq(4 * merge_requests.size)
+ end
+ end
+end
diff --git a/spec/models/preloaders/user_max_access_level_in_groups_preloader_spec.rb b/spec/models/preloaders/user_max_access_level_in_groups_preloader_spec.rb
new file mode 100644
index 00000000000..8144e1ad233
--- /dev/null
+++ b/spec/models/preloaders/user_max_access_level_in_groups_preloader_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Preloaders::UserMaxAccessLevelInGroupsPreloader do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group1) { create(:group, :private).tap { |g| g.add_developer(user) } }
+ let_it_be(:group2) { create(:group, :private).tap { |g| g.add_developer(user) } }
+ let_it_be(:group3) { create(:group, :private) }
+
+ let(:max_query_regex) { /SELECT MAX\("members"\."access_level"\).+/ }
+ let(:groups) { [group1, group2, group3] }
+
+ shared_examples 'executes N max member permission queries to the DB' do
+ it 'executes the specified max membership queries' do
+ queries = ActiveRecord::QueryRecorder.new do
+ groups.each { |group| user.can?(:read_group, group) }
+ end
+
+ max_queries = queries.log.grep(max_query_regex)
+
+ expect(max_queries.count).to eq(expected_query_count)
+ end
+ end
+
+ context 'when the preloader is used', :request_store do
+ before do
+ described_class.new(groups, user).execute
+ end
+
+ it_behaves_like 'executes N max member permission queries to the DB' do
+ # Will query all groups where the user is not already a member
+ let(:expected_query_count) { 1 }
+ end
+
+ context 'when user has access but is not a direct member of the group' do
+ let(:groups) { [group1, group2, group3, create(:group, :private, parent: group1)] }
+
+ it_behaves_like 'executes N max member permission queries to the DB' do
+ # One query for group with no access and another one where the user is not a direct member
+ let(:expected_query_count) { 2 }
+ end
+ end
+ end
+
+ context 'when the preloader is not used', :request_store do
+ it_behaves_like 'executes N max member permission queries to the DB' do
+ let(:expected_query_count) { groups.count }
+ end
+ end
+end
diff --git a/spec/models/project_ci_cd_setting_spec.rb b/spec/models/project_ci_cd_setting_spec.rb
index caab182cda8..406485d8cc8 100644
--- a/spec/models/project_ci_cd_setting_spec.rb
+++ b/spec/models/project_ci_cd_setting_spec.rb
@@ -21,12 +21,6 @@ RSpec.describe ProjectCiCdSetting do
end
end
- describe '#job_token_scope_enabled' do
- it 'is false by default' do
- expect(described_class.new.job_token_scope_enabled).to be_falsey
- end
- end
-
describe '#default_git_depth' do
let(:default_value) { described_class::DEFAULT_GIT_DEPTH }
diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb
index 5f720f8c4f8..75e43ed9a67 100644
--- a/spec/models/project_feature_spec.rb
+++ b/spec/models/project_feature_spec.rb
@@ -41,18 +41,15 @@ RSpec.describe ProjectFeature do
end
end
- context 'public features' do
- features = ProjectFeature::FEATURES - %i(pages)
+ it_behaves_like 'access level validation', ProjectFeature::FEATURES - %i(pages) do
+ let(:container_features) { project.project_feature }
+ end
- features.each do |feature|
- it "does not allow public access level for #{feature}" do
- project_feature = project.project_feature
- field = "#{feature}_access_level".to_sym
- project_feature.update_attribute(field, ProjectFeature::PUBLIC)
+ it 'allows public access level for :pages feature' do
+ project_feature = project.project_feature
+ project_feature.pages_access_level = ProjectFeature::PUBLIC
- expect(project_feature.valid?).to be_falsy, "#{field} failed"
- end
- end
+ expect(project_feature.valid?).to be_truthy
end
describe 'default pages access level' do
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index d8f3a63d221..3989ddc31e8 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -16,6 +16,7 @@ RSpec.describe Project, factory_default: :keep do
describe 'associations' do
it { is_expected.to belong_to(:group) }
it { is_expected.to belong_to(:namespace) }
+ it { is_expected.to belong_to(:project_namespace).class_name('Namespaces::ProjectNamespace').with_foreign_key('project_namespace_id').inverse_of(:project) }
it { is_expected.to belong_to(:creator).class_name('User') }
it { is_expected.to belong_to(:pool_repository) }
it { is_expected.to have_many(:users) }
@@ -137,6 +138,8 @@ RSpec.describe Project, factory_default: :keep do
it { is_expected.to have_many(:timelogs) }
it { is_expected.to have_many(:error_tracking_errors).class_name('ErrorTracking::Error') }
it { is_expected.to have_many(:error_tracking_client_keys).class_name('ErrorTracking::ClientKey') }
+ it { is_expected.to have_many(:pending_builds).class_name('Ci::PendingBuild') }
+ it { is_expected.to have_many(:ci_feature_usages).class_name('Projects::CiFeatureUsage') }
# GitLab Pages
it { is_expected.to have_many(:pages_domains) }
@@ -183,6 +186,20 @@ RSpec.describe Project, factory_default: :keep do
end
end
+ context 'when deleting project' do
+ # using delete rather than destroy due to `delete` skipping AR hooks/callbacks
+ # so it's ensured to work at the DB level. Uses AFTER DELETE trigger.
+ let_it_be(:project) { create(:project) }
+ let_it_be(:project_namespace) { create(:project_namespace, project: project) }
+
+ it 'also deletes the associated ProjectNamespace' do
+ project.delete
+
+ expect { project.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { project_namespace.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
context 'when creating a new project' do
let_it_be(:project) { create(:project) }
@@ -602,6 +619,12 @@ RSpec.describe Project, factory_default: :keep do
end
end
+ describe '#membership_locked?' do
+ it 'returns false' do
+ expect(build(:project)).not_to be_membership_locked
+ end
+ end
+
describe '#autoclose_referenced_issues' do
context 'when DB entry is nil' do
let(:project) { build(:project, autoclose_referenced_issues: nil) }
@@ -1051,12 +1074,12 @@ RSpec.describe Project, factory_default: :keep do
project.open_issues_count(user)
end
- it 'invokes the count service with no current_user' do
- count_service = instance_double(Projects::OpenIssuesCountService)
- expect(Projects::OpenIssuesCountService).to receive(:new).with(project, nil).and_return(count_service)
- expect(count_service).to receive(:count)
+ it 'invokes the batch count service with no current_user' do
+ count_service = instance_double(Projects::BatchOpenIssuesCountService)
+ expect(Projects::BatchOpenIssuesCountService).to receive(:new).with([project]).and_return(count_service)
+ expect(count_service).to receive(:refresh_cache_and_retrieve_data).and_return({})
- project.open_issues_count
+ project.open_issues_count.to_s
end
end
@@ -1257,19 +1280,19 @@ RSpec.describe Project, factory_default: :keep do
end
it 'returns an active external wiki' do
- create(:service, project: project, type: 'ExternalWikiService', active: true)
+ create(:external_wiki_integration, project: project, active: true)
is_expected.to be_kind_of(Integrations::ExternalWiki)
end
it 'does not return an inactive external wiki' do
- create(:service, project: project, type: 'ExternalWikiService', active: false)
+ create(:external_wiki_integration, project: project, active: false)
is_expected.to eq(nil)
end
it 'sets Project#has_external_wiki when it is nil' do
- create(:service, project: project, type: 'ExternalWikiService', active: true)
+ create(:external_wiki_integration, project: project, active: true)
project.update_column(:has_external_wiki, nil)
expect { subject }.to change { project.has_external_wiki }.from(nil).to(true)
@@ -1279,36 +1302,40 @@ RSpec.describe Project, factory_default: :keep do
describe '#has_external_wiki' do
let_it_be(:project) { create(:project) }
- def subject
+ def has_external_wiki
project.reload.has_external_wiki
end
- specify { is_expected.to eq(false) }
+ specify { expect(has_external_wiki).to eq(false) }
- context 'when there is an active external wiki service' do
- let!(:service) do
- create(:service, project: project, type: 'ExternalWikiService', active: true)
+ context 'when there is an active external wiki integration' do
+ let(:active) { true }
+
+ let!(:integration) do
+ create(:external_wiki_integration, project: project, active: active)
end
- specify { is_expected.to eq(true) }
+ specify { expect(has_external_wiki).to eq(true) }
it 'becomes false if the external wiki service is destroyed' do
expect do
- Integration.find(service.id).delete
- end.to change { subject }.to(false)
+ Integration.find(integration.id).delete
+ end.to change { has_external_wiki }.to(false)
end
it 'becomes false if the external wiki service becomes inactive' do
expect do
- service.update_column(:active, false)
- end.to change { subject }.to(false)
+ integration.update_column(:active, false)
+ end.to change { has_external_wiki }.to(false)
end
- end
- it 'is false when external wiki service is not active' do
- create(:service, project: project, type: 'ExternalWikiService', active: false)
+ context 'when created as inactive' do
+ let(:active) { false }
- is_expected.to eq(false)
+ it 'is false' do
+ expect(has_external_wiki).to eq(false)
+ end
+ end
end
end
@@ -2536,7 +2563,7 @@ RSpec.describe Project, factory_default: :keep do
end
describe '#uses_default_ci_config?' do
- let(:project) { build(:project)}
+ let(:project) { build(:project) }
it 'has a custom ci config path' do
project.ci_config_path = 'something_custom'
@@ -2557,6 +2584,44 @@ RSpec.describe Project, factory_default: :keep do
end
end
+ describe '#uses_external_project_ci_config?' do
+ subject(:uses_external_project_ci_config) { project.uses_external_project_ci_config? }
+
+ let(:project) { build(:project) }
+
+ context 'when ci_config_path is configured with external project' do
+ before do
+ project.ci_config_path = '.gitlab-ci.yml@hello/world'
+ end
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when ci_config_path is nil' do
+ before do
+ project.ci_config_path = nil
+ end
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when ci_config_path is configured with a file in the project' do
+ before do
+ project.ci_config_path = 'hello/world/gitlab-ci.yml'
+ end
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when ci_config_path is configured with remote file' do
+ before do
+ project.ci_config_path = 'https://example.org/file.yml'
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
describe '#latest_successful_build_for_ref' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:pipeline) { create_pipeline(project) }
@@ -3260,6 +3325,16 @@ RSpec.describe Project, factory_default: :keep do
end
end
+ describe '#after_change_head_branch_does_not_exist' do
+ let_it_be(:project) { create(:project) }
+
+ it 'adds an error to container if branch does not exist' do
+ expect do
+ project.after_change_head_branch_does_not_exist('unexisted-branch')
+ end.to change { project.errors.size }.from(0).to(1)
+ end
+ end
+
describe '#lfs_objects_for_repository_types' do
let(:project) { create(:project) }
@@ -4496,44 +4571,6 @@ RSpec.describe Project, factory_default: :keep do
end
end
- describe '#legacy_remove_pages' do
- let(:project) { create(:project).tap { |project| project.mark_pages_as_deployed } }
- let(:pages_metadatum) { project.pages_metadatum }
- let(:namespace) { project.namespace }
- let(:pages_path) { project.pages_path }
-
- around do |example|
- FileUtils.mkdir_p(pages_path)
- begin
- example.run
- ensure
- FileUtils.rm_rf(pages_path)
- end
- end
-
- it 'removes the pages directory and marks the project as not having pages deployed' do
- expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project).and_return(true)
- expect(PagesWorker).to receive(:perform_in).with(5.minutes, :remove, namespace.full_path, anything)
-
- expect { project.legacy_remove_pages }.to change { pages_metadatum.reload.deployed }.from(true).to(false)
- end
-
- it 'does nothing if updates on legacy storage are disabled' do
- allow(Settings.pages.local_store).to receive(:enabled).and_return(false)
-
- expect(Gitlab::PagesTransfer).not_to receive(:new)
- expect(PagesWorker).not_to receive(:perform_in)
-
- project.legacy_remove_pages
- end
-
- it 'is run when the project is destroyed' do
- expect(project).to receive(:legacy_remove_pages).and_call_original
-
- expect { project.destroy! }.not_to raise_error
- end
- end
-
describe '#remove_export' do
let(:project) { create(:project, :with_export) }
@@ -7037,6 +7074,15 @@ RSpec.describe Project, factory_default: :keep do
end
end
+ describe '#ci_config_external_project' do
+ subject(:ci_config_external_project) { project.ci_config_external_project }
+
+ let(:other_project) { create(:project) }
+ let(:project) { build(:project, ci_config_path: ".gitlab-ci.yml@#{other_project.full_path}") }
+
+ it { is_expected.to eq(other_project) }
+ end
+
describe '#enabled_group_deploy_keys' do
let_it_be(:project) { create(:project) }
@@ -7131,15 +7177,96 @@ RSpec.describe Project, factory_default: :keep do
end
describe 'topics' do
- let_it_be(:project) { create(:project, topic_list: 'topic1, topic2, topic3') }
+ let_it_be(:project) { create(:project, name: 'topic-project', topic_list: 'topic1, topic2, topic3') }
it 'topic_list returns correct string array' do
- expect(project.topic_list).to match_array(%w[topic1 topic2 topic3])
+ expect(project.topic_list).to eq(%w[topic1 topic2 topic3])
+ end
+
+ it 'topics returns correct topic records' do
+ expect(project.topics.first.class.name).to eq('Projects::Topic')
+ expect(project.topics.map(&:name)).to eq(%w[topic1 topic2 topic3])
+ end
+
+ context 'topic_list=' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:topic_list, :expected_result) do
+ ['topicA', 'topicB'] | %w[topicA topicB] # rubocop:disable Style/WordArray, Lint/BinaryOperatorWithIdenticalOperands
+ ['topicB', 'topicA'] | %w[topicB topicA] # rubocop:disable Style/WordArray, Lint/BinaryOperatorWithIdenticalOperands
+ [' topicC ', ' topicD '] | %w[topicC topicD]
+ ['topicE', 'topicF', 'topicE'] | %w[topicE topicF] # rubocop:disable Style/WordArray
+ ['topicE ', 'topicF', ' topicE'] | %w[topicE topicF]
+ 'topicA, topicB' | %w[topicA topicB]
+ 'topicB, topicA' | %w[topicB topicA]
+ ' topicC , topicD ' | %w[topicC topicD]
+ 'topicE, topicF, topicE' | %w[topicE topicF]
+ 'topicE , topicF, topicE' | %w[topicE topicF]
+ end
+
+ with_them do
+ it 'set topics' do
+ project.topic_list = topic_list
+ project.save!
+
+ expect(project.topics.map(&:name)).to eq(expected_result)
+ end
+ end
+
+ it 'set topics if only the order is changed' do
+ project.topic_list = 'topicA, topicB'
+ project.save!
+
+ expect(project.reload.topics.map(&:name)).to eq(%w[topicA topicB])
+
+ project.topic_list = 'topicB, topicA'
+ project.save!
+
+ expect(project.reload.topics.map(&:name)).to eq(%w[topicB topicA])
+ end
+
+ it 'does not persist topics before project is saved' do
+ project.topic_list = 'topicA, topicB'
+
+ expect(project.reload.topics.map(&:name)).to eq(%w[topic1 topic2 topic3])
+ end
+
+ it 'does not update topics if project is not valid' do
+ project.name = nil
+ project.topic_list = 'topicA, topicB'
+
+ expect(project.save).to be_falsy
+ expect(project.reload.topics.map(&:name)).to eq(%w[topic1 topic2 topic3])
+ end
end
- it 'topics returns correct tag records' do
- expect(project.topics.first.class.name).to eq('ActsAsTaggableOn::Tag')
- expect(project.topics.map(&:name)).to match_array(%w[topic1 topic2 topic3])
+ context 'during ExtractProjectTopicsIntoSeparateTable migration' do
+ before do
+ topic_a = ActsAsTaggableOn::Tag.find_or_create_by!(name: 'topicA')
+ topic_b = ActsAsTaggableOn::Tag.find_or_create_by!(name: 'topicB')
+
+ project.reload.topics_acts_as_taggable = [topic_a, topic_b]
+ project.save!
+ project.reload
+ end
+
+ it 'topic_list returns correct string array' do
+ expect(project.topic_list).to eq(%w[topicA topicB topic1 topic2 topic3])
+ end
+
+ it 'topics returns correct topic records' do
+ expect(project.topics.map(&:class)).to eq([ActsAsTaggableOn::Tag, ActsAsTaggableOn::Tag, Projects::Topic, Projects::Topic, Projects::Topic])
+ expect(project.topics.map(&:name)).to eq(%w[topicA topicB topic1 topic2 topic3])
+ end
+
+ it 'topic_list= sets new topics and removes old topics' do
+ project.topic_list = 'new-topic1, new-topic2'
+ project.save!
+ project.reload
+
+ expect(project.topics.map(&:class)).to eq([Projects::Topic, Projects::Topic])
+ expect(project.topics.map(&:name)).to eq(%w[new-topic1 new-topic2])
+ end
end
end
diff --git a/spec/models/projects/project_topic_spec.rb b/spec/models/projects/project_topic_spec.rb
new file mode 100644
index 00000000000..c7a989040c7
--- /dev/null
+++ b/spec/models/projects/project_topic_spec.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::ProjectTopic do
+ let_it_be(:project_topic, reload: true) { create(:project_topic) }
+
+ subject { project_topic }
+
+ it { expect(subject).to be_valid }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:project) }
+ it { is_expected.to belong_to(:topic) }
+ end
+end
diff --git a/spec/models/projects/topic_spec.rb b/spec/models/projects/topic_spec.rb
new file mode 100644
index 00000000000..409dc932709
--- /dev/null
+++ b/spec/models/projects/topic_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::Topic do
+ let_it_be(:topic, reload: true) { create(:topic) }
+
+ subject { topic }
+
+ it { expect(subject).to be_valid }
+
+ describe 'associations' do
+ it { is_expected.to have_many(:project_topics) }
+ it { is_expected.to have_many(:projects) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:name) }
+ it { is_expected.to validate_uniqueness_of(:name) }
+ it { is_expected.to validate_length_of(:name).is_at_most(255) }
+ end
+end
diff --git a/spec/models/protected_branch_spec.rb b/spec/models/protected_branch_spec.rb
index a173ab48f17..019c01af672 100644
--- a/spec/models/protected_branch_spec.rb
+++ b/spec/models/protected_branch_spec.rb
@@ -162,6 +162,30 @@ RSpec.describe ProtectedBranch do
expect(described_class.protected?(project, 'staging/some-branch')).to eq(false)
end
+
+ context 'with caching', :use_clean_rails_memory_store_caching do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:protected_branch) { create(:protected_branch, project: project, name: "jawn") }
+
+ before do
+ allow(described_class).to receive(:matching).once.and_call_original
+ # the original call works and warms the cache
+ described_class.protected?(project, 'jawn')
+ end
+
+ it 'correctly invalidates a cache' do
+ expect(described_class).to receive(:matching).once.and_call_original
+
+ create(:protected_branch, project: project, name: "bar")
+ # the cache is invalidated because the project has been "updated"
+ expect(described_class.protected?(project, 'jawn')).to eq(true)
+ end
+
+ it 'correctly uses the cached version' do
+ expect(described_class).not_to receive(:matching)
+ expect(described_class.protected?(project, 'jawn')).to eq(true)
+ end
+ end
end
context 'new project' do
diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb
index 211e448b6cf..dc55214c1dd 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -68,51 +68,69 @@ RSpec.describe Repository do
describe 'tags_sorted_by' do
let(:tags_to_compare) { %w[v1.0.0 v1.1.0] }
+ let(:feature_flag) { true }
+
+ before do
+ stub_feature_flags(gitaly_tags_finder: feature_flag)
+ end
context 'name_desc' do
subject { repository.tags_sorted_by('name_desc').map(&:name) & tags_to_compare }
it { is_expected.to eq(['v1.1.0', 'v1.0.0']) }
+
+ context 'when feature flag is disabled' do
+ let(:feature_flag) { false }
+
+ it { is_expected.to eq(['v1.1.0', 'v1.0.0']) }
+ end
end
context 'name_asc' do
subject { repository.tags_sorted_by('name_asc').map(&:name) & tags_to_compare }
it { is_expected.to eq(['v1.0.0', 'v1.1.0']) }
+
+ context 'when feature flag is disabled' do
+ let(:feature_flag) { false }
+
+ it { is_expected.to eq(['v1.0.0', 'v1.1.0']) }
+ end
end
context 'updated' do
- let(:tag_a) { repository.find_tag('v1.0.0') }
- let(:tag_b) { repository.find_tag('v1.1.0') }
+ let(:latest_tag) { 'v0.0.0' }
+
+ before do
+ rugged_repo(repository).tags.create(latest_tag, repository.commit.id)
+ end
+
+ after do
+ rugged_repo(repository).tags.delete(latest_tag)
+ end
context 'desc' do
- subject { repository.tags_sorted_by('updated_desc').map(&:name) }
+ subject { repository.tags_sorted_by('updated_desc').map(&:name) & (tags_to_compare + [latest_tag]) }
- before do
- double_first = double(committed_date: Time.current)
- double_last = double(committed_date: Time.current - 1.second)
+ it { is_expected.to eq([latest_tag, 'v1.1.0', 'v1.0.0']) }
- allow(tag_a).to receive(:dereferenced_target).and_return(double_first)
- allow(tag_b).to receive(:dereferenced_target).and_return(double_last)
- allow(repository).to receive(:tags).and_return([tag_a, tag_b])
- end
+ context 'when feature flag is disabled' do
+ let(:feature_flag) { false }
- it { is_expected.to eq(['v1.0.0', 'v1.1.0']) }
+ it { is_expected.to eq([latest_tag, 'v1.1.0', 'v1.0.0']) }
+ end
end
context 'asc' do
- subject { repository.tags_sorted_by('updated_asc').map(&:name) }
+ subject { repository.tags_sorted_by('updated_asc').map(&:name) & (tags_to_compare + [latest_tag]) }
- before do
- double_first = double(committed_date: Time.current - 1.second)
- double_last = double(committed_date: Time.current)
+ it { is_expected.to eq(['v1.0.0', 'v1.1.0', latest_tag]) }
- allow(tag_a).to receive(:dereferenced_target).and_return(double_last)
- allow(tag_b).to receive(:dereferenced_target).and_return(double_first)
- allow(repository).to receive(:tags).and_return([tag_a, tag_b])
- end
+ context 'when feature flag is disabled' do
+ let(:feature_flag) { false }
- it { is_expected.to eq(['v1.1.0', 'v1.0.0']) }
+ it { is_expected.to eq(['v1.0.0', 'v1.1.0', latest_tag]) }
+ end
end
context 'annotated tag pointing to a blob' do
@@ -125,29 +143,32 @@ RSpec.describe Repository do
tagger: { name: 'John Smith', email: 'john@gmail.com' } }
rugged_repo(repository).tags.create(annotated_tag_name, 'a48e4fc218069f68ef2e769dd8dfea3991362175', **options)
+ end
- double_first = double(committed_date: Time.current - 1.second)
- double_last = double(committed_date: Time.current)
+ it { is_expected.to eq(['v1.0.0', 'v1.1.0', annotated_tag_name]) }
- allow(tag_a).to receive(:dereferenced_target).and_return(double_last)
- allow(tag_b).to receive(:dereferenced_target).and_return(double_first)
- end
+ context 'when feature flag is disabled' do
+ let(:feature_flag) { false }
- it { is_expected.to eq(['v1.1.0', 'v1.0.0', annotated_tag_name]) }
+ it { is_expected.to eq(['v1.0.0', 'v1.1.0', annotated_tag_name]) }
+ end
after do
rugged_repo(repository).tags.delete(annotated_tag_name)
end
end
end
- end
- describe '#ref_name_for_sha' do
- it 'returns the ref' do
- allow(repository.raw_repository).to receive(:ref_name_for_sha)
- .and_return('refs/environments/production/77')
+ context 'unknown option' do
+ subject { repository.tags_sorted_by('unknown_desc').map(&:name) & tags_to_compare }
+
+ it { is_expected.to eq(['v1.0.0', 'v1.1.0']) }
- expect(repository.ref_name_for_sha('bla', '0' * 40)).to eq 'refs/environments/production/77'
+ context 'when feature flag is disabled' do
+ let(:feature_flag) { false }
+
+ it { is_expected.to eq(['v1.0.0', 'v1.1.0']) }
+ end
end
end
@@ -479,6 +500,29 @@ RSpec.describe Repository do
end
end
+ describe '#commits_between' do
+ let(:commit) { project.commit }
+
+ it 'delegates to Gitlab::Git::Commit#between, returning decorated commits' do
+ expect(Gitlab::Git::Commit)
+ .to receive(:between)
+ .with(repository.raw_repository, commit.parent_id, commit.id, limit: 5)
+ .and_call_original
+
+ result = repository.commits_between(commit.parent_id, commit.id, limit: 5)
+
+ expect(result).to contain_exactly(instance_of(Commit), instance_of(Commit))
+ end
+
+ it 'defaults to no limit' do
+ expect(Gitlab::Git::Commit)
+ .to receive(:between)
+ .with(repository.raw_repository, commit.parent_id, commit.id, limit: nil)
+
+ repository.commits_between(commit.parent_id, commit.id)
+ end
+ end
+
describe '#find_commits_by_message' do
it 'returns commits with messages containing a given string' do
commit_ids = repository.find_commits_by_message('submodule').map(&:id)
@@ -1294,6 +1338,15 @@ RSpec.describe Repository do
expect(repository.license).to be_nil
end
+ it 'returns nil when license_key is not recognized' do
+ expect(repository).to receive(:license_key).twice.and_return('not-recognized')
+ expect(Gitlab::ErrorTracking).to receive(:track_exception) do |ex|
+ expect(ex).to be_a(Licensee::InvalidLicense)
+ end
+
+ expect(repository.license).to be_nil
+ end
+
it 'returns other when the content is not recognizable' do
license = Licensee::License.new('other')
repository.create_file(user, 'LICENSE', 'Gitlab B.V.',
@@ -1773,7 +1826,7 @@ RSpec.describe Repository do
expect(merge_commit.message).to eq('Custom message')
expect(merge_commit.author_name).to eq(user.name)
- expect(merge_commit.author_email).to eq(user.commit_email)
+ expect(merge_commit.author_email).to eq(user.commit_email_or_default)
expect(repository.blob_at(merge_commit.id, 'files/ruby/feature.rb')).to be_present
end
end
@@ -2313,6 +2366,42 @@ RSpec.describe Repository do
end
end
+ describe '#find_tag' do
+ before do
+ allow(Gitlab::GitalyClient).to receive(:call).and_call_original
+ end
+
+ it 'finds a tag with specified name by performing FindTag request' do
+ expect(Gitlab::GitalyClient)
+ .to receive(:call).with(anything, :ref_service, :find_tag, anything, anything).and_call_original
+
+ expect(repository.find_tag('v1.1.0').name).to eq('v1.1.0')
+ end
+
+ it 'does not perform Gitaly call when tags are preloaded' do
+ repository.tags
+
+ expect(Gitlab::GitalyClient).not_to receive(:call)
+
+ expect(repository.find_tag('v1.1.0').name).to eq('v1.1.0')
+ end
+
+ it 'returns nil when tag does not exists' do
+ expect(repository.find_tag('does-not-exist')).to be_nil
+ end
+
+ context 'when find_tag_via_gitaly is disabled' do
+ it 'fetches all tags' do
+ stub_feature_flags(find_tag_via_gitaly: false)
+
+ expect(Gitlab::GitalyClient)
+ .to receive(:call).with(anything, :ref_service, :find_all_tags, anything, anything).and_call_original
+
+ expect(repository.find_tag('v1.1.0').name).to eq('v1.1.0')
+ end
+ end
+ end
+
describe '#avatar' do
it 'returns nil if repo does not exist' do
allow(repository).to receive(:root_ref).and_raise(Gitlab::Git::Repository::NoRepository)
@@ -3230,26 +3319,54 @@ RSpec.describe Repository do
describe '#change_head' do
let(:branch) { repository.container.default_branch }
- it 'adds an error to container if branch does not exist' do
- expect(repository.change_head('unexisted-branch')).to be false
- expect(repository.container.errors.size).to eq(1)
- end
+ context 'when the branch exists' do
+ it 'returns truthy' do
+ expect(repository.change_head(branch)).to be_truthy
+ end
- it 'calls the before_change_head and after_change_head methods' do
- expect(repository).to receive(:before_change_head)
- expect(repository).to receive(:after_change_head)
+ it 'does not call container.after_change_head_branch_does_not_exist' do
+ expect(repository.container).not_to receive(:after_change_head_branch_does_not_exist)
- repository.change_head(branch)
- end
+ repository.change_head(branch)
+ end
+
+ it 'calls repository hooks' do
+ expect(repository).to receive(:before_change_head)
+ expect(repository).to receive(:after_change_head)
- it 'copies the gitattributes' do
- expect(repository).to receive(:copy_gitattributes).with(branch)
- repository.change_head(branch)
+ repository.change_head(branch)
+ end
+
+ it 'copies the gitattributes' do
+ expect(repository).to receive(:copy_gitattributes).with(branch)
+ repository.change_head(branch)
+ end
+
+ it 'reloads the default branch' do
+ expect(repository.container).to receive(:reload_default_branch)
+ repository.change_head(branch)
+ end
end
- it 'reloads the default branch' do
- expect(repository.container).to receive(:reload_default_branch)
- repository.change_head(branch)
+ context 'when the branch does not exist' do
+ let(:branch) { 'non-existent-branch' }
+
+ it 'returns falsey' do
+ expect(repository.change_head(branch)).to be_falsey
+ end
+
+ it 'calls container.after_change_head_branch_does_not_exist' do
+ expect(repository.container).to receive(:after_change_head_branch_does_not_exist).with(branch)
+
+ repository.change_head(branch)
+ end
+
+ it 'does not call repository hooks' do
+ expect(repository).not_to receive(:before_change_head)
+ expect(repository).not_to receive(:after_change_head)
+
+ repository.change_head(branch)
+ end
end
end
end
diff --git a/spec/models/shard_spec.rb b/spec/models/shard_spec.rb
index a9d11f4290c..38729fa1758 100644
--- a/spec/models/shard_spec.rb
+++ b/spec/models/shard_spec.rb
@@ -33,19 +33,21 @@ RSpec.describe Shard do
expect(result.name).to eq('foo')
end
- it 'retries if creation races' do
+ it 'returns existing record if creation races' do
+ shard_created_by_others = double(described_class)
+
expect(described_class)
- .to receive(:find_or_create_by)
- .with(name: 'default')
- .and_raise(ActiveRecord::RecordNotUnique, 'fail')
- .once
+ .to receive(:find_by)
+ .with(name: 'new_shard')
+ .and_return(nil, shard_created_by_others)
expect(described_class)
- .to receive(:find_or_create_by)
- .with(name: 'default')
- .and_call_original
+ .to receive(:create)
+ .with(name: 'new_shard')
+ .and_raise(ActiveRecord::RecordNotUnique, 'fail')
+ .once
- expect(described_class.by_name('default')).to eq(default_shard)
+ expect(described_class.by_name('new_shard')).to eq(shard_created_by_others)
end
end
end
diff --git a/spec/models/user_callout_spec.rb b/spec/models/user_callout_spec.rb
index eb66f074293..5b36c8450ea 100644
--- a/spec/models/user_callout_spec.rb
+++ b/spec/models/user_callout_spec.rb
@@ -3,29 +3,12 @@
require 'spec_helper'
RSpec.describe UserCallout do
- let!(:callout) { create(:user_callout) }
+ let_it_be(:callout) { create(:user_callout) }
it_behaves_like 'having unique enum values'
- describe 'relationships' do
- it { is_expected.to belong_to(:user) }
- end
-
describe 'validations' do
- it { is_expected.to validate_presence_of(:user) }
-
it { is_expected.to validate_presence_of(:feature_name) }
it { is_expected.to validate_uniqueness_of(:feature_name).scoped_to(:user_id).ignoring_case_sensitivity }
end
-
- describe '#dismissed_after?' do
- let(:some_feature_name) { described_class.feature_names.keys.second }
- let(:callout_dismissed_month_ago) { create(:user_callout, feature_name: some_feature_name, dismissed_at: 1.month.ago )}
- let(:callout_dismissed_day_ago) { create(:user_callout, feature_name: some_feature_name, dismissed_at: 1.day.ago )}
-
- it 'returns whether a callout dismissed after specified date' do
- expect(callout_dismissed_month_ago.dismissed_after?(15.days.ago)).to eq(false)
- expect(callout_dismissed_day_ago.dismissed_after?(15.days.ago)).to eq(true)
- end
- end
end
diff --git a/spec/models/user_detail_spec.rb b/spec/models/user_detail_spec.rb
index 3c87dcdcbd9..ba7ea3f7ce2 100644
--- a/spec/models/user_detail_spec.rb
+++ b/spec/models/user_detail_spec.rb
@@ -25,29 +25,4 @@ RSpec.describe UserDetail do
it { is_expected.to validate_length_of(:bio).is_at_most(255) }
end
end
-
- describe '#bio_html' do
- let(:user) { create(:user, bio: 'some **bio**') }
-
- subject { user.user_detail.bio_html }
-
- it 'falls back to #bio when the html representation is missing' do
- user.user_detail.update!(bio_html: nil)
-
- expect(subject).to eq(user.user_detail.bio)
- end
-
- it 'stores rendered html' do
- expect(subject).to include('some <strong>bio</strong>')
- end
-
- it 'does not try to set the value when the column is not there' do
- without_bio_html_column = UserDetail.column_names - ['bio_html']
-
- expect(described_class).to receive(:column_names).at_least(:once).and_return(without_bio_html_column)
- expect(user.user_detail).not_to receive(:bio_html=)
-
- subject
- end
- end
end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index d73bc95a2f2..334f9b4ae30 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -65,9 +65,6 @@ RSpec.describe User do
it { is_expected.to delegate_method(:render_whitespace_in_code).to(:user_preference) }
it { is_expected.to delegate_method(:render_whitespace_in_code=).to(:user_preference).with_arguments(:args) }
- 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) }
@@ -82,7 +79,6 @@ RSpec.describe User do
it { is_expected.to delegate_method(:bio).to(:user_detail).allow_nil }
it { is_expected.to delegate_method(:bio=).to(:user_detail).with_arguments(:args).allow_nil }
- it { is_expected.to delegate_method(:bio_html).to(:user_detail).allow_nil }
end
describe 'associations' do
@@ -110,7 +106,6 @@ RSpec.describe User do
it { is_expected.to have_many(:spam_logs).dependent(:destroy) }
it { is_expected.to have_many(:todos) }
it { is_expected.to have_many(:award_emoji).dependent(:destroy) }
- it { is_expected.to have_many(:triggers).dependent(:destroy) }
it { is_expected.to have_many(:builds).dependent(:nullify) }
it { is_expected.to have_many(:pipelines).dependent(:nullify) }
it { is_expected.to have_many(:chat_names).dependent(:destroy) }
@@ -125,6 +120,8 @@ RSpec.describe User do
it { is_expected.to have_many(:created_custom_emoji).inverse_of(:creator) }
it { is_expected.to have_many(:in_product_marketing_emails) }
it { is_expected.to have_many(:timelogs) }
+ it { is_expected.to have_many(:callouts).class_name('UserCallout') }
+ it { is_expected.to have_many(:group_callouts).class_name('Users::GroupCallout') }
describe "#user_detail" do
it 'does not persist `user_detail` by default' do
@@ -404,7 +401,7 @@ RSpec.describe User do
user = build(:user, username: "test.#{type}")
expect(user).not_to be_valid
- expect(user.errors.full_messages).to include('Username ending with MIME type format is not allowed.')
+ expect(user.errors.full_messages).to include('Username ending with a file extension is not allowed.')
expect(build(:user, username: "test#{type}")).to be_valid
end
end
@@ -438,12 +435,12 @@ RSpec.describe User do
subject { create(:user).tap { |user| user.emails << build(:email, email: email_value, confirmed_at: Time.current) } }
end
- describe '#commit_email' do
+ describe '#commit_email_or_default' do
subject(:user) { create(:user) }
it 'defaults to the primary email' do
expect(user.email).to be_present
- expect(user.commit_email).to eq(user.email)
+ expect(user.commit_email_or_default).to eq(user.email)
end
it 'defaults to the primary email when the column in the database is null' do
@@ -451,38 +448,37 @@ RSpec.describe User do
found_user = described_class.find_by(id: user.id)
- expect(found_user.commit_email).to eq(user.email)
+ expect(found_user.commit_email_or_default).to eq(user.email)
end
it 'returns the private commit email when commit_email has _private' do
user.update_column(:commit_email, Gitlab::PrivateCommitEmail::TOKEN)
- expect(user.commit_email).to eq(user.private_commit_email)
+ expect(user.commit_email_or_default).to eq(user.private_commit_email)
end
+ end
+
+ describe '#commit_email=' do
+ subject(:user) { create(:user) }
it 'can be set to a confirmed email' do
confirmed = create(:email, :confirmed, user: user)
user.commit_email = confirmed.email
expect(user).to be_valid
- expect(user.commit_email).to eq(confirmed.email)
end
it 'can not be set to an unconfirmed email' do
unconfirmed = create(:email, user: user)
user.commit_email = unconfirmed.email
- # This should set the commit_email attribute to the primary email
- expect(user).to be_valid
- expect(user.commit_email).to eq(user.email)
+ expect(user).not_to be_valid
end
it 'can not be set to a non-existent email' do
user.commit_email = 'non-existent-email@nonexistent.nonexistent'
- # This should set the commit_email attribute to the primary email
- expect(user).to be_valid
- expect(user.commit_email).to eq(user.email)
+ expect(user).not_to be_valid
end
it 'can not be set to an invalid email, even if confirmed' do
@@ -692,70 +688,26 @@ RSpec.describe User do
end
end
- context 'owns_notification_email' do
- it 'accepts temp_oauth_email emails' do
- user = build(:user, email: "temp-email-for-oauth@example.com")
- expect(user).to be_valid
- end
-
- it 'does not accept not verified emails' do
- email = create(:email)
- user = email.user
- user.notification_email = email.email
+ context 'when secondary email is same as primary' do
+ let(:user) { create(:user, email: 'user@example.com') }
- expect(user).to be_invalid
- expect(user.errors[:notification_email]).to include(_('must be an email you have verified'))
- end
- end
+ it 'lets user change primary email without failing validations' do
+ user.commit_email = user.email
+ user.notification_email = user.email
+ user.public_email = user.email
+ user.save!
- context 'owns_public_email' do
- it 'accepts verified emails' do
- email = create(:email, :confirmed, email: 'test@test.com')
- user = email.user
- user.notification_email = email.email
+ user.email = 'newemail@example.com'
+ user.confirm
expect(user).to be_valid
end
-
- it 'does not accept not verified emails' do
- email = create(:email)
- user = email.user
- user.public_email = email.email
-
- expect(user).to be_invalid
- expect(user.errors[:public_email]).to include(_('must be an email you have verified'))
- end
end
- context 'set_commit_email' do
- it 'keeps commit email when private commit email is being used' do
- user = create(:user, commit_email: Gitlab::PrivateCommitEmail::TOKEN)
-
- expect(user.read_attribute(:commit_email)).to eq(Gitlab::PrivateCommitEmail::TOKEN)
- end
-
- it 'keeps the commit email when nil' do
- user = create(:user, commit_email: nil)
-
- expect(user.read_attribute(:commit_email)).to be_nil
- end
-
- it 'reverts to nil when email is not verified' do
- user = create(:user, commit_email: "foo@bar.com")
-
- expect(user.read_attribute(:commit_email)).to be_nil
- end
- end
-
- context 'owns_commit_email' do
- it 'accepts private commit email' do
- user = build(:user, commit_email: Gitlab::PrivateCommitEmail::TOKEN)
-
- expect(user).to be_valid
- end
-
- it 'accepts nil commit email' do
- user = build(:user, commit_email: nil)
+ context 'when commit_email is changed to _private' do
+ it 'passes user validations' do
+ user = create(:user)
+ user.commit_email = '_private'
expect(user).to be_valid
end
@@ -931,12 +883,8 @@ RSpec.describe User do
end
context 'maximum value' do
- before do
- allow(Devise.password_length).to receive(:max).and_return(201)
- end
-
it 'is determined by the current value of `Devise.password_length.max`' do
- expect(password_length.max).to eq(201)
+ expect(password_length.max).to eq(Devise.password_length.max)
end
end
end
@@ -1311,53 +1259,57 @@ RSpec.describe User do
end
end
- describe '#update_notification_email' do
- # Regression: https://gitlab.com/gitlab-org/gitlab-foss/issues/22846
- context 'when changing :email' do
- let(:user) { create(:user) }
- let(:new_email) { 'new-email@example.com' }
+ describe 'when changing email' do
+ let(:user) { create(:user) }
+ let(:new_email) { 'new-email@example.com' }
+ context 'if notification_email was nil' do
it 'sets :unconfirmed_email' do
expect do
user.tap { |u| u.update!(email: new_email) }.reload
end.to change(user, :unconfirmed_email).to(new_email)
end
- it 'does not change :notification_email' do
+
+ it 'does not change notification_email or notification_email_or_default before email is confirmed' do
expect do
user.tap { |u| u.update!(email: new_email) }.reload
- end.not_to change(user, :notification_email)
+ end.not_to change(user, :notification_email_or_default)
+
+ expect(user.notification_email).to be_nil
end
- it 'updates :notification_email to the new email once confirmed' do
+ it 'updates notification_email_or_default to the new email once confirmed' do
user.update!(email: new_email)
expect do
user.tap(&:confirm).reload
- end.to change(user, :notification_email).to eq(new_email)
+ end.to change(user, :notification_email_or_default).to eq(new_email)
+
+ expect(user.notification_email).to be_nil
end
+ end
- context 'and :notification_email is set to a secondary email' do
- let!(:email_attrs) { attributes_for(:email, :confirmed, user: user) }
- let(:secondary) { create(:email, :confirmed, email: 'secondary@example.com', user: user) }
+ context 'when notification_email is set to a secondary email' do
+ let!(:email_attrs) { attributes_for(:email, :confirmed, user: user) }
+ let(:secondary) { create(:email, :confirmed, email: 'secondary@example.com', user: user) }
- before do
- user.emails.create!(email_attrs)
- user.tap { |u| u.update!(notification_email: email_attrs[:email]) }.reload
- end
+ before do
+ user.emails.create!(email_attrs)
+ user.tap { |u| u.update!(notification_email: email_attrs[:email]) }.reload
+ end
- it 'does not change :notification_email to :email' do
- expect do
- user.tap { |u| u.update!(email: new_email) }.reload
- end.not_to change(user, :notification_email)
- end
+ it 'does not change notification_email to email before email is confirmed' do
+ expect do
+ user.tap { |u| u.update!(email: new_email) }.reload
+ end.not_to change(user, :notification_email)
+ end
- it 'does not change :notification_email to :email once confirmed' do
- user.update!(email: new_email)
+ it 'does not change notification_email to email once confirmed' do
+ user.update!(email: new_email)
- expect do
- user.tap(&:confirm).reload
- end.not_to change(user, :notification_email)
- end
+ expect do
+ user.tap(&:confirm).reload
+ end.not_to change(user, :notification_email)
end
end
end
@@ -1833,14 +1785,26 @@ RSpec.describe User do
end
describe '#manageable_groups' do
- it 'includes all the namespaces the user can manage' do
- expect(user.manageable_groups).to contain_exactly(group, subgroup)
+ shared_examples 'manageable groups examples' do
+ it 'includes all the namespaces the user can manage' do
+ expect(user.manageable_groups).to contain_exactly(group, subgroup)
+ end
+
+ it 'does not include duplicates if a membership was added for the subgroup' do
+ subgroup.add_owner(user)
+
+ expect(user.manageable_groups).to contain_exactly(group, subgroup)
+ end
end
- it 'does not include duplicates if a membership was added for the subgroup' do
- subgroup.add_owner(user)
+ it_behaves_like 'manageable groups examples'
+
+ context 'when feature flag :linear_user_manageable_groups is disabled' do
+ before do
+ stub_feature_flags(linear_user_manageable_groups: false)
+ end
- expect(user.manageable_groups).to contain_exactly(group, subgroup)
+ it_behaves_like 'manageable groups examples'
end
end
@@ -1925,12 +1889,25 @@ RSpec.describe User do
expect(user.deactivated?).to be_truthy
end
- it 'sends deactivated user an email' do
- expect_next_instance_of(NotificationService) do |notification|
- allow(notification).to receive(:user_deactivated).with(user.name, user.notification_email)
+ context "when user deactivation emails are disabled" do
+ before do
+ stub_application_setting(user_deactivation_emails_enabled: false)
end
+ it 'does not send deactivated user an email' do
+ expect(NotificationService).not_to receive(:new)
- user.deactivate
+ user.deactivate
+ end
+ end
+
+ context "when user deactivation emails are enabled" do
+ it 'sends deactivated user an email' do
+ expect_next_instance_of(NotificationService) do |notification|
+ allow(notification).to receive(:user_deactivated).with(user.name, user.notification_email_or_default)
+ end
+
+ user.deactivate
+ end
end
end
@@ -1997,15 +1974,15 @@ RSpec.describe User do
user.ban!
end
- it 'activates the user' do
- user.activate
+ it 'unbans the user' do
+ user.unban
expect(user.banned?).to eq(false)
expect(user.active?).to eq(true)
end
it 'deletes the BannedUser record' do
- expect { user.activate }.to change { Users::BannedUser.count }.by(-1)
+ expect { user.unban }.to change { Users::BannedUser.count }.by(-1)
expect(Users::BannedUser.where(user_id: user.id)).not_to exist
end
end
@@ -3125,15 +3102,15 @@ RSpec.describe User do
end
end
- describe '#notification_email' do
+ describe '#notification_email_or_default' do
let(:email) { 'gonzo@muppets.com' }
context 'when the column in the database is null' do
subject { create(:user, email: email, notification_email: nil) }
it 'defaults to the primary email' do
- expect(subject.read_attribute(:notification_email)).to be nil
- expect(subject.notification_email).to eq(email)
+ expect(subject.notification_email).to be nil
+ expect(subject.notification_email_or_default).to eq(email)
end
end
end
@@ -3467,17 +3444,32 @@ RSpec.describe User do
end
describe '#membership_groups' do
- let!(:user) { create(:user) }
- let!(:parent_group) { create(:group) }
- let!(:child_group) { create(:group, parent: parent_group) }
+ let_it_be(:user) { create(:user) }
- before do
- parent_group.add_user(user, Gitlab::Access::MAINTAINER)
+ let_it_be(:parent_group) do
+ create(:group).tap do |g|
+ g.add_user(user, Gitlab::Access::MAINTAINER)
+ end
end
+ let_it_be(:child_group) { create(:group, parent: parent_group) }
+ let_it_be(:other_group) { create(:group) }
+
subject { user.membership_groups }
- it { is_expected.to contain_exactly parent_group, child_group }
+ shared_examples 'returns groups where the user is a member' do
+ specify { is_expected.to contain_exactly(parent_group, child_group) }
+ end
+
+ it_behaves_like 'returns groups where the user is a member'
+
+ context 'when feature flag :linear_user_membership_groups is disabled' do
+ before do
+ stub_feature_flags(linear_user_membership_groups: false)
+ end
+
+ it_behaves_like 'returns groups where the user is a member'
+ end
end
describe '#authorizations_for_projects' do
@@ -3686,6 +3678,11 @@ RSpec.describe User do
it 'loads all the runners in the tree of groups' do
expect(user.ci_owned_runners).to contain_exactly(runner, group_runner)
end
+
+ it 'returns true for owns_runner?' do
+ expect(user.owns_runner?(runner)).to eq(true)
+ expect(user.owns_runner?(group_runner)).to eq(true)
+ end
end
end
@@ -3698,6 +3695,10 @@ RSpec.describe User do
it 'loads the runners in the group' do
expect(user.ci_owned_runners).to contain_exactly(group_runner)
end
+
+ it 'returns true for owns_runner?' do
+ expect(user.owns_runner?(group_runner)).to eq(true)
+ end
end
end
@@ -3706,6 +3707,10 @@ RSpec.describe User do
it 'loads the runner belonging to the project' do
expect(user.ci_owned_runners).to contain_exactly(runner)
end
+
+ it 'returns true for owns_runner?' do
+ expect(user.owns_runner?(runner)).to eq(true)
+ end
end
end
@@ -3718,6 +3723,10 @@ RSpec.describe User do
it 'loads the runners of the project' do
expect(user.ci_owned_runners).to contain_exactly(project_runner)
end
+
+ it 'returns true for owns_runner?' do
+ expect(user.owns_runner?(project_runner)).to eq(true)
+ end
end
context 'when the user is a developer' do
@@ -3728,6 +3737,10 @@ RSpec.describe User do
it 'does not load any runner' do
expect(user.ci_owned_runners).to be_empty
end
+
+ it 'returns false for owns_runner?' do
+ expect(user.owns_runner?(project_runner)).to eq(false)
+ end
end
context 'when the user is a reporter' do
@@ -3738,6 +3751,10 @@ RSpec.describe User do
it 'does not load any runner' do
expect(user.ci_owned_runners).to be_empty
end
+
+ it 'returns false for owns_runner?' do
+ expect(user.owns_runner?(project_runner)).to eq(false)
+ end
end
context 'when the user is a guest' do
@@ -3748,6 +3765,10 @@ RSpec.describe User do
it 'does not load any runner' do
expect(user.ci_owned_runners).to be_empty
end
+
+ it 'returns false for owns_runner?' do
+ expect(user.owns_runner?(project_runner)).to eq(false)
+ end
end
end
@@ -3760,6 +3781,10 @@ RSpec.describe User do
it 'does not load the runners of the group' do
expect(user.ci_owned_runners).to be_empty
end
+
+ it 'returns false for owns_runner?' do
+ expect(user.owns_runner?(runner)).to eq(false)
+ end
end
context 'when the user is a developer' do
@@ -3770,6 +3795,10 @@ RSpec.describe User do
it 'does not load any runner' do
expect(user.ci_owned_runners).to be_empty
end
+
+ it 'returns false for owns_runner?' do
+ expect(user.owns_runner?(runner)).to eq(false)
+ end
end
context 'when the user is a reporter' do
@@ -3780,6 +3809,10 @@ RSpec.describe User do
it 'does not load any runner' do
expect(user.ci_owned_runners).to be_empty
end
+
+ it 'returns false for owns_runner?' do
+ expect(user.owns_runner?(runner)).to eq(false)
+ end
end
context 'when the user is a guest' do
@@ -3790,6 +3823,10 @@ RSpec.describe User do
it 'does not load any runner' do
expect(user.ci_owned_runners).to be_empty
end
+
+ it 'returns false for owns_runner?' do
+ expect(user.owns_runner?(runner)).to eq(false)
+ end
end
end
@@ -3797,6 +3834,10 @@ RSpec.describe User do
it 'does not load any runner' do
expect(user.ci_owned_runners).to be_empty
end
+
+ it 'returns false for owns_runner?' do
+ expect(user.owns_runner?(create(:ci_runner))).to eq(false)
+ end
end
context 'with runner in a personal project' do
@@ -5312,7 +5353,7 @@ RSpec.describe User do
let(:group) { nil }
it 'returns global notification email' do
- is_expected.to eq(user.notification_email)
+ is_expected.to eq(user.notification_email_or_default)
end
end
@@ -5320,7 +5361,7 @@ RSpec.describe User do
it 'returns global notification email' do
create(:notification_setting, user: user, source: group, notification_email: '')
- is_expected.to eq(user.notification_email)
+ is_expected.to eq(user.notification_email_or_default)
end
end
@@ -5521,22 +5562,17 @@ RSpec.describe User do
end
describe '#dismissed_callout?' do
- subject(:user) { create(:user) }
-
- let(:feature_name) { UserCallout.feature_names.each_key.first }
+ let_it_be(:user, refind: true) { create(:user) }
+ let_it_be(:feature_name) { UserCallout.feature_names.each_key.first }
context 'when no callout dismissal record exists' do
it 'returns false when no ignore_dismissal_earlier_than provided' do
expect(user.dismissed_callout?(feature_name: feature_name)).to eq false
end
-
- it 'returns false when ignore_dismissal_earlier_than provided' do
- expect(user.dismissed_callout?(feature_name: feature_name, ignore_dismissal_earlier_than: 3.months.ago)).to eq false
- end
end
context 'when dismissed callout exists' do
- before do
+ before_all do
create(:user_callout, user: user, feature_name: feature_name, dismissed_at: 4.months.ago)
end
@@ -5554,6 +5590,123 @@ RSpec.describe User do
end
end
+ describe '#find_or_initialize_callout' do
+ let_it_be(:user, refind: true) { create(:user) }
+ let_it_be(:feature_name) { UserCallout.feature_names.each_key.first }
+
+ subject(:find_or_initialize_callout) { user.find_or_initialize_callout(feature_name) }
+
+ 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
+
+ describe '#dismissed_callout_for_group?' do
+ let_it_be(:user, refind: true) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:feature_name) { Users::GroupCallout.feature_names.each_key.first }
+
+ context 'when no callout dismissal record exists' do
+ it 'returns false when no ignore_dismissal_earlier_than provided' do
+ expect(user.dismissed_callout_for_group?(feature_name: feature_name, group: group)).to eq false
+ end
+ end
+
+ context 'when dismissed callout exists' do
+ before_all do
+ create(:group_callout,
+ user: user,
+ group_id: group.id,
+ feature_name: feature_name,
+ dismissed_at: 4.months.ago)
+ end
+
+ it 'returns true when no ignore_dismissal_earlier_than provided' do
+ expect(user.dismissed_callout_for_group?(feature_name: feature_name, group: group)).to eq true
+ end
+
+ it 'returns true when ignore_dismissal_earlier_than is earlier than dismissed_at' do
+ expect(user.dismissed_callout_for_group?(feature_name: feature_name, group: group, ignore_dismissal_earlier_than: 6.months.ago)).to eq true
+ end
+
+ it 'returns false when ignore_dismissal_earlier_than is later than dismissed_at' do
+ expect(user.dismissed_callout_for_group?(feature_name: feature_name, group: group, ignore_dismissal_earlier_than: 3.months.ago)).to eq false
+ end
+ end
+ end
+
+ describe '#find_or_initialize_group_callout' do
+ let_it_be(:user, refind: true) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:feature_name) { Users::GroupCallout.feature_names.each_key.first }
+
+ subject(:callout_with_source) do
+ user.find_or_initialize_group_callout(feature_name, group.id)
+ end
+
+ context 'when callout exists' do
+ let!(:callout) do
+ create(:group_callout, user: user, feature_name: feature_name, group_id: group.id)
+ end
+
+ it 'returns existing callout' do
+ expect(callout_with_source).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(callout_with_source).to be_a_new(Users::GroupCallout)
+ end
+
+ it 'is valid' do
+ expect(callout_with_source).to be_valid
+ end
+ end
+
+ context 'when feature name is not valid' do
+ let(:feature_name) { 'notvalid' }
+
+ it 'initializes a new callout' do
+ expect(callout_with_source).to be_a_new(Users::GroupCallout)
+ end
+
+ it 'is not valid' do
+ expect(callout_with_source).not_to be_valid
+ end
+ end
+ end
+ end
+
describe '#hook_attrs' do
it 'includes id, name, username, avatar_url, and email' do
user = create(:user)
@@ -5916,45 +6069,6 @@ RSpec.describe User do
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
-
describe '#default_dashboard?' do
it 'is the default dashboard' do
user = build(:user)
@@ -6024,4 +6138,75 @@ RSpec.describe User do
expect(described_class.by_provider_and_extern_uid(:github, 'my_github_id')).to match_array([expected_user])
end
end
+
+ describe '#unset_secondary_emails_matching_deleted_email!' do
+ let(:deleted_email) { 'kermit@muppets.com' }
+
+ subject { build(:user, commit_email: commit_email) }
+
+ context 'when no secondary email matches the deleted email' do
+ let(:commit_email) { 'fozzie@muppets.com' }
+
+ it 'does nothing' do
+ expect(subject).not_to receive(:save)
+ subject.unset_secondary_emails_matching_deleted_email!(deleted_email)
+ expect(subject.commit_email).to eq commit_email
+ end
+ end
+
+ context 'when a secondary email matches the deleted_email' do
+ let(:commit_email) { deleted_email }
+
+ it 'un-sets the secondary email' do
+ expect(subject).to receive(:save)
+ subject.unset_secondary_emails_matching_deleted_email!(deleted_email)
+ expect(subject.commit_email).to be nil
+ end
+ end
+ end
+
+ describe '#groups_with_developer_maintainer_project_access' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group1) { create(:group) }
+
+ let_it_be(:developer_group1) do
+ create(:group).tap do |g|
+ g.add_developer(user)
+ end
+ end
+
+ let_it_be(:developer_group2) do
+ create(:group, project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS).tap do |g|
+ g.add_developer(user)
+ end
+ end
+
+ let_it_be(:guest_group1) do
+ create(:group, project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS).tap do |g|
+ g.add_guest(user)
+ end
+ end
+
+ let_it_be(:developer_group1) do
+ create(:group, project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS).tap do |g|
+ g.add_maintainer(user)
+ end
+ end
+
+ subject { user.send(:groups_with_developer_maintainer_project_access) }
+
+ shared_examples 'groups_with_developer_maintainer_project_access examples' do
+ specify { is_expected.to contain_exactly(developer_group2) }
+ end
+
+ it_behaves_like 'groups_with_developer_maintainer_project_access examples'
+
+ context 'when feature flag :linear_user_groups_with_developer_maintainer_project_access is disabled' do
+ before do
+ stub_feature_flags(linear_user_groups_with_developer_maintainer_project_access: false)
+ end
+
+ it_behaves_like 'groups_with_developer_maintainer_project_access examples'
+ end
+ end
end
diff --git a/spec/models/users/group_callout_spec.rb b/spec/models/users/group_callout_spec.rb
new file mode 100644
index 00000000000..461b5fd7715
--- /dev/null
+++ b/spec/models/users/group_callout_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Users::GroupCallout do
+ let_it_be(:user) { create_default(:user) }
+ let_it_be(:group) { create_default(:group) }
+ let_it_be(:callout) { create(:group_callout) }
+
+ it_behaves_like 'having unique enum values'
+
+ describe 'relationships' do
+ it { is_expected.to belong_to(:group) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:group) }
+ it { is_expected.to validate_presence_of(:feature_name) }
+ it { is_expected.to validate_uniqueness_of(:feature_name).scoped_to(:user_id, :group_id).ignoring_case_sensitivity }
+ end
+
+ describe '#source_feature_name' do
+ it 'provides string based off source and feature' do
+ expect(callout.source_feature_name).to eq "#{callout.feature_name}_#{callout.group_id}"
+ end
+ end
+end
diff --git a/spec/models/work_item/type_spec.rb b/spec/models/work_item/type_spec.rb
index 90f551b7d63..dd5324d63a0 100644
--- a/spec/models/work_item/type_spec.rb
+++ b/spec/models/work_item/type_spec.rb
@@ -19,8 +19,10 @@ RSpec.describe WorkItem::Type do
it 'deletes type but not unrelated issues' do
type = create(:work_item_type)
+ expect(WorkItem::Type.count).to eq(5)
+
expect { type.destroy! }.not_to change(Issue, :count)
- expect(WorkItem::Type.count).to eq 0
+ expect(WorkItem::Type.count).to eq(4)
end
end
@@ -28,7 +30,7 @@ RSpec.describe WorkItem::Type do
type = create(:work_item_type, work_items: [work_item])
expect { type.destroy! }.to raise_error(ActiveRecord::InvalidForeignKey)
- expect(Issue.count).to eq 1
+ expect(Issue.count).to eq(1)
end
end
diff --git a/spec/policies/custom_emoji_policy_spec.rb b/spec/policies/custom_emoji_policy_spec.rb
new file mode 100644
index 00000000000..9538ef9bb4a
--- /dev/null
+++ b/spec/policies/custom_emoji_policy_spec.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe CustomEmojiPolicy do
+ let(:user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:custom_emoji) { create(:custom_emoji, group: group) }
+
+ let(:custom_emoji_permissions) do
+ [
+ :create_custom_emoji,
+ :delete_custom_emoji
+ ]
+ end
+
+ context 'custom emoji permissions' do
+ subject { described_class.new(user, custom_emoji) }
+
+ context 'when user is' do
+ context 'a developer' do
+ before do
+ group.add_developer(user)
+ end
+
+ it do
+ expect_allowed(:create_custom_emoji)
+ end
+ end
+
+ context 'is maintainer' do
+ before do
+ group.add_maintainer(user)
+ end
+
+ it do
+ expect_allowed(*custom_emoji_permissions)
+ end
+ end
+
+ context 'is owner' do
+ before do
+ group.add_owner(user)
+ end
+
+ it do
+ expect_allowed(*custom_emoji_permissions)
+ end
+ end
+
+ context 'is developer and emoji creator' do
+ before do
+ group.add_developer(user)
+ custom_emoji.update_attribute(:creator, user)
+ end
+
+ it do
+ expect_allowed(*custom_emoji_permissions)
+ end
+ end
+
+ context 'is emoji creator but not a member of the group' do
+ before do
+ custom_emoji.update_attribute(:creator, user)
+ end
+
+ it do
+ expect_disallowed(*custom_emoji_permissions)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb
index 9fac5521aa6..482e12c029d 100644
--- a/spec/policies/group_policy_spec.rb
+++ b/spec/policies/group_policy_spec.rb
@@ -11,6 +11,9 @@ RSpec.describe GroupPolicy do
it do
expect_allowed(:read_group)
+ expect_allowed(:read_organization)
+ expect_allowed(:read_contact)
+ expect_allowed(:read_counts)
expect_allowed(*read_group_permissions)
expect_disallowed(:upload_file)
expect_disallowed(*reporter_permissions)
@@ -30,6 +33,9 @@ RSpec.describe GroupPolicy do
end
it { expect_disallowed(:read_group) }
+ it { expect_disallowed(:read_organization) }
+ it { expect_disallowed(:read_contact) }
+ it { expect_disallowed(:read_counts) }
it { expect_disallowed(*read_group_permissions) }
end
@@ -42,6 +48,9 @@ RSpec.describe GroupPolicy do
end
it { expect_disallowed(:read_group) }
+ it { expect_disallowed(:read_organization) }
+ it { expect_disallowed(:read_contact) }
+ it { expect_disallowed(:read_counts) }
it { expect_disallowed(*read_group_permissions) }
end
@@ -245,6 +254,7 @@ RSpec.describe GroupPolicy do
let(:current_user) { nil }
it do
+ expect_disallowed(:read_counts)
expect_disallowed(*read_group_permissions)
expect_disallowed(*guest_permissions)
expect_disallowed(*reporter_permissions)
@@ -258,6 +268,7 @@ RSpec.describe GroupPolicy do
let(:current_user) { guest }
it do
+ expect_allowed(:read_counts)
expect_allowed(*read_group_permissions)
expect_allowed(*guest_permissions)
expect_disallowed(*reporter_permissions)
@@ -271,6 +282,7 @@ RSpec.describe GroupPolicy do
let(:current_user) { reporter }
it do
+ expect_allowed(:read_counts)
expect_allowed(*read_group_permissions)
expect_allowed(*guest_permissions)
expect_allowed(*reporter_permissions)
@@ -284,6 +296,7 @@ RSpec.describe GroupPolicy do
let(:current_user) { developer }
it do
+ expect_allowed(:read_counts)
expect_allowed(*read_group_permissions)
expect_allowed(*guest_permissions)
expect_allowed(*reporter_permissions)
@@ -297,6 +310,7 @@ RSpec.describe GroupPolicy do
let(:current_user) { maintainer }
it do
+ expect_allowed(:read_counts)
expect_allowed(*read_group_permissions)
expect_allowed(*guest_permissions)
expect_allowed(*reporter_permissions)
@@ -310,6 +324,7 @@ RSpec.describe GroupPolicy do
let(:current_user) { owner }
it do
+ expect_allowed(:read_counts)
expect_allowed(*read_group_permissions)
expect_allowed(*guest_permissions)
expect_allowed(*reporter_permissions)
@@ -878,6 +893,34 @@ RSpec.describe GroupPolicy do
end
end
+ describe 'dependency proxy' do
+ context 'feature disabled' do
+ let(:current_user) { owner }
+
+ it { is_expected.to be_disallowed(:read_dependency_proxy) }
+ it { is_expected.to be_disallowed(:admin_dependency_proxy) }
+ end
+
+ context 'feature enabled' do
+ before do
+ stub_config(dependency_proxy: { enabled: true })
+ group.create_dependency_proxy_setting!(enabled: true)
+ end
+
+ context 'reporter' do
+ let(:current_user) { reporter }
+
+ it { is_expected.to be_disallowed(:admin_dependency_proxy) }
+ end
+
+ context 'developer' do
+ let(:current_user) { developer }
+
+ it { is_expected.to be_allowed(:admin_dependency_proxy) }
+ end
+ end
+ end
+
context 'deploy token access' do
let!(:group_deploy_token) do
create(:group_deploy_token, group: group, deploy_token: deploy_token)
@@ -890,6 +933,8 @@ RSpec.describe GroupPolicy do
it { is_expected.to be_allowed(:read_package) }
it { is_expected.to be_allowed(:read_group) }
+ it { is_expected.to be_allowed(:read_organization) }
+ it { is_expected.to be_allowed(:read_contact) }
it { is_expected.to be_disallowed(:create_package) }
end
@@ -899,8 +944,22 @@ RSpec.describe GroupPolicy do
it { is_expected.to be_allowed(:create_package) }
it { is_expected.to be_allowed(:read_package) }
it { is_expected.to be_allowed(:read_group) }
+ it { is_expected.to be_allowed(:read_organization) }
+ it { is_expected.to be_allowed(:read_contact) }
it { is_expected.to be_disallowed(:destroy_package) }
end
+
+ context 'a deploy token with dependency proxy scopes' do
+ let_it_be(:deploy_token) { create(:deploy_token, :group, :dependency_proxy_scopes) }
+
+ before do
+ stub_config(dependency_proxy: { enabled: true })
+ group.create_dependency_proxy_setting!(enabled: true)
+ end
+
+ it { is_expected.to be_allowed(:read_dependency_proxy) }
+ it { is_expected.to be_disallowed(:admin_dependency_proxy) }
+ end
end
it_behaves_like 'Self-managed Core resource access tokens'
diff --git a/spec/policies/issue_policy_spec.rb b/spec/policies/issue_policy_spec.rb
index d62271eedf6..3805976b3e7 100644
--- a/spec/policies/issue_policy_spec.rb
+++ b/spec/policies/issue_policy_spec.rb
@@ -27,17 +27,17 @@ RSpec.describe IssuePolicy do
end
it 'allows support_bot to read issues, create and set metadata on new issues' do
- expect(permissions(support_bot, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
- expect(permissions(support_bot, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
- expect(permissions(support_bot, new_issue)).to be_allowed(:create_issue, :set_issue_metadata)
+ expect(permissions(support_bot, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(support_bot, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(support_bot, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality)
end
end
shared_examples 'support bot with service desk disabled' do
- it 'allows support_bot to read issues, create and set metadata on new issues' do
- expect(permissions(support_bot, issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
- expect(permissions(support_bot, issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
- expect(permissions(support_bot, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata)
+ it 'does not allow support_bot to read issues, create and set metadata on new issues' do
+ expect(permissions(support_bot, issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(support_bot, issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(support_bot, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata, :set_confidentiality)
end
end
@@ -60,50 +60,50 @@ RSpec.describe IssuePolicy do
it 'allows guests to read issues' do
expect(permissions(guest, issue)).to be_allowed(:read_issue, :read_issue_iid)
- expect(permissions(guest, issue)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(guest, issue)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
expect(permissions(guest, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid)
- expect(permissions(guest, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(guest, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
- expect(permissions(guest, new_issue)).to be_allowed(:create_issue, :set_issue_metadata)
+ expect(permissions(guest, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality)
end
it 'allows reporters to read, update, and admin issues' do
- expect(permissions(reporter, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
- expect(permissions(reporter, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
- expect(permissions(reporter, new_issue)).to be_allowed(:create_issue, :set_issue_metadata)
+ expect(permissions(reporter, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(reporter, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(reporter, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality)
end
it 'allows reporters from group links to read, update, and admin issues' do
- expect(permissions(reporter_from_group_link, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
- expect(permissions(reporter_from_group_link, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
- expect(permissions(reporter_from_group_link, new_issue)).to be_allowed(:create_issue, :set_issue_metadata)
+ expect(permissions(reporter_from_group_link, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(reporter_from_group_link, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(reporter_from_group_link, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality)
end
it 'allows issue authors to read and update their issues' do
expect(permissions(author, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue)
- expect(permissions(author, issue)).to be_disallowed(:admin_issue, :set_issue_metadata)
+ expect(permissions(author, issue)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality)
expect(permissions(author, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid)
- expect(permissions(author, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(author, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
- expect(permissions(author, new_issue)).to be_allowed(:create_issue, :set_issue_metadata)
+ expect(permissions(author, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality)
end
it 'allows issue assignees to read and update their issues' do
expect(permissions(assignee, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue)
- expect(permissions(assignee, issue)).to be_disallowed(:admin_issue, :set_issue_metadata)
+ expect(permissions(assignee, issue)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality)
expect(permissions(assignee, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid)
- expect(permissions(assignee, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(assignee, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
- expect(permissions(assignee, new_issue)).to be_allowed(:create_issue, :set_issue_metadata)
+ expect(permissions(assignee, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality)
end
it 'does not allow non-members to read, update or create issues' do
- expect(permissions(non_member, issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
- expect(permissions(non_member, issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
- expect(permissions(non_member, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata)
+ expect(permissions(non_member, issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(non_member, issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(non_member, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata, :set_confidentiality)
end
it_behaves_like 'support bot with service desk disabled'
@@ -115,49 +115,49 @@ RSpec.describe IssuePolicy do
it 'does not allow non-members to read confidential issues' do
expect(permissions(non_member, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
- expect(permissions(non_member, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(non_member, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
end
it 'does not allow guests to read confidential issues' do
expect(permissions(guest, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
- expect(permissions(guest, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(guest, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
end
it 'allows reporters to read, update, and admin confidential issues' do
- expect(permissions(reporter, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
- expect(permissions(reporter, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(reporter, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(reporter, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
end
it 'allows reporters from group links to read, update, and admin confidential issues' do
- expect(permissions(reporter_from_group_link, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
- expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(reporter_from_group_link, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
end
it 'allows issue authors to read and update their confidential issues' do
expect(permissions(author, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue)
- expect(permissions(author, confidential_issue)).to be_disallowed(:admin_issue, :set_issue_metadata)
+ expect(permissions(author, confidential_issue)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality)
expect(permissions(author, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
- expect(permissions(author, confidential_issue_no_assignee)).to be_disallowed(:admin_issue, :set_issue_metadata)
+ expect(permissions(author, confidential_issue_no_assignee)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality)
end
it 'does not allow issue author to read or update confidential issue moved to an private project' do
confidential_issue.project = create(:project, :private)
- expect(permissions(author, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :set_issue_metadata)
+ expect(permissions(author, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :set_issue_metadata, :set_confidentiality)
end
it 'allows issue assignees to read and update their confidential issues' do
expect(permissions(assignee, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue)
- expect(permissions(assignee, confidential_issue)).to be_disallowed(:admin_issue, :set_issue_metadata)
+ expect(permissions(assignee, confidential_issue)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality)
- expect(permissions(assignee, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(assignee, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
end
it 'does not allow issue assignees to read or update confidential issue moved to an private project' do
confidential_issue.project = create(:project, :private)
- expect(permissions(assignee, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :set_issue_metadata)
+ expect(permissions(assignee, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :set_issue_metadata, :set_confidentiality)
end
end
end
@@ -180,48 +180,48 @@ RSpec.describe IssuePolicy do
it 'does not allow anonymous user to create todos' do
expect(permissions(nil, issue)).to be_allowed(:read_issue)
- expect(permissions(nil, issue)).to be_disallowed(:create_todo, :update_subscription, :set_issue_metadata)
- expect(permissions(nil, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata)
+ expect(permissions(nil, issue)).to be_disallowed(:create_todo, :update_subscription, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(nil, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata, :set_confidentiality)
end
it 'allows guests to read issues' do
expect(permissions(guest, issue)).to be_allowed(:read_issue, :read_issue_iid, :create_todo, :update_subscription)
- expect(permissions(guest, issue)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata)
+ expect(permissions(guest, issue)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality)
expect(permissions(guest, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid)
- expect(permissions(guest, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata)
+ expect(permissions(guest, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality)
expect(permissions(guest, issue_locked)).to be_allowed(:read_issue, :read_issue_iid)
- expect(permissions(guest, issue_locked)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata)
+ expect(permissions(guest, issue_locked)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality)
- expect(permissions(guest, new_issue)).to be_allowed(:create_issue, :set_issue_metadata)
+ expect(permissions(guest, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality)
end
it 'allows reporters to read, update, reopen, and admin issues' do
- expect(permissions(reporter, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :reopen_issue, :set_issue_metadata)
- expect(permissions(reporter, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :reopen_issue, :set_issue_metadata)
- expect(permissions(reporter, issue_locked)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(reporter, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(reporter, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(reporter, issue_locked)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
expect(permissions(reporter, issue_locked)).to be_disallowed(:reopen_issue)
- expect(permissions(reporter, new_issue)).to be_allowed(:create_issue, :set_issue_metadata)
+ expect(permissions(reporter, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality)
end
it 'allows reporters from group links to read, update, reopen and admin issues' do
- expect(permissions(reporter_from_group_link, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :reopen_issue, :set_issue_metadata)
- expect(permissions(reporter_from_group_link, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :reopen_issue, :set_issue_metadata)
- expect(permissions(reporter_from_group_link, issue_locked)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(reporter_from_group_link, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(reporter_from_group_link, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(reporter_from_group_link, issue_locked)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
expect(permissions(reporter_from_group_link, issue_locked)).to be_disallowed(:reopen_issue)
- expect(permissions(reporter, new_issue)).to be_allowed(:create_issue, :set_issue_metadata)
+ expect(permissions(reporter, new_issue)).to be_allowed(:create_issue, :set_issue_metadata, :set_confidentiality)
end
it 'allows issue authors to read, reopen and update their issues' do
expect(permissions(author, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :reopen_issue)
- expect(permissions(author, issue)).to be_disallowed(:admin_issue, :set_issue_metadata)
+ expect(permissions(author, issue)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality)
expect(permissions(author, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid)
- expect(permissions(author, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata)
+ expect(permissions(author, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality)
expect(permissions(author, issue_locked)).to be_allowed(:read_issue, :read_issue_iid, :update_issue)
- expect(permissions(author, issue_locked)).to be_disallowed(:admin_issue, :reopen_issue, :set_issue_metadata)
+ expect(permissions(author, issue_locked)).to be_disallowed(:admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality)
expect(permissions(author, new_issue)).to be_allowed(:create_issue)
expect(permissions(author, new_issue)).to be_disallowed(:set_issue_metadata)
@@ -229,13 +229,13 @@ RSpec.describe IssuePolicy do
it 'allows issue assignees to read, reopen and update their issues' do
expect(permissions(assignee, issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :reopen_issue)
- expect(permissions(assignee, issue)).to be_disallowed(:admin_issue, :set_issue_metadata)
+ expect(permissions(assignee, issue)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality)
expect(permissions(assignee, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid)
- expect(permissions(assignee, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata)
+ expect(permissions(assignee, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality)
expect(permissions(assignee, issue_locked)).to be_allowed(:read_issue, :read_issue_iid, :update_issue)
- expect(permissions(assignee, issue_locked)).to be_disallowed(:admin_issue, :reopen_issue, :set_issue_metadata)
+ expect(permissions(assignee, issue_locked)).to be_disallowed(:admin_issue, :reopen_issue, :set_issue_metadata, :set_confidentiality)
end
it 'allows non-members to read and create issues' do
@@ -249,22 +249,25 @@ RSpec.describe IssuePolicy do
expect(permissions(non_member, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid)
end
- it 'does not allow non-members to update, admin or set metadata' do
- expect(permissions(non_member, issue)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata)
- expect(permissions(non_member, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata)
+ it 'does not allow non-members to update, admin or set metadata except for set confidential flag' do
+ expect(permissions(non_member, issue)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(non_member, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
expect(permissions(non_member, new_issue)).to be_disallowed(:set_issue_metadata)
+ # this is allowed for non-members in a public project, as we want to let users report security issues
+ # see https://gitlab.com/gitlab-org/gitlab/-/issues/337665
+ expect(permissions(non_member, new_issue)).to be_allowed(:set_confidentiality)
end
it 'allows support_bot to read issues' do
# support_bot is still allowed read access in public projects through :public_access permission,
# see project_policy public_access rules policy (rule { can?(:public_access) }.policy {...})
expect(permissions(support_bot, issue)).to be_allowed(:read_issue, :read_issue_iid)
- expect(permissions(support_bot, issue)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(support_bot, issue)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
expect(permissions(support_bot, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid)
- expect(permissions(support_bot, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(support_bot, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
- expect(permissions(support_bot, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata)
+ expect(permissions(support_bot, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata, :set_confidentiality)
end
it_behaves_like 'support bot with service desk enabled'
@@ -318,9 +321,9 @@ RSpec.describe IssuePolicy do
end
it 'does not allow non-members to update or create issues' do
- expect(permissions(non_member, issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
- expect(permissions(non_member, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata)
- expect(permissions(non_member, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata)
+ expect(permissions(non_member, issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(non_member, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
+ expect(permissions(non_member, new_issue)).to be_disallowed(:create_issue, :set_issue_metadata, :set_confidentiality)
end
it_behaves_like 'support bot with service desk disabled'
@@ -333,31 +336,31 @@ RSpec.describe IssuePolicy do
it 'does not allow guests to read confidential issues' do
expect(permissions(guest, confidential_issue)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
- expect(permissions(guest, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(guest, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
end
it 'allows reporters to read, update, and admin confidential issues' do
expect(permissions(reporter, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
- expect(permissions(reporter, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(reporter, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
end
it 'allows reporter from group links to read, update, and admin confidential issues' do
expect(permissions(reporter_from_group_link, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue)
- expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
end
it 'allows issue authors to read and update their confidential issues' do
expect(permissions(author, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue)
- expect(permissions(author, confidential_issue)).to be_disallowed(:admin_issue, :set_issue_metadata)
+ expect(permissions(author, confidential_issue)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality)
- expect(permissions(author, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(author, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
end
it 'allows issue assignees to read and update their confidential issues' do
expect(permissions(assignee, confidential_issue)).to be_allowed(:read_issue, :read_issue_iid, :update_issue)
- expect(permissions(assignee, confidential_issue)).to be_disallowed(:admin_issue, :set_issue_metadata)
+ expect(permissions(assignee, confidential_issue)).to be_disallowed(:admin_issue, :set_issue_metadata, :set_confidentiality)
- expect(permissions(assignee, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata)
+ expect(permissions(assignee, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :read_issue_iid, :update_issue, :admin_issue, :set_issue_metadata, :set_confidentiality)
end
end
diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb
index 78212f06526..b800e7dbc43 100644
--- a/spec/policies/user_policy_spec.rb
+++ b/spec/policies/user_policy_spec.rb
@@ -3,8 +3,12 @@
require 'spec_helper'
RSpec.describe UserPolicy do
- let(:current_user) { create(:user) }
- let(:user) { create(:user) }
+ let_it_be(:admin) { create(:user, :admin) }
+ let_it_be(:regular_user) { create(:user) }
+ let_it_be(:subject_user) { create(:user) }
+
+ let(:current_user) { regular_user }
+ let(:user) { subject_user }
subject { described_class.new(current_user, user) }
@@ -16,7 +20,7 @@ RSpec.describe UserPolicy do
let(:token) { create(:personal_access_token, user: user) }
context 'when user is admin' do
- let(:current_user) { create(:user, :admin) }
+ let(:current_user) { admin }
context 'when admin mode is enabled', :enable_admin_mode do
it { is_expected.to be_allowed(:read_user_personal_access_tokens) }
@@ -42,7 +46,7 @@ RSpec.describe UserPolicy do
describe "creating a different user's Personal Access Tokens" do
context 'when current_user is admin' do
- let(:current_user) { create(:user, :admin) }
+ let(:current_user) { admin }
context 'when admin mode is enabled and current_user is not blocked', :enable_admin_mode do
it { is_expected.to be_allowed(:create_user_personal_access_token) }
@@ -92,7 +96,7 @@ RSpec.describe UserPolicy do
end
context "when an admin user tries to destroy a regular user" do
- let(:current_user) { create(:user, :admin) }
+ let(:current_user) { admin }
context 'when admin mode is enabled', :enable_admin_mode do
it { is_expected.to be_allowed(ability) }
@@ -104,7 +108,7 @@ RSpec.describe UserPolicy do
end
context "when an admin user tries to destroy a ghost user" do
- let(:current_user) { create(:user, :admin) }
+ let(:current_user) { admin }
let(:user) { create(:user, :ghost) }
it { is_expected.not_to be_allowed(ability) }
@@ -132,7 +136,7 @@ RSpec.describe UserPolicy do
context 'disabling the two-factor authentication of another user' do
context 'when the executor is an admin', :enable_admin_mode do
- let(:current_user) { create(:user, :admin) }
+ let(:current_user) { admin }
it { is_expected.to be_allowed(:disable_two_factor) }
end
@@ -145,7 +149,7 @@ RSpec.describe UserPolicy do
describe "reading a user's group count" do
context "when current_user is an admin", :enable_admin_mode do
- let(:current_user) { create(:user, :admin) }
+ let(:current_user) { admin }
it { is_expected.to be_allowed(:read_group_count) }
end
@@ -172,4 +176,30 @@ RSpec.describe UserPolicy do
it { is_expected.to be_allowed(:read_user_profile) }
end
end
+
+ describe ':read_user_groups' do
+ context 'when user is admin' do
+ let(:current_user) { admin }
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed(:read_user_groups) }
+ end
+
+ context 'when admin mode is disabled' do
+ it { is_expected.not_to be_allowed(:read_user_groups) }
+ end
+ end
+
+ context 'when user is not an admin' do
+ context 'requesting their own manageable groups' do
+ subject { described_class.new(current_user, current_user) }
+
+ it { is_expected.to be_allowed(:read_user_groups) }
+ end
+
+ context "requesting a different user's manageable groups" do
+ it { is_expected.not_to be_allowed(:read_user_groups) }
+ end
+ end
+ end
end
diff --git a/spec/presenters/packages/helm/index_presenter_spec.rb b/spec/presenters/packages/helm/index_presenter_spec.rb
new file mode 100644
index 00000000000..38e1dc17f49
--- /dev/null
+++ b/spec/presenters/packages/helm/index_presenter_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Packages::Helm::IndexPresenter do
+ include_context 'with expected presenters dependency groups'
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:packages) { create_list(:helm_package, 5, project: project) }
+ let_it_be(:package_files3_1) { create(:helm_package_file, package: packages[2], file_sha256: '3_1', file_name: 'file3_1') }
+ let_it_be(:package_files3_2) { create(:helm_package_file, package: packages[2], file_sha256: '3_2', file_name: 'file3_2') }
+ let_it_be(:package_files4_1) { create(:helm_package_file, package: packages[3], file_sha256: '4_1', file_name: 'file4_1') }
+ let_it_be(:package_files4_2) { create(:helm_package_file, package: packages[3], file_sha256: '4_2', file_name: 'file4_2') }
+ let_it_be(:package_files4_3) { create(:helm_package_file, package: packages[3], file_sha256: '4_3', file_name: 'file4_3') }
+
+ let(:project_id_param) { project.id }
+ let(:channel) { 'stable' }
+ let(:presenter) { described_class.new(project_id_param, channel, ::Packages::Package.id_in(packages.map(&:id))) }
+
+ describe('#entries') do
+ subject { presenter.entries }
+
+ it 'returns the correct hash' do
+ expect(subject.size).to eq(5)
+ expect(subject.keys).to eq(packages.map(&:name))
+ subject.values.zip(packages) do |raws, pkg|
+ expect(raws.size).to eq(1)
+
+ file = pkg.package_files.recent.first
+ raw = raws.first
+ expect(raw['name']).to eq(pkg.name)
+ expect(raw['version']).to eq(pkg.version)
+ expect(raw['apiVersion']).to eq("v2")
+ expect(raw['created']).to eq(file.created_at.utc.strftime('%Y-%m-%dT%H:%M:%S.%NZ'))
+ expect(raw['digest']).to eq(file.file_sha256)
+ expect(raw['urls']).to eq(["charts/#{file.file_name}"])
+ end
+ end
+
+ context 'with an unknown channel' do
+ let(:channel) { 'unknown' }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'with a nil channel' do
+ let(:channel) { nil }
+
+ it { is_expected.to be_empty }
+ end
+ end
+
+ describe('#api_version') do
+ subject { presenter.api_version }
+
+ it { is_expected.to eq(described_class::API_VERSION) }
+ end
+
+ describe('#generated') do
+ subject { presenter.generated }
+
+ it 'returns the expected format' do
+ freeze_time do
+ expect(subject).to eq(Time.zone.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%NZ'))
+ end
+ end
+ end
+
+ describe('#server_info') do
+ subject { presenter.server_info }
+
+ it { is_expected.to eq({ 'contextPath' => "/api/v4/projects/#{project.id}/packages/helm" }) }
+
+ context 'with url encoded project id param' do
+ let_it_be(:project_id_param) { 'foo/bar' }
+
+ it { is_expected.to eq({ 'contextPath' => '/api/v4/projects/foo%2Fbar/packages/helm' }) }
+ end
+ end
+end
diff --git a/spec/presenters/packages/npm/package_presenter_spec.rb b/spec/presenters/packages/npm/package_presenter_spec.rb
index e524edaadc6..65f69d4056b 100644
--- a/spec/presenters/packages/npm/package_presenter_spec.rb
+++ b/spec/presenters/packages/npm/package_presenter_spec.rb
@@ -5,10 +5,10 @@ require 'spec_helper'
RSpec.describe ::Packages::Npm::PackagePresenter do
let_it_be(:project) { create(:project) }
let_it_be(:package_name) { "@#{project.root_namespace.path}/test" }
+ let_it_be(:package1) { create(:npm_package, version: '2.0.4', project: project, name: package_name) }
+ let_it_be(:package2) { create(:npm_package, version: '2.0.6', project: project, name: package_name) }
+ let_it_be(:latest_package) { create(:npm_package, version: '2.0.11', project: project, name: package_name) }
- let!(:package1) { create(:npm_package, version: '1.0.4', project: project, name: package_name) }
- let!(:package2) { create(:npm_package, version: '1.0.6', project: project, name: package_name) }
- let!(:latest_package) { create(:npm_package, version: '1.0.11', project: project, name: package_name) }
let(:packages) { project.packages.npm.with_name(package_name).last_of_each_version }
let(:presenter) { described_class.new(package_name, packages) }
@@ -20,23 +20,39 @@ RSpec.describe ::Packages::Npm::PackagePresenter do
it { expect(subject[package1.version].with_indifferent_access).to match_schema('public_api/v4/packages/npm_package_version') }
it { expect(subject[package2.version].with_indifferent_access).to match_schema('public_api/v4/packages/npm_package_version') }
- described_class::NPM_VALID_DEPENDENCY_TYPES.each do |dependency_type|
+ ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type|
it { expect(subject.dig(package1.version, dependency_type)).to be nil }
it { expect(subject.dig(package2.version, dependency_type)).to be nil }
end
+
+ it 'avoids N+1 database queries' do
+ check_n_plus_one(:versions) do
+ create_list(:npm_package, 5, project: project, name: package_name)
+ end
+ end
end
context 'for packages with dependencies' do
- described_class::NPM_VALID_DEPENDENCY_TYPES.each do |dependency_type|
- let!("package_dependency_link_for_#{dependency_type}") { create(:packages_dependency_link, package: package1, dependency_type: dependency_type) }
+ ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type|
+ let_it_be("package_dependency_link_for_#{dependency_type}") { create(:packages_dependency_link, package: package1, dependency_type: dependency_type) }
end
it { is_expected.to be_a(Hash) }
it { expect(subject[package1.version].with_indifferent_access).to match_schema('public_api/v4/packages/npm_package_version') }
it { expect(subject[package2.version].with_indifferent_access).to match_schema('public_api/v4/packages/npm_package_version') }
- described_class::NPM_VALID_DEPENDENCY_TYPES.each do |dependency_type|
+ ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type|
it { expect(subject.dig(package1.version, dependency_type.to_s)).to be_any }
end
+
+ it 'avoids N+1 database queries' do
+ check_n_plus_one(:versions) do
+ create_list(:npm_package, 5, project: project, name: package_name).each do |npm_package|
+ ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type|
+ create(:packages_dependency_link, package: npm_package, dependency_type: dependency_type)
+ end
+ end
+ end
+ end
end
end
@@ -46,14 +62,20 @@ RSpec.describe ::Packages::Npm::PackagePresenter do
context 'for packages without tags' do
it { is_expected.to be_a(Hash) }
it { expect(subject["latest"]).to eq(latest_package.version) }
+
+ it 'avoids N+1 database queries' do
+ check_n_plus_one(:dist_tags) do
+ create_list(:npm_package, 5, project: project, name: package_name)
+ end
+ end
end
context 'for packages with tags' do
- let!(:package_tag1) { create(:packages_tag, package: package1, name: 'release_a') }
- let!(:package_tag2) { create(:packages_tag, package: package1, name: 'test_release') }
- let!(:package_tag3) { create(:packages_tag, package: package2, name: 'release_b') }
- let!(:package_tag4) { create(:packages_tag, package: latest_package, name: 'release_c') }
- let!(:package_tag5) { create(:packages_tag, package: latest_package, name: 'latest') }
+ let_it_be(:package_tag1) { create(:packages_tag, package: package1, name: 'release_a') }
+ let_it_be(:package_tag2) { create(:packages_tag, package: package1, name: 'test_release') }
+ let_it_be(:package_tag3) { create(:packages_tag, package: package2, name: 'release_b') }
+ let_it_be(:package_tag4) { create(:packages_tag, package: latest_package, name: 'release_c') }
+ let_it_be(:package_tag5) { create(:packages_tag, package: latest_package, name: 'latest') }
it { is_expected.to be_a(Hash) }
it { expect(subject[package_tag1.name]).to eq(package1.version) }
@@ -61,6 +83,25 @@ RSpec.describe ::Packages::Npm::PackagePresenter do
it { expect(subject[package_tag3.name]).to eq(package2.version) }
it { expect(subject[package_tag4.name]).to eq(latest_package.version) }
it { expect(subject[package_tag5.name]).to eq(latest_package.version) }
+
+ it 'avoids N+1 database queries' do
+ check_n_plus_one(:dist_tags) do
+ create_list(:npm_package, 5, project: project, name: package_name).each_with_index do |npm_package, index|
+ create(:packages_tag, package: npm_package, name: "tag_#{index}")
+ end
+ end
+ end
end
end
+
+ def check_n_plus_one(field)
+ pkgs = project.packages.npm.with_name(package_name).last_of_each_version.preload_files
+ control = ActiveRecord::QueryRecorder.new { described_class.new(package_name, pkgs).public_send(field) }
+
+ yield
+
+ pkgs = project.packages.npm.with_name(package_name).last_of_each_version.preload_files
+
+ expect { described_class.new(package_name, pkgs).public_send(field) }.not_to exceed_query_limit(control)
+ end
end
diff --git a/spec/presenters/project_presenter_spec.rb b/spec/presenters/project_presenter_spec.rb
index fd75c8411d5..5f789f59908 100644
--- a/spec/presenters/project_presenter_spec.rb
+++ b/spec/presenters/project_presenter_spec.rb
@@ -649,36 +649,18 @@ 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
+ it 'includes a button to configure integrations for maintainers' do
+ project.add_maintainer(user)
- context 'when disabled' do
- it 'does not include a button' do
- project.add_maintainer(user)
+ expect(empty_repo_statistics_buttons.map(&:label)).to include(
+ a_string_including('Configure Integration')
+ )
+ end
- expect(empty_repo_statistics_buttons.map(&:label)).not_to include(
- a_string_including('Configure Integration')
- )
- end
- 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
context 'for a developer' do
diff --git a/spec/presenters/snippet_blob_presenter_spec.rb b/spec/presenters/snippet_blob_presenter_spec.rb
index 1a5130dcdf6..d7f56c30b5e 100644
--- a/spec/presenters/snippet_blob_presenter_spec.rb
+++ b/spec/presenters/snippet_blob_presenter_spec.rb
@@ -10,6 +10,7 @@ RSpec.describe SnippetBlobPresenter do
describe '#rich_data' do
let(:data_endpoint_url) { "/-/snippets/#{snippet.id}/raw/#{branch}/#{file}" }
+ let(:data_raw_dir) { "/-/snippets/#{snippet.id}/raw/#{branch}/" }
before do
allow_next_instance_of(described_class) do |instance|
@@ -45,7 +46,7 @@ RSpec.describe SnippetBlobPresenter do
let(:file) { 'test.ipynb' }
it 'returns rich notebook content' do
- expect(subject.strip).to eq %Q(<div class="file-content" data-endpoint="#{data_endpoint_url}" id="js-notebook-viewer"></div>)
+ expect(subject.strip).to eq %Q(<div class="file-content" data-endpoint="#{data_endpoint_url}" data-relative-raw-path="#{data_raw_dir}" id="js-notebook-viewer"></div>)
end
end
diff --git a/spec/rake_helper.rb b/spec/rake_helper.rb
index 7df1cf7444f..ca5b4d8337c 100644
--- a/spec/rake_helper.rb
+++ b/spec/rake_helper.rb
@@ -12,6 +12,6 @@ RSpec.configure do |config|
end
config.after(:all) do
- delete_from_all_tables!
+ delete_from_all_tables!(except: deletion_except_tables)
end
end
diff --git a/spec/requests/admin/background_migrations_controller_spec.rb b/spec/requests/admin/background_migrations_controller_spec.rb
new file mode 100644
index 00000000000..c7d5d5cae08
--- /dev/null
+++ b/spec/requests/admin/background_migrations_controller_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::BackgroundMigrationsController, :enable_admin_mode do
+ let(:admin) { create(:admin) }
+
+ before do
+ sign_in(admin)
+ end
+
+ describe 'POST #retry' do
+ let(:migration) { create(:batched_background_migration, status: 'failed') }
+
+ before do
+ create(:batched_background_migration_job, batched_migration: migration, batch_size: 10, min_value: 6, max_value: 15, status: :failed, attempts: 3)
+
+ allow_next_instance_of(Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchingStrategy) do |batch_class|
+ allow(batch_class).to receive(:next_batch).with(anything, anything, batch_min_value: 6, batch_size: 5).and_return([6, 10])
+ end
+ end
+
+ subject(:retry_migration) { post retry_admin_background_migration_path(migration) }
+
+ it 'redirects the user to the admin migrations page' do
+ retry_migration
+
+ expect(response).to redirect_to(admin_background_migrations_path)
+ end
+
+ it 'retries the migration' do
+ retry_migration
+
+ expect(migration.reload.status).to eql 'active'
+ end
+
+ context 'when the migration is not failed' do
+ let(:migration) { create(:batched_background_migration, status: 'paused') }
+
+ it 'keeps the same migration status' do
+ expect { retry_migration }.not_to change { migration.reload.status }
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/admin/sidekiq_spec.rb b/spec/requests/api/admin/sidekiq_spec.rb
index 3c488816bed..1e626c90e7e 100644
--- a/spec/requests/api/admin/sidekiq_spec.rb
+++ b/spec/requests/api/admin/sidekiq_spec.rb
@@ -36,7 +36,7 @@ RSpec.describe API::Admin::Sidekiq, :clean_gitlab_redis_queues do
add_job(admin, [2])
add_job(create(:user), [3])
- delete api("/admin/sidekiq/queues/authorized_projects?user=#{admin.username}", admin)
+ delete api("/admin/sidekiq/queues/authorized_projects?user=#{admin.username}&worker_class=AuthorizedProjectsWorker", admin)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq('completed' => true,
diff --git a/spec/requests/api/ci/pipelines_spec.rb b/spec/requests/api/ci/pipelines_spec.rb
index 640e1ee6422..7ae350885f4 100644
--- a/spec/requests/api/ci/pipelines_spec.rb
+++ b/spec/requests/api/ci/pipelines_spec.rb
@@ -37,24 +37,10 @@ RSpec.describe API::Ci::Pipelines do
end
describe 'keys in the response' do
- context 'when `pipeline_source_filter` feature flag is disabled' do
- before do
- stub_feature_flags(pipeline_source_filter: false)
- end
+ it 'includes pipeline source' do
+ get api("/projects/#{project.id}/pipelines", user)
- it 'does not includes pipeline source' do
- get api("/projects/#{project.id}/pipelines", user)
-
- expect(json_response.first.keys).to contain_exactly(*%w[id project_id sha ref status web_url created_at updated_at])
- end
- end
-
- context 'when `pipeline_source_filter` feature flag is disabled' do
- it 'includes pipeline source' do
- get api("/projects/#{project.id}/pipelines", user)
-
- expect(json_response.first.keys).to contain_exactly(*%w[id project_id sha ref status web_url created_at updated_at source])
- end
+ expect(json_response.first.keys).to contain_exactly(*%w[id project_id sha ref status web_url created_at updated_at source])
end
end
@@ -182,30 +168,6 @@ RSpec.describe API::Ci::Pipelines do
end
end
- context 'when name is specified' do
- let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
-
- context 'when name exists' do
- it 'returns matched pipelines' do
- get api("/projects/#{project.id}/pipelines", user), params: { name: user.name }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
- expect(json_response.first['id']).to eq(pipeline.id)
- end
- end
-
- context 'when name does not exist' do
- it 'returns empty' do
- get api("/projects/#{project.id}/pipelines", user), params: { name: 'invalid-name' }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
- expect(json_response).to be_empty
- end
- end
- end
-
context 'when username is specified' do
let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
@@ -323,37 +285,20 @@ RSpec.describe API::Ci::Pipelines do
create(:ci_pipeline, project: project, source: :api)
end
- context 'when `pipeline_source_filter` feature flag is disabled' do
- before do
- stub_feature_flags(pipeline_source_filter: false)
- end
-
- it 'returns all pipelines' do
- get api("/projects/#{project.id}/pipelines", user), params: { source: 'web' }
+ it 'returns matched pipelines' do
+ get api("/projects/#{project.id}/pipelines", user), params: { source: 'web' }
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
- expect(json_response).not_to be_empty
- expect(json_response.length).to be >= 3
- end
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).not_to be_empty
+ json_response.each { |r| expect(r['source']).to eq('web') }
end
- context 'when `pipeline_source_filter` feature flag is enabled' do
- it 'returns matched pipelines' do
- get api("/projects/#{project.id}/pipelines", user), params: { source: 'web' }
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
- expect(json_response).not_to be_empty
- json_response.each { |r| expect(r['source']).to eq('web') }
- end
-
- context 'when source is invalid' do
- it 'returns bad_request' do
- get api("/projects/#{project.id}/pipelines", user), params: { source: 'invalid-source' }
+ context 'when source is invalid' do
+ it 'returns bad_request' do
+ get api("/projects/#{project.id}/pipelines", user), params: { source: 'invalid-source' }
- expect(response).to have_gitlab_http_status(:bad_request)
- end
+ expect(response).to have_gitlab_http_status(:bad_request)
end
end
end
diff --git a/spec/requests/api/ci/runners_reset_registration_token_spec.rb b/spec/requests/api/ci/runners_reset_registration_token_spec.rb
new file mode 100644
index 00000000000..7623d3f1b17
--- /dev/null
+++ b/spec/requests/api/ci/runners_reset_registration_token_spec.rb
@@ -0,0 +1,149 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Ci::Runners do
+ subject { post api("#{prefix}/runners/reset_registration_token", user) }
+
+ shared_examples 'bad request' do |result|
+ it 'returns 400 error' do
+ expect { subject }.not_to change { get_token }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to eq(result)
+ end
+ end
+
+ shared_examples 'unauthenticated' do
+ it 'returns 401 error' do
+ expect { subject }.not_to change { get_token }
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ shared_examples 'unauthorized' do
+ it 'returns 403 error' do
+ expect { subject }.not_to change { get_token }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ shared_examples 'not found' do |scope|
+ it 'returns 404 error' do
+ expect { subject }.not_to change { get_token }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response).to eq({ 'message' => "404 #{scope.capitalize} Not Found" })
+ end
+ end
+
+ shared_context 'when unauthorized' do |scope|
+ context 'when unauthorized' do
+ let_it_be(:user) { create(:user) }
+
+ context "when not a #{scope} member" do
+ it_behaves_like 'not found', scope
+ end
+
+ context "with a non-admin #{scope} member" do
+ before do
+ target.add_developer(user)
+ end
+
+ it_behaves_like 'unauthorized'
+ end
+ end
+ end
+
+ shared_context 'when authorized' do |scope|
+ it 'resets runner registration token' do
+ expect { subject }.to change { get_token }
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(json_response).to eq({ 'token' => get_token })
+ end
+
+ if scope != 'instance'
+ context 'when malformed id is provided' do
+ let(:prefix) { "/#{scope.pluralize}/some%20string" }
+
+ it_behaves_like 'not found', scope
+ end
+ end
+ end
+
+ describe '/api/v4/runners/reset_registration_token' do
+ describe 'POST /api/v4/runners/reset_registration_token' do
+ before do
+ ApplicationSetting.create_from_defaults
+ stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
+ end
+
+ let(:prefix) { '' }
+
+ context 'when unauthenticated' do
+ let(:user) { nil }
+
+ it_behaves_like 'unauthenticated'
+ end
+
+ context 'when unauthorized' do
+ let(:user) { create(:user) }
+
+ context "with a non-admin instance member" do
+ it_behaves_like 'unauthorized'
+ end
+ end
+
+ include_context 'when authorized', 'instance' do
+ let_it_be(:user) { create(:user, :admin) }
+
+ def get_token
+ ApplicationSetting.current_without_cache.runners_registration_token
+ end
+ end
+ end
+ end
+
+ describe '/api/v4/groups/:id/runners/reset_registration_token' do
+ describe 'POST /api/v4/groups/:id/runners/reset_registration_token' do
+ let_it_be(:group) { create_default(:group, :private) }
+
+ let(:prefix) { "/groups/#{group.id}" }
+
+ include_context 'when unauthorized', 'group' do
+ let(:target) { group }
+ end
+
+ include_context 'when authorized', 'group' do
+ let_it_be(:user) { create_default(:group_member, :maintainer, user: create(:user), group: group ).user }
+
+ def get_token
+ group.reload.runners_token
+ end
+ end
+ end
+ end
+
+ describe '/api/v4/projects/:id/runners/reset_registration_token' do
+ describe 'POST /api/v4/projects/:id/runners/reset_registration_token' do
+ let_it_be(:project) { create_default(:project) }
+
+ let(:prefix) { "/projects/#{project.id}" }
+
+ include_context 'when unauthorized', 'project' do
+ let(:target) { project }
+ end
+
+ include_context 'when authorized', 'project' do
+ let_it_be(:user) { project.owner }
+
+ def get_token
+ project.reload.runners_token
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/commit_statuses_spec.rb b/spec/requests/api/commit_statuses_spec.rb
index ccc9f8c50c4..47bc3eb74a6 100644
--- a/spec/requests/api/commit_statuses_spec.rb
+++ b/spec/requests/api/commit_statuses_spec.rb
@@ -345,38 +345,12 @@ RSpec.describe API::CommitStatuses do
expect(json_response['status']).to eq('success')
end
- 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
+ it 'retries the commit status', :sidekiq_might_not_need_inline do
+ post_request
- 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
+ expect(CommitStatus.count).to eq 2
+ expect(CommitStatus.first).to be_retried
+ expect(CommitStatus.last.pipeline).to be_success
end
end
diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb
index 1162ae76d15..1d76c281dee 100644
--- a/spec/requests/api/commits_spec.rb
+++ b/spec/requests/api/commits_spec.rb
@@ -1879,6 +1879,26 @@ RSpec.describe API::Commits do
expect(json_response['line_type']).to eq('new')
end
+ it 'correctly adds a note for the "old" line type' do
+ commit = project.repository.commit("markdown")
+ commit_id = commit.id
+ route = "/projects/#{project_id}/repository/commits/#{commit_id}/comments"
+
+ post api(route, current_user), params: {
+ note: 'My comment',
+ path: commit.raw_diffs.first.old_path,
+ line: 4,
+ line_type: 'old'
+ }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response).to match_response_schema('public_api/v4/commit_note')
+ expect(json_response['note']).to eq('My comment')
+ expect(json_response['path']).to eq(commit.raw_diffs.first.old_path)
+ expect(json_response['line']).to eq(4)
+ expect(json_response['line_type']).to eq('old')
+ end
+
context 'when ref does not exist' do
let(:commit_id) { 'unknown' }
diff --git a/spec/requests/api/dependency_proxy_spec.rb b/spec/requests/api/dependency_proxy_spec.rb
index d59f2bf06e3..2837d1c02c4 100644
--- a/spec/requests/api/dependency_proxy_spec.rb
+++ b/spec/requests/api/dependency_proxy_spec.rb
@@ -13,60 +13,74 @@ RSpec.describe API::DependencyProxy, api: true do
group.add_owner(user)
stub_config(dependency_proxy: { enabled: true })
stub_last_activity_update
- group.create_dependency_proxy_setting!(enabled: true)
end
describe 'DELETE /groups/:id/dependency_proxy/cache' do
- subject { delete api("/groups/#{group.id}/dependency_proxy/cache", user) }
+ subject { delete api("/groups/#{group_id}/dependency_proxy/cache", user) }
- context 'with feature available and enabled' do
- let_it_be(:lease_key) { "dependency_proxy:delete_group_blobs:#{group.id}" }
+ shared_examples 'responding to purge requests' do
+ context 'with feature available and enabled' do
+ let_it_be(:lease_key) { "dependency_proxy:delete_group_blobs:#{group.id}" }
- context 'an admin user' do
- it 'deletes the blobs and returns no content' do
- stub_exclusive_lease(lease_key, timeout: 1.hour)
- expect(PurgeDependencyProxyCacheWorker).to receive(:perform_async)
+ context 'an admin user' do
+ it 'deletes the blobs and returns no content' do
+ stub_exclusive_lease(lease_key, timeout: 1.hour)
+ expect(PurgeDependencyProxyCacheWorker).to receive(:perform_async)
- subject
+ subject
- expect(response).to have_gitlab_http_status(:no_content)
- end
+ expect(response).to have_gitlab_http_status(:accepted)
+ expect(response.body).to eq('202')
+ end
- context 'called multiple times in one hour', :clean_gitlab_redis_shared_state do
- it 'returns 409 with an error message' do
- stub_exclusive_lease_taken(lease_key, timeout: 1.hour)
+ context 'called multiple times in one hour', :clean_gitlab_redis_shared_state do
+ it 'returns 409 with an error message' do
+ stub_exclusive_lease_taken(lease_key, timeout: 1.hour)
- subject
+ subject
- expect(response).to have_gitlab_http_status(:conflict)
- expect(response.body).to include('This request has already been made.')
+ expect(response).to have_gitlab_http_status(:conflict)
+ expect(response.body).to include('This request has already been made.')
+ end
+
+ it 'executes service only for the first time' do
+ expect(PurgeDependencyProxyCacheWorker).to receive(:perform_async).once
+
+ 2.times { subject }
+ end
end
+ end
- it 'executes service only for the first time' do
- expect(PurgeDependencyProxyCacheWorker).to receive(:perform_async).once
+ context 'a non-admin' do
+ let(:user) { create(:user) }
- 2.times { subject }
+ before do
+ group.add_maintainer(user)
end
+
+ it_behaves_like 'returning response status', :forbidden
end
end
- context 'a non-admin' do
- let(:user) { create(:user) }
-
+ context 'depencency proxy is not enabled in the config' do
before do
- group.add_maintainer(user)
+ stub_config(dependency_proxy: { enabled: false })
end
- it_behaves_like 'returning response status', :forbidden
+ it_behaves_like 'returning response status', :not_found
end
end
- context 'depencency proxy is not enabled' do
- before do
- stub_config(dependency_proxy: { enabled: false })
- end
+ context 'with a group id' do
+ let(:group_id) { group.id }
+
+ it_behaves_like 'responding to purge requests'
+ end
+
+ context 'with an url encoded group id' do
+ let(:group_id) { ERB::Util.url_encode(group.full_path) }
- it_behaves_like 'returning response status', :not_found
+ it_behaves_like 'responding to purge requests'
end
end
end
diff --git a/spec/requests/api/error_tracking_client_keys_spec.rb b/spec/requests/api/error_tracking_client_keys_spec.rb
new file mode 100644
index 00000000000..886ec5ade3d
--- /dev/null
+++ b/spec/requests/api/error_tracking_client_keys_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::ErrorTrackingClientKeys do
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:setting) { create(:project_error_tracking_setting) }
+ let_it_be(:project) { setting.project }
+
+ let!(:client_key) { create(:error_tracking_client_key, project: project) }
+
+ before do
+ project.add_guest(guest)
+ project.add_maintainer(maintainer)
+ end
+
+ shared_examples 'endpoint with authorization' do
+ context 'when unauthenticated' do
+ let(:user) { nil }
+
+ it { expect(response).to have_gitlab_http_status(:unauthorized) }
+ end
+
+ context 'when authenticated as non-maintainer' do
+ let(:user) { guest }
+
+ it { expect(response).to have_gitlab_http_status(:forbidden) }
+ end
+ end
+
+ describe "GET /projects/:id/error_tracking/client_keys" do
+ before do
+ get api("/projects/#{project.id}/error_tracking/client_keys", user)
+ end
+
+ it_behaves_like 'endpoint with authorization'
+
+ context 'when authenticated as maintainer' do
+ let(:user) { maintainer }
+
+ it 'returns client keys' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response.size).to eq(1)
+ expect(json_response.first['id']).to eq(client_key.id)
+ end
+ end
+ end
+
+ describe "POST /projects/:id/error_tracking/client_keys" do
+ before do
+ post api("/projects/#{project.id}/error_tracking/client_keys", user)
+ end
+
+ it_behaves_like 'endpoint with authorization'
+
+ context 'when authenticated as maintainer' do
+ let(:user) { maintainer }
+
+ it 'returns a newly created client key' do
+ new_key = project.error_tracking_client_keys.last
+
+ expect(json_response['id']).to eq(new_key.id)
+ expect(json_response['public_key']).to eq(new_key.public_key)
+ expect(json_response['sentry_dsn']).to eq(new_key.sentry_dsn)
+ end
+ end
+ end
+
+ describe "DELETE /projects/:id/error_tracking/client_keys/:key_id" do
+ before do
+ delete api("/projects/#{project.id}/error_tracking/client_keys/#{client_key.id}", user)
+ end
+
+ it_behaves_like 'endpoint with authorization'
+
+ context 'when authenticated as maintainer' do
+ let(:user) { maintainer }
+
+ it 'returns a correct status' do
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/error_tracking_collector_spec.rb b/spec/requests/api/error_tracking_collector_spec.rb
index 4b186657c4a..35d3ea01f87 100644
--- a/spec/requests/api/error_tracking_collector_spec.rb
+++ b/spec/requests/api/error_tracking_collector_spec.rb
@@ -7,6 +7,30 @@ RSpec.describe API::ErrorTrackingCollector do
let_it_be(:setting) { create(:project_error_tracking_setting, :integrated, project: project) }
let_it_be(:client_key) { create(:error_tracking_client_key, project: project) }
+ RSpec.shared_examples 'not found' do
+ it 'reponds with 404' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ RSpec.shared_examples 'bad request' do
+ it 'responds with 400' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ RSpec.shared_examples 'successful request' do
+ it 'writes to the database and returns no content' do
+ expect { subject }.to change { ErrorTracking::ErrorEvent.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+
describe "POST /error_tracking/collector/api/:id/envelope" do
let_it_be(:raw_event) { fixture_file('error_tracking/event.txt') }
let_it_be(:url) { "/error_tracking/collector/api/#{project.id}/envelope" }
@@ -16,22 +40,6 @@ RSpec.describe API::ErrorTrackingCollector do
subject { post api(url), params: params, headers: headers }
- RSpec.shared_examples 'not found' do
- it 'reponds with 404' do
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- RSpec.shared_examples 'bad request' do
- it 'responds with 400' do
- subject
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
- end
-
context 'error tracking feature is disabled' do
before do
setting.update!(enabled: false)
@@ -48,14 +56,6 @@ RSpec.describe API::ErrorTrackingCollector do
it_behaves_like 'not found'
end
- context 'feature flag is disabled' do
- before do
- stub_feature_flags(integrated_error_tracking: false)
- end
-
- it_behaves_like 'not found'
- end
-
context 'auth headers are missing' do
let(:headers) { {} }
@@ -96,10 +96,53 @@ RSpec.describe API::ErrorTrackingCollector do
end
end
- it 'writes to the database and returns no content' do
- expect { subject }.to change { ErrorTracking::ErrorEvent.count }.by(1)
+ it_behaves_like 'successful request'
+ end
- expect(response).to have_gitlab_http_status(:no_content)
+ describe "POST /error_tracking/collector/api/:id/store" do
+ let_it_be(:raw_event) { fixture_file('error_tracking/parsed_event.json') }
+ let_it_be(:url) { "/error_tracking/collector/api/#{project.id}/store" }
+
+ let(:params) { raw_event }
+ let(:headers) { { 'X-Sentry-Auth' => "Sentry sentry_key=#{client_key.public_key}" } }
+
+ subject { post api(url), params: params, headers: headers }
+
+ it_behaves_like 'successful request'
+
+ context 'empty headers' do
+ let(:headers) { {} }
+
+ it_behaves_like 'bad request'
+ end
+
+ context 'empty body' do
+ let(:params) { '' }
+
+ it_behaves_like 'bad request'
+ end
+
+ context 'sentry_key as param and empty headers' do
+ let(:url) { "/error_tracking/collector/api/#{project.id}/store?sentry_key=#{sentry_key}" }
+ let(:headers) { {} }
+
+ context 'key is wrong' do
+ let(:sentry_key) { 'glet_1fedb514e17f4b958435093deb02048c' }
+
+ it_behaves_like 'not found'
+ end
+
+ context 'key is empty' do
+ let(:sentry_key) { '' }
+
+ it_behaves_like 'bad request'
+ end
+
+ context 'key is correct' do
+ let(:sentry_key) { client_key.public_key }
+
+ it_behaves_like 'successful request'
+ end
end
end
end
diff --git a/spec/requests/api/feature_flags_spec.rb b/spec/requests/api/feature_flags_spec.rb
index 8c8c6803a38..a1aedc1d6b2 100644
--- a/spec/requests/api/feature_flags_spec.rb
+++ b/spec/requests/api/feature_flags_spec.rb
@@ -116,21 +116,6 @@ RSpec.describe API::FeatureFlags do
}])
end
end
-
- context 'with version 1 and 2 feature flags' do
- it 'returns both versions of flags ordered by name' do
- create(:operations_feature_flag, project: project, name: 'legacy_flag')
- feature_flag = create(:operations_feature_flag, :new_version_flag, project: project, name: 'new_version_flag')
- strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
- create(:operations_scope, strategy: strategy, environment_scope: 'production')
-
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('public_api/v4/feature_flags')
- expect(json_response.map { |f| f['name'] }).to eq(%w[legacy_flag new_version_flag])
- end
- end
end
describe 'GET /projects/:id/feature_flags/:name' do
@@ -185,22 +170,13 @@ RSpec.describe API::FeatureFlags do
end
describe 'POST /projects/:id/feature_flags' do
- def scope_default
- {
- environment_scope: '*',
- active: false,
- strategies: [{ name: 'default', parameters: {} }].to_json
- }
- end
-
subject do
post api("/projects/#{project.id}/feature_flags", user), params: params
end
let(:params) do
{
- name: 'awesome-feature',
- scopes: [scope_default]
+ name: 'awesome-feature'
}
end
@@ -215,14 +191,14 @@ RSpec.describe API::FeatureFlags do
expect(feature_flag.description).to eq(params[:description])
end
- it 'defaults to a version 1 (legacy) feature flag' do
+ it 'defaults to a version 2 (new) feature flag' do
subject
expect(response).to have_gitlab_http_status(:created)
expect(response).to match_response_schema('public_api/v4/feature_flag')
feature_flag = project.operations_feature_flags.last
- expect(feature_flag.version).to eq('legacy_flag')
+ expect(feature_flag.version).to eq('new_version_flag')
end
it_behaves_like 'check user permission'
@@ -232,38 +208,7 @@ RSpec.describe API::FeatureFlags do
expect(response).to have_gitlab_http_status(:created)
expect(response).to match_response_schema('public_api/v4/feature_flag')
- expect(json_response['version']).to eq('legacy_flag')
- end
-
- context 'with active set to false in the params for a legacy flag' do
- let(:params) do
- {
- name: 'awesome-feature',
- version: 'legacy_flag',
- active: 'false',
- scopes: [scope_default]
- }
- end
-
- it 'creates an inactive feature flag' do
- subject
-
- expect(response).to have_gitlab_http_status(:created)
- expect(response).to match_response_schema('public_api/v4/feature_flag')
- expect(json_response['active']).to eq(false)
- end
- end
-
- context 'when no scopes passed in parameters' do
- let(:params) { { name: 'awesome-feature' } }
-
- it 'creates a new feature flag with active default scope' do
- subject
-
- expect(response).to have_gitlab_http_status(:created)
- feature_flag = project.operations_feature_flags.last
- expect(feature_flag.default_scope).to be_active
- end
+ expect(json_response['version']).to eq('new_version_flag')
end
context 'when there is a feature flag with the same name already' do
@@ -278,43 +223,6 @@ RSpec.describe API::FeatureFlags do
end
end
- context 'when create a feature flag with two scopes' do
- let(:params) do
- {
- name: 'awesome-feature',
- description: 'this is awesome',
- scopes: [
- scope_default,
- scope_with_user_with_id
- ]
- }
- end
-
- let(:scope_with_user_with_id) do
- {
- environment_scope: 'production',
- active: true,
- strategies: [{
- name: 'userWithId',
- parameters: { userIds: 'user:1' }
- }].to_json
- }
- end
-
- it 'creates a new feature flag with two scopes' do
- subject
-
- expect(response).to have_gitlab_http_status(:created)
-
- feature_flag = project.operations_feature_flags.last
- feature_flag.scopes.ordered.each_with_index do |scope, index|
- expect(scope.environment_scope).to eq(params[:scopes][index][:environment_scope])
- expect(scope.active).to eq(params[:scopes][index][:active])
- expect(scope.strategies).to eq(Gitlab::Json.parse(params[:scopes][index][:strategies]))
- end
- end
- end
-
context 'when creating a version 2 feature flag' do
it 'creates a new feature flag' do
params = {
@@ -455,23 +363,6 @@ RSpec.describe API::FeatureFlags do
end
describe 'PUT /projects/:id/feature_flags/:name' do
- context 'with a legacy feature flag' do
- let!(:feature_flag) do
- create(:operations_feature_flag, :legacy_flag, project: project,
- name: 'feature1', description: 'old description')
- end
-
- it 'returns a 404' do
- params = { description: 'new description' }
-
- put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(json_response).to eq({ 'message' => '404 Not Found' })
- expect(feature_flag.reload.description).to eq('old description')
- end
- end
-
context 'with a version 2 feature flag' do
let!(:feature_flag) do
create(:operations_feature_flag, :new_version_flag, project: project, active: true,
@@ -781,7 +672,7 @@ RSpec.describe API::FeatureFlags do
params: params
end
- let!(:feature_flag) { create(:operations_feature_flag, :legacy_flag, project: project) }
+ let!(:feature_flag) { create(:operations_feature_flag, project: project) }
let(:params) { {} }
it 'destroys the feature flag' do
@@ -794,7 +685,7 @@ RSpec.describe API::FeatureFlags do
subject
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['version']).to eq('legacy_flag')
+ expect(json_response['version']).to eq('new_version_flag')
end
context 'with a version 2 feature flag' do
diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb
index 869df06b60c..0b898496dd6 100644
--- a/spec/requests/api/files_spec.rb
+++ b/spec/requests/api/files_spec.rb
@@ -95,6 +95,19 @@ RSpec.describe API::Files do
expect(response.headers['X-Gitlab-Content-Sha256']).to eq('c440cd09bae50c4632cc58638ad33c6aa375b6109d811e76a9cc3a613c1e8887')
end
+ it 'caches sha256 of the content', :use_clean_rails_redis_caching do
+ head api(route(file_path), current_user, **options), params: params
+
+ expect(Rails.cache.fetch("blob_content_sha256:#{project.full_path}:#{response.headers['X-Gitlab-Blob-Id']}"))
+ .to eq('c440cd09bae50c4632cc58638ad33c6aa375b6109d811e76a9cc3a613c1e8887')
+
+ expect_next_instance_of(Gitlab::Git::Blob) do |instance|
+ expect(instance).not_to receive(:load_all_data!)
+ end
+
+ head api(route(file_path), current_user, **options), params: params
+ end
+
it 'returns file by commit sha' do
# This file is deleted on HEAD
file_path = "files%2Fjs%2Fcommit%2Ejs%2Ecoffee"
diff --git a/spec/requests/api/generic_packages_spec.rb b/spec/requests/api/generic_packages_spec.rb
index 4091253fb54..7e439a22e4b 100644
--- a/spec/requests/api/generic_packages_spec.rb
+++ b/spec/requests/api/generic_packages_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe API::GenericPackages do
let_it_be(:project_deploy_token_wo) { create(:project_deploy_token, deploy_token: deploy_token_wo, project: project) }
let(:user) { personal_access_token.user }
- let(:ci_build) { create(:ci_build, :running, user: user) }
+ let(:ci_build) { create(:ci_build, :running, user: user, project: project) }
let(:snowplow_standard_context_params) { { user: user, project: project, namespace: project.namespace } }
def auth_header
@@ -388,9 +388,11 @@ RSpec.describe API::GenericPackages do
end
context 'event tracking' do
+ let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, user: user } }
+
subject { upload_file(params, workhorse_headers.merge(personal_access_token_header)) }
- it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package'
+ it_behaves_like 'a package tracking event', described_class.name, 'push_package'
end
it 'rejects request without a file from workhorse' do
@@ -542,13 +544,15 @@ RSpec.describe API::GenericPackages do
end
context 'event tracking' do
+ let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, user: user } }
+
before do
project.add_developer(user)
end
subject { download_file(personal_access_token_header) }
- it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package'
+ it_behaves_like 'a package tracking event', described_class.name, 'pull_package'
end
it 'rejects a malicious file name request' do
diff --git a/spec/requests/api/go_proxy_spec.rb b/spec/requests/api/go_proxy_spec.rb
index e678b6cf1c8..0143340de11 100644
--- a/spec/requests/api/go_proxy_spec.rb
+++ b/spec/requests/api/go_proxy_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe API::GoProxy do
let_it_be(:base) { "#{Settings.build_gitlab_go_url}/#{project.full_path}" }
let_it_be(:oauth) { create :oauth_access_token, scopes: 'api', resource_owner: user }
- let_it_be(:job) { create :ci_build, user: user, status: :running }
+ let_it_be(:job) { create :ci_build, user: user, status: :running, project: project }
let_it_be(:pa_token) { create :personal_access_token, user: user }
let_it_be(:modules) do
diff --git a/spec/requests/api/graphql/boards/board_list_issues_query_spec.rb b/spec/requests/api/graphql/boards/board_list_issues_query_spec.rb
index 3628171fcc1..008241b8055 100644
--- a/spec/requests/api/graphql/boards/board_list_issues_query_spec.rb
+++ b/spec/requests/api/graphql/boards/board_list_issues_query_spec.rb
@@ -48,13 +48,18 @@ RSpec.describe 'get board lists' do
issues_data.map { |i| i['title'] }
end
+ def issue_relative_positions
+ issues_data.map { |i| i['relativePosition'] }
+ end
+
shared_examples 'group and project board list issues query' do
let!(:board) { create(:board, resource_parent: board_parent) }
let!(:label_list) { create(:list, board: board, label: label, position: 10) }
let!(:issue1) { create(:issue, project: issue_project, labels: [label, label2], relative_position: 9) }
let!(:issue2) { create(:issue, project: issue_project, labels: [label, label2], relative_position: 2) }
- let!(:issue3) { create(:issue, project: issue_project, labels: [label], relative_position: 9) }
- let!(:issue4) { create(:issue, project: issue_project, labels: [label2], relative_position: 432) }
+ let!(:issue3) { create(:issue, project: issue_project, labels: [label, label2], relative_position: nil) }
+ let!(:issue4) { create(:issue, project: issue_project, labels: [label], relative_position: 9) }
+ let!(:issue5) { create(:issue, project: issue_project, labels: [label2], relative_position: 432) }
context 'when the user does not have access to the board' do
it 'returns nil' do
@@ -69,10 +74,11 @@ RSpec.describe 'get board lists' do
board_parent.add_reporter(user)
end
- it 'can access the issues' do
+ it 'can access the issues', :aggregate_failures do
post_graphql(query("id: \"#{global_id_of(label_list)}\""), current_user: user)
- expect(issue_titles).to eq([issue2.title, issue1.title])
+ expect(issue_titles).to eq([issue2.title, issue1.title, issue3.title])
+ expect(issue_relative_positions).not_to include(nil)
end
end
end
diff --git a/spec/requests/api/graphql/ci/stages_spec.rb b/spec/requests/api/graphql/ci/stages_spec.rb
index cd48a24b9c8..50d2cf75097 100644
--- a/spec/requests/api/graphql/ci/stages_spec.rb
+++ b/spec/requests/api/graphql/ci/stages_spec.rb
@@ -4,11 +4,13 @@ require 'spec_helper'
RSpec.describe 'Query.project.pipeline.stages' do
include GraphqlHelpers
- let(:project) { create(:project, :repository, :public) }
- let(:user) { create(:user) }
- let(:pipeline) { create(:ci_pipeline, project: project, user: user) }
- let(:stage_graphql_data) { graphql_data['project']['pipeline']['stages'] }
+ subject(:post_query) { post_graphql(query, current_user: user) }
+ let_it_be(:project) { create(:project, :repository, :public) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
+
+ let(:stage_nodes) { graphql_data_at(:project, :pipeline, :stages, :nodes) }
let(:params) { {} }
let(:fields) do
@@ -33,14 +35,42 @@ RSpec.describe 'Query.project.pipeline.stages' do
)
end
- before do
+ before_all do
create(:ci_stage_entity, pipeline: pipeline, name: 'deploy')
- post_graphql(query, current_user: user)
+ create_list(:ci_build, 2, pipeline: pipeline, stage: 'deploy')
end
- it_behaves_like 'a working graphql query'
+ it_behaves_like 'a working graphql query' do
+ before do
+ post_query
+ end
+ end
it 'returns the stage of a pipeline' do
- expect(stage_graphql_data['nodes'].first['name']).to eq('deploy')
+ post_query
+
+ expect(stage_nodes.first['name']).to eq('deploy')
+ end
+
+ describe 'job pagination' do
+ let(:job_nodes) { graphql_dig_at(stage_nodes, :jobs, :nodes) }
+
+ it 'returns up to default limit jobs per stage' do
+ post_query
+
+ expect(job_nodes.count).to eq(2)
+ end
+
+ context 'when the limit is manually set' do
+ before do
+ stub_application_setting(jobs_per_stage_page_size: 1)
+ end
+
+ it 'returns up to custom limit jobs per stage' do
+ post_query
+
+ expect(job_nodes.count).to eq(1)
+ end
+ end
end
end
diff --git a/spec/requests/api/graphql/current_user/groups_query_spec.rb b/spec/requests/api/graphql/current_user/groups_query_spec.rb
new file mode 100644
index 00000000000..39f323b21a3
--- /dev/null
+++ b/spec/requests/api/graphql/current_user/groups_query_spec.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Query current user groups' do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:guest_group) { create(:group, name: 'public guest', path: 'public-guest') }
+ let_it_be(:private_maintainer_group) { create(:group, :private, name: 'b private maintainer', path: 'b-private-maintainer') }
+ let_it_be(:public_developer_group) { create(:group, :private, project_creation_level: nil, name: 'c public developer', path: 'c-public-developer') }
+ let_it_be(:public_maintainer_group) { create(:group, :private, name: 'a public maintainer', path: 'a-public-maintainer') }
+
+ let(:group_arguments) { {} }
+ let(:current_user) { user }
+
+ let(:fields) do
+ <<~GRAPHQL
+ nodes { id path fullPath name }
+ GRAPHQL
+ end
+
+ let(:query) do
+ graphql_query_for('currentUser', {}, query_graphql_field('groups', group_arguments, fields))
+ end
+
+ before_all do
+ guest_group.add_guest(user)
+ private_maintainer_group.add_maintainer(user)
+ public_developer_group.add_developer(user)
+ public_maintainer_group.add_maintainer(user)
+ end
+
+ subject { graphql_data.dig('currentUser', 'groups', 'nodes') }
+
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ it 'avoids N+1 queries', :request_store do
+ control = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) }
+
+ new_group = create(:group, :private)
+ new_group.add_maintainer(current_user)
+
+ expect { post_graphql(query, current_user: current_user) }.not_to exceed_query_limit(control)
+ end
+
+ it 'returns all groups where the user is a direct member' do
+ is_expected.to match(
+ expected_group_hash(
+ public_maintainer_group,
+ private_maintainer_group,
+ public_developer_group,
+ guest_group
+ )
+ )
+ end
+
+ context 'when permission_scope is CREATE_PROJECTS' do
+ let(:group_arguments) { { permission_scope: :CREATE_PROJECTS } }
+
+ specify do
+ is_expected.to match(
+ expected_group_hash(
+ public_maintainer_group,
+ private_maintainer_group,
+ public_developer_group
+ )
+ )
+ end
+
+ context 'when search is provided' do
+ let(:group_arguments) { { permission_scope: :CREATE_PROJECTS, search: 'maintainer' } }
+
+ specify do
+ is_expected.to match(
+ expected_group_hash(
+ public_maintainer_group,
+ private_maintainer_group
+ )
+ )
+ end
+ end
+ end
+
+ context 'when search is provided' do
+ let(:group_arguments) { { search: 'maintainer' } }
+
+ specify do
+ is_expected.to match(
+ expected_group_hash(
+ public_maintainer_group,
+ private_maintainer_group
+ )
+ )
+ end
+ end
+
+ def expected_group_hash(*groups)
+ groups.map do |group|
+ {
+ 'id' => group.to_global_id.to_s,
+ 'name' => group.name,
+ 'path' => group.path,
+ 'fullPath' => group.full_path
+ }
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/group/dependency_proxy_blobs_spec.rb b/spec/requests/api/graphql/group/dependency_proxy_blobs_spec.rb
new file mode 100644
index 00000000000..cdb21512894
--- /dev/null
+++ b/spec/requests/api/graphql/group/dependency_proxy_blobs_spec.rb
@@ -0,0 +1,127 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe 'getting dependency proxy blobs in a group' do
+ using RSpec::Parameterized::TableSyntax
+ include GraphqlHelpers
+
+ let_it_be(:owner) { create(:user) }
+ let_it_be_with_reload(:group) { create(:group) }
+ let_it_be(:blob) { create(:dependency_proxy_blob, group: group) }
+ let_it_be(:blob2) { create(:dependency_proxy_blob, file_name: 'blob2.json', group: group) }
+ let_it_be(:blobs) { [blob, blob2].flatten }
+
+ let(:dependency_proxy_blob_fields) do
+ <<~GQL
+ edges {
+ node {
+ #{all_graphql_fields_for('dependency_proxy_blobs'.classify, max_depth: 1)}
+ }
+ }
+ GQL
+ end
+
+ let(:fields) do
+ <<~GQL
+ #{query_graphql_field('dependency_proxy_blobs', {}, dependency_proxy_blob_fields)}
+ dependencyProxyBlobCount
+ dependencyProxyTotalSize
+ GQL
+ end
+
+ let(:query) do
+ graphql_query_for(
+ 'group',
+ { 'fullPath' => group.full_path },
+ fields
+ )
+ end
+
+ let(:user) { owner }
+ let(:variables) { {} }
+ let(:dependency_proxy_blobs_response) { graphql_data.dig('group', 'dependencyProxyBlobs', 'edges') }
+ let(:dependency_proxy_blob_count_response) { graphql_data.dig('group', 'dependencyProxyBlobCount') }
+ let(:dependency_proxy_total_size_response) { graphql_data.dig('group', 'dependencyProxyTotalSize') }
+
+ before do
+ stub_config(dependency_proxy: { enabled: true })
+ group.add_owner(owner)
+ end
+
+ subject { post_graphql(query, current_user: user, variables: variables) }
+
+ it_behaves_like 'a working graphql query' do
+ before do
+ subject
+ end
+ end
+
+ context 'with different permissions' do
+ let_it_be(:user) { create(:user) }
+
+ where(:group_visibility, :role, :access_granted) do
+ :private | :maintainer | true
+ :private | :developer | true
+ :private | :reporter | true
+ :private | :guest | true
+ :private | :anonymous | false
+ :public | :maintainer | true
+ :public | :developer | true
+ :public | :reporter | true
+ :public | :guest | true
+ :public | :anonymous | false
+ end
+
+ with_them do
+ before do
+ group.update_column(:visibility_level, Gitlab::VisibilityLevel.const_get(group_visibility.to_s.upcase, false))
+ group.add_user(user, role) unless role == :anonymous
+ end
+
+ it 'return the proper response' do
+ subject
+
+ if access_granted
+ expect(dependency_proxy_blobs_response.size).to eq(blobs.size)
+ else
+ expect(dependency_proxy_blobs_response).to be_blank
+ end
+ end
+ end
+ end
+
+ context 'limiting the number of blobs' do
+ let(:limit) { 1 }
+ let(:variables) do
+ { path: group.full_path, n: limit }
+ end
+
+ let(:query) do
+ <<~GQL
+ query($path: ID!, $n: Int) {
+ group(fullPath: $path) {
+ dependencyProxyBlobs(first: $n) { #{dependency_proxy_blob_fields} }
+ }
+ }
+ GQL
+ end
+
+ it 'only returns N blobs' do
+ subject
+
+ expect(dependency_proxy_blobs_response.size).to eq(limit)
+ end
+ end
+
+ it 'returns the total count of blobs' do
+ subject
+
+ expect(dependency_proxy_blob_count_response).to eq(blobs.size)
+ end
+
+ it 'returns the total size' do
+ subject
+ expected_size = blobs.inject(0) { |sum, blob| sum + blob.size }
+ expect(dependency_proxy_total_size_response).to eq(ActiveSupport::NumberHelper.number_to_human_size(expected_size))
+ end
+end
diff --git a/spec/requests/api/graphql/group/dependency_proxy_group_setting_spec.rb b/spec/requests/api/graphql/group/dependency_proxy_group_setting_spec.rb
new file mode 100644
index 00000000000..c5c6d85d1e6
--- /dev/null
+++ b/spec/requests/api/graphql/group/dependency_proxy_group_setting_spec.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe 'getting dependency proxy settings for a group' do
+ using RSpec::Parameterized::TableSyntax
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be_with_reload(:group) { create(:group) }
+
+ let(:dependency_proxy_group_setting_fields) do
+ <<~GQL
+ #{all_graphql_fields_for('dependency_proxy_setting'.classify, max_depth: 1)}
+ GQL
+ end
+
+ let(:fields) do
+ <<~GQL
+ #{query_graphql_field('dependency_proxy_setting', {}, dependency_proxy_group_setting_fields)}
+ GQL
+ end
+
+ let(:query) do
+ graphql_query_for(
+ 'group',
+ { 'fullPath' => group.full_path },
+ fields
+ )
+ end
+
+ let(:variables) { {} }
+ let(:dependency_proxy_group_setting_response) { graphql_data.dig('group', 'dependencyProxySetting') }
+
+ before do
+ stub_config(dependency_proxy: { enabled: true })
+ group.create_dependency_proxy_setting!(enabled: true)
+ end
+
+ subject { post_graphql(query, current_user: user, variables: variables) }
+
+ it_behaves_like 'a working graphql query' do
+ before do
+ subject
+ end
+ end
+
+ context 'with different permissions' do
+ where(:group_visibility, :role, :access_granted) do
+ :private | :maintainer | true
+ :private | :developer | true
+ :private | :reporter | true
+ :private | :guest | true
+ :private | :anonymous | false
+ :public | :maintainer | true
+ :public | :developer | true
+ :public | :reporter | true
+ :public | :guest | true
+ :public | :anonymous | false
+ end
+
+ with_them do
+ before do
+ group.update_column(:visibility_level, Gitlab::VisibilityLevel.const_get(group_visibility.to_s.upcase, false))
+ group.add_user(user, role) unless role == :anonymous
+ end
+
+ it 'return the proper response' do
+ subject
+
+ if access_granted
+ expect(dependency_proxy_group_setting_response).to eq('enabled' => true)
+ else
+ expect(dependency_proxy_group_setting_response).to be_blank
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/group/dependency_proxy_image_ttl_policy_spec.rb b/spec/requests/api/graphql/group/dependency_proxy_image_ttl_policy_spec.rb
new file mode 100644
index 00000000000..c8797d84906
--- /dev/null
+++ b/spec/requests/api/graphql/group/dependency_proxy_image_ttl_policy_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe 'getting dependency proxy image ttl policy for a group' do
+ using RSpec::Parameterized::TableSyntax
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be_with_reload(:group) { create(:group) }
+
+ let(:dependency_proxy_image_ttl_policy_fields) do
+ <<~GQL
+ #{all_graphql_fields_for('dependency_proxy_image_ttl_group_policy'.classify, max_depth: 1)}
+ GQL
+ end
+
+ let(:fields) do
+ <<~GQL
+ #{query_graphql_field('dependency_proxy_image_ttl_policy', {}, dependency_proxy_image_ttl_policy_fields)}
+ GQL
+ end
+
+ let(:query) do
+ graphql_query_for(
+ 'group',
+ { 'fullPath' => group.full_path },
+ fields
+ )
+ end
+
+ let(:variables) { {} }
+ let(:dependency_proxy_image_ttl_policy_response) { graphql_data.dig('group', 'dependencyProxyImageTtlPolicy') }
+
+ before do
+ stub_config(dependency_proxy: { enabled: true })
+ end
+
+ subject { post_graphql(query, current_user: user, variables: variables) }
+
+ it_behaves_like 'a working graphql query' do
+ before do
+ subject
+ end
+ end
+
+ context 'with different permissions' do
+ where(:group_visibility, :role, :access_granted) do
+ :private | :maintainer | true
+ :private | :developer | true
+ :private | :reporter | true
+ :private | :guest | true
+ :private | :anonymous | false
+ :public | :maintainer | true
+ :public | :developer | true
+ :public | :reporter | true
+ :public | :guest | true
+ :public | :anonymous | false
+ end
+
+ with_them do
+ before do
+ group.update_column(:visibility_level, Gitlab::VisibilityLevel.const_get(group_visibility.to_s.upcase, false))
+ group.add_user(user, role) unless role == :anonymous
+ end
+
+ it 'return the proper response' do
+ subject
+
+ if access_granted
+ expect(dependency_proxy_image_ttl_policy_response).to eq("createdAt" => nil, "enabled" => false, "ttl" => 90, "updatedAt" => nil)
+ else
+ expect(dependency_proxy_image_ttl_policy_response).to be_blank
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/group/dependency_proxy_manifests_spec.rb b/spec/requests/api/graphql/group/dependency_proxy_manifests_spec.rb
new file mode 100644
index 00000000000..30e704adb92
--- /dev/null
+++ b/spec/requests/api/graphql/group/dependency_proxy_manifests_spec.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe 'getting dependency proxy manifests in a group' do
+ using RSpec::Parameterized::TableSyntax
+ include GraphqlHelpers
+
+ let_it_be(:owner) { create(:user) }
+ let_it_be_with_reload(:group) { create(:group) }
+ let_it_be(:manifest) { create(:dependency_proxy_manifest, group: group) }
+ let_it_be(:manifest2) { create(:dependency_proxy_manifest, file_name: 'image2.json', group: group) }
+ let_it_be(:manifests) { [manifest, manifest2].flatten }
+
+ let(:dependency_proxy_manifest_fields) do
+ <<~GQL
+ edges {
+ node {
+ #{all_graphql_fields_for('dependency_proxy_manifests'.classify, max_depth: 1)}
+ }
+ }
+ GQL
+ end
+
+ let(:fields) do
+ <<~GQL
+ #{query_graphql_field('dependency_proxy_manifests', {}, dependency_proxy_manifest_fields)}
+ dependencyProxyImageCount
+ GQL
+ end
+
+ let(:query) do
+ graphql_query_for(
+ 'group',
+ { 'fullPath' => group.full_path },
+ fields
+ )
+ end
+
+ let(:user) { owner }
+ let(:variables) { {} }
+ let(:dependency_proxy_manifests_response) { graphql_data.dig('group', 'dependencyProxyManifests', 'edges') }
+ let(:dependency_proxy_image_count_response) { graphql_data.dig('group', 'dependencyProxyImageCount') }
+
+ before do
+ stub_config(dependency_proxy: { enabled: true })
+ group.add_owner(owner)
+ end
+
+ subject { post_graphql(query, current_user: user, variables: variables) }
+
+ it_behaves_like 'a working graphql query' do
+ before do
+ subject
+ end
+ end
+
+ context 'with different permissions' do
+ let_it_be(:user) { create(:user) }
+
+ where(:group_visibility, :role, :access_granted) do
+ :private | :maintainer | true
+ :private | :developer | true
+ :private | :reporter | true
+ :private | :guest | true
+ :private | :anonymous | false
+ :public | :maintainer | true
+ :public | :developer | true
+ :public | :reporter | true
+ :public | :guest | true
+ :public | :anonymous | false
+ end
+
+ with_them do
+ before do
+ group.update_column(:visibility_level, Gitlab::VisibilityLevel.const_get(group_visibility.to_s.upcase, false))
+ group.add_user(user, role) unless role == :anonymous
+ end
+
+ it 'return the proper response' do
+ subject
+
+ if access_granted
+ expect(dependency_proxy_manifests_response.size).to eq(manifests.size)
+ else
+ expect(dependency_proxy_manifests_response).to be_blank
+ end
+ end
+ end
+ end
+
+ context 'limiting the number of manifests' do
+ let(:limit) { 1 }
+ let(:variables) do
+ { path: group.full_path, n: limit }
+ end
+
+ let(:query) do
+ <<~GQL
+ query($path: ID!, $n: Int) {
+ group(fullPath: $path) {
+ dependencyProxyManifests(first: $n) { #{dependency_proxy_manifest_fields} }
+ }
+ }
+ GQL
+ end
+
+ it 'only returns N manifests' do
+ subject
+
+ expect(dependency_proxy_manifests_response.size).to eq(limit)
+ end
+ end
+
+ it 'returns the total count of manifests' do
+ subject
+
+ expect(dependency_proxy_image_count_response).to eq(manifests.size)
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb b/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb
index 1692cfbcf84..f992e46879f 100644
--- a/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb
+++ b/spec/requests/api/graphql/mutations/admin/sidekiq_queues/delete_jobs_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe 'Deleting Sidekiq jobs', :clean_gitlab_redis_queues do
let(:queue) { 'authorized_projects' }
- let(:variables) { { user: admin.username, queue_name: queue } }
+ let(:variables) { { user: admin.username, worker_class: 'AuthorizedProjectsWorker', queue_name: queue } }
let(:mutation) { graphql_mutation(:admin_sidekiq_queues_delete_jobs, variables) }
def mutation_response
diff --git a/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb b/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb
new file mode 100644
index 00000000000..07fd57a2cee
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/custom_emoji/destroy_spec.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Deletion of custom emoji' do
+ include GraphqlHelpers
+
+ let_it_be(:group) { create(:group) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:user2) { create(:user) }
+ let_it_be_with_reload(:custom_emoji) { create(:custom_emoji, group: group, creator: user2) }
+
+ let(:mutation) do
+ variables = {
+ id: GitlabSchema.id_from_object(custom_emoji).to_s
+ }
+
+ graphql_mutation(:destroy_custom_emoji, variables)
+ end
+
+ shared_examples 'does not delete custom emoji' do
+ it 'does not change count' do
+ expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(CustomEmoji, :count)
+ end
+ end
+
+ shared_examples 'deletes custom emoji' do
+ it 'changes count' do
+ expect { post_graphql_mutation(mutation, current_user: current_user) }.to change(CustomEmoji, :count).by(-1)
+ end
+ end
+
+ context 'when the user' do
+ context 'has no permissions' do
+ it_behaves_like 'does not delete custom emoji'
+ end
+
+ context 'when the user is developer and not creator of custom emoji' do
+ before do
+ group.add_developer(current_user)
+ end
+
+ it_behaves_like 'does not delete custom emoji'
+ end
+ end
+
+ context 'when user' do
+ context 'is maintainer' do
+ before do
+ group.add_maintainer(current_user)
+ end
+
+ it_behaves_like 'deletes custom emoji'
+ end
+
+ context 'is owner' do
+ before do
+ group.add_owner(current_user)
+ end
+
+ it_behaves_like 'deletes custom emoji'
+ end
+
+ context 'is developer and creator of the emoji' do
+ before do
+ group.add_developer(current_user)
+ custom_emoji.update_attribute(:creator, current_user)
+ end
+
+ it_behaves_like 'deletes custom emoji'
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb b/spec/requests/api/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb
new file mode 100644
index 00000000000..c9e9a22ee0b
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/dependency_proxy/image_ttl_group_policy/update_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Updating the dependency proxy image ttl policy' do
+ include GraphqlHelpers
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:user) { create(:user) }
+
+ let(:params) do
+ {
+ group_path: group.full_path,
+ enabled: false,
+ ttl: 2
+ }
+ end
+
+ let(:mutation) do
+ graphql_mutation(:update_dependency_proxy_image_ttl_group_policy, params) do
+ <<~QL
+ dependencyProxyImageTtlPolicy {
+ enabled
+ ttl
+ }
+ errors
+ QL
+ end
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:update_dependency_proxy_image_ttl_group_policy) }
+ let(:ttl_policy_response) { mutation_response['dependencyProxyImageTtlPolicy'] }
+
+ before do
+ stub_config(dependency_proxy: { enabled: true })
+ end
+
+ describe 'post graphql mutation' do
+ subject { post_graphql_mutation(mutation, current_user: user) }
+
+ let_it_be(:ttl_policy, reload: true) { create(:image_ttl_group_policy) }
+ let_it_be(:group, reload: true) { ttl_policy.group }
+
+ context 'without permission' do
+ it 'returns no response' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response).to be_nil
+ end
+ end
+
+ context 'with permission' do
+ before do
+ group.add_developer(user)
+ end
+
+ it 'returns the updated dependency proxy image ttl policy', :aggregate_failures do
+ subject
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['errors']).to be_empty
+ expect(ttl_policy_response).to include(
+ 'enabled' => params[:enabled],
+ 'ttl' => params[:ttl]
+ )
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/issues/create_spec.rb b/spec/requests/api/graphql/mutations/issues/create_spec.rb
index 66450f8c604..886f3140086 100644
--- a/spec/requests/api/graphql/mutations/issues/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/issues/create_spec.rb
@@ -39,11 +39,14 @@ RSpec.describe 'Create an issue' do
end
it 'creates the issue' do
- post_graphql_mutation(mutation, current_user: current_user)
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ end.to change(Issue, :count).by(1)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['issue']).to include(input)
expect(mutation_response['issue']).to include('discussionLocked' => true)
+ expect(Issue.last.work_item_type.base_type).to eq('issue')
end
end
end
diff --git a/spec/requests/api/graphql/mutations/issues/update_spec.rb b/spec/requests/api/graphql/mutations/issues/update_spec.rb
index c3aaf090703..0f2eeb90894 100644
--- a/spec/requests/api/graphql/mutations/issues/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/issues/update_spec.rb
@@ -44,6 +44,19 @@ RSpec.describe 'Update of an existing issue' do
expect(mutation_response['issue']).to include('discussionLocked' => true)
end
+ context 'when issue_type is updated' do
+ let(:input) { { 'iid' => issue.iid.to_s, 'type' => 'INCIDENT' } }
+
+ it 'updates issue_type and work_item_type' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ issue.reload
+ end.to change { issue.work_item_type.base_type }.from('issue').to('incident').and(
+ change(issue, :issue_type).from('issue').to('incident')
+ )
+ end
+ end
+
context 'setting labels' do
let(:mutation) do
graphql_mutation(:update_issue, input_params) do
diff --git a/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb b/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb
index 80376f56ee8..a540386a9de 100644
--- a/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb
+++ b/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb
@@ -3,6 +3,7 @@ require 'spec_helper'
RSpec.describe 'sentry errors requests' do
include GraphqlHelpers
+
let_it_be(:project) { create(:project, :repository) }
let_it_be(:project_setting) { create(:project_error_tracking_setting, project: project) }
let_it_be(:current_user) { project.owner }
@@ -30,7 +31,7 @@ RSpec.describe 'sentry errors requests' do
let(:error_data) { graphql_data.dig('project', 'sentryErrors', 'detailedError') }
- it 'returns a successful response', :aggregate_failures, :quarantine do
+ it 'returns a successful response', :aggregate_failures do
post_graphql(query, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
@@ -48,11 +49,9 @@ RSpec.describe 'sentry errors requests' do
end
end
- context 'reactive cache returns data' do
+ context 'when reactive cache returns data' do
before do
- allow_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting)
- .to receive(:issue_details)
- .and_return(issue: sentry_detailed_error)
+ stub_setting_for(:issue_details, issue: sentry_detailed_error)
post_graphql(query, current_user: current_user)
end
@@ -72,7 +71,7 @@ RSpec.describe 'sentry errors requests' do
end
end
- context 'user does not have permission' do
+ context 'when user does not have permission' do
let(:current_user) { create(:user) }
it 'is expected to return an empty error' do
@@ -81,11 +80,9 @@ RSpec.describe 'sentry errors requests' do
end
end
- context 'sentry api returns an error' do
+ context 'when sentry api returns an error' do
before do
- expect_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting)
- .to receive(:issue_details)
- .and_return(error: 'error message')
+ stub_setting_for(:issue_details, error: 'error message')
post_graphql(query, current_user: current_user)
end
@@ -140,11 +137,11 @@ RSpec.describe 'sentry errors requests' do
end
end
- context 'reactive cache returns data' do
+ context 'when reactive cache returns data' do
before do
- expect_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting)
- .to receive(:list_sentry_issues)
- .and_return(issues: [sentry_error], pagination: pagination)
+ stub_setting_for(:list_sentry_issues,
+ issues: [sentry_error],
+ pagination: pagination)
post_graphql(query, current_user: current_user)
end
@@ -177,11 +174,9 @@ RSpec.describe 'sentry errors requests' do
end
end
- context 'sentry api itself errors out' do
+ context 'when sentry api itself errors out' do
before do
- expect_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting)
- .to receive(:list_sentry_issues)
- .and_return(error: 'error message')
+ stub_setting_for(:list_sentry_issues, error: 'error message')
post_graphql(query, current_user: current_user)
end
@@ -223,18 +218,16 @@ RSpec.describe 'sentry errors requests' do
end
end
- context 'reactive cache returns data' do
+ context 'when reactive cache returns data' do
before do
- allow_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting)
- .to receive(:issue_latest_event)
- .and_return(latest_event: sentry_stack_trace)
+ stub_setting_for(:issue_latest_event, latest_event: sentry_stack_trace)
post_graphql(query, current_user: current_user)
end
it_behaves_like 'setting stack trace error'
- context 'user does not have permission' do
+ context 'when user does not have permission' do
let(:current_user) { create(:user) }
it 'is expected to return an empty error' do
@@ -243,11 +236,9 @@ RSpec.describe 'sentry errors requests' do
end
end
- context 'sentry api returns an error' do
+ context 'when sentry api returns an error' do
before do
- expect_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting)
- .to receive(:issue_latest_event)
- .and_return(error: 'error message')
+ stub_setting_for(:issue_latest_event, error: 'error message')
post_graphql(query, current_user: current_user)
end
@@ -257,4 +248,12 @@ RSpec.describe 'sentry errors requests' do
end
end
end
+
+ private
+
+ def stub_setting_for(method, **return_value)
+ allow_next_found_instance_of(ErrorTracking::ProjectErrorTrackingSetting) do |setting|
+ allow(setting).to receive(method).and_return(**return_value)
+ end
+ end
end
diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb
index ff0d7ecceb5..c6b4d82bf15 100644
--- a/spec/requests/api/graphql/project/issues_spec.rb
+++ b/spec/requests/api/graphql/project/issues_spec.rb
@@ -61,6 +61,34 @@ RSpec.describe 'getting an issue list for a project' do
end
end
+ context 'filtering by my_reaction_emoji' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:upvote_award) { create(:award_emoji, :upvote, user: current_user, awardable: issue_a) }
+
+ let(:issue_a_gid) { issue_a.to_global_id.to_s }
+ let(:issue_b_gid) { issue_b.to_global_id.to_s }
+
+ where(:value, :gids) do
+ 'thumbsup' | lazy { [issue_a_gid] }
+ 'ANY' | lazy { [issue_a_gid] }
+ 'any' | lazy { [issue_a_gid] }
+ 'AnY' | lazy { [issue_a_gid] }
+ 'NONE' | lazy { [issue_b_gid] }
+ 'thumbsdown' | lazy { [] }
+ end
+
+ with_them do
+ let(:issue_filter_params) { { my_reaction_emoji: value } }
+
+ it 'returns correctly filtered issues' do
+ post_graphql(query, current_user: current_user)
+
+ expect(graphql_dig_at(issues_data, :node, :id)).to eq(gids)
+ end
+ end
+ end
+
context 'when limiting the number of results' do
let(:query) do
<<~GQL
diff --git a/spec/requests/api/graphql/project/pipeline_spec.rb b/spec/requests/api/graphql/project/pipeline_spec.rb
index cb6755640a9..d46ef313563 100644
--- a/spec/requests/api/graphql/project/pipeline_spec.rb
+++ b/spec/requests/api/graphql/project/pipeline_spec.rb
@@ -311,6 +311,10 @@ RSpec.describe 'getting pipeline information nested in a project' do
end
it 'does not generate N+1 queries', :request_store, :use_sql_query_cache do
+ # create extra statuses
+ create(:generic_commit_status, :pending, name: 'generic-build-a', pipeline: pipeline, stage_idx: 0, stage: 'build')
+ create(:ci_bridge, :failed, name: 'deploy-a', pipeline: pipeline, stage_idx: 2, stage: 'deploy')
+
# warm up
post_graphql(query, current_user: current_user)
@@ -318,9 +322,11 @@ RSpec.describe 'getting pipeline information nested in a project' do
post_graphql(query, current_user: current_user)
end
- create(:ci_build, name: 'test-a', pipeline: pipeline, stage_idx: 1, stage: 'test')
- create(:ci_build, name: 'test-b', pipeline: pipeline, stage_idx: 1, stage: 'test')
- create(:ci_build, name: 'deploy-a', pipeline: pipeline, stage_idx: 2, stage: 'deploy')
+ create(:generic_commit_status, :pending, name: 'generic-build-b', pipeline: pipeline, stage_idx: 0, stage: 'build')
+ create(:ci_build, :failed, name: 'test-a', pipeline: pipeline, stage_idx: 1, stage: 'test')
+ create(:ci_build, :running, name: 'test-b', pipeline: pipeline, stage_idx: 1, stage: 'test')
+ create(:ci_build, :pending, name: 'deploy-b', pipeline: pipeline, stage_idx: 2, stage: 'deploy')
+ create(:ci_bridge, :failed, name: 'deploy-c', pipeline: pipeline, stage_idx: 2, stage: 'deploy')
expect do
post_graphql(query, current_user: current_user)
diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb
index 30df47ccc41..38abedde7da 100644
--- a/spec/requests/api/groups_spec.rb
+++ b/spec/requests/api/groups_spec.rb
@@ -158,6 +158,127 @@ RSpec.describe API::Groups do
end
end
+ context 'pagination strategies' do
+ let_it_be(:group_1) { create(:group, name: '1_group') }
+ let_it_be(:group_2) { create(:group, name: '2_group') }
+
+ context 'when the user is anonymous' do
+ context 'offset pagination' do
+ context 'on making requests beyond the allowed offset pagination threshold' do
+ it 'returns error and suggests to use keyset pagination' do
+ get api('/groups'), params: { page: 3000, per_page: 25 }
+
+ expect(response).to have_gitlab_http_status(:method_not_allowed)
+ expect(json_response['error']).to eq(
+ 'Offset pagination has a maximum allowed offset of 50000 for requests that return objects of type Group. '\
+ 'Remaining records can be retrieved using keyset pagination.'
+ )
+ end
+
+ context 'when the feature flag `keyset_pagination_for_groups_api` is disabled' do
+ before do
+ stub_feature_flags(keyset_pagination_for_groups_api: false)
+ end
+
+ it 'returns successful response' do
+ get api('/groups'), params: { page: 3000, per_page: 25 }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ context 'on making requests below the allowed offset pagination threshold' do
+ it 'paginates the records' do
+ get api('/groups'), params: { page: 1, per_page: 1 }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ records = json_response
+ expect(records.size).to eq(1)
+ expect(records.first['id']).to eq(group_1.id)
+
+ # next page
+
+ get api('/groups'), params: { page: 2, per_page: 1 }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ records = Gitlab::Json.parse(response.body)
+ expect(records.size).to eq(1)
+ expect(records.first['id']).to eq(group_2.id)
+ end
+ end
+ end
+
+ context 'keyset pagination' do
+ def pagination_links(response)
+ link = response.headers['LINK']
+ return unless link
+
+ link.split(',').map do |link|
+ match = link.match(/<(?<url>.*)>; rel="(?<rel>\w+)"/)
+ break nil unless match
+
+ { url: match[:url], rel: match[:rel] }
+ end.compact
+ end
+
+ def params_for_next_page(response)
+ next_url = pagination_links(response).find { |link| link[:rel] == 'next' }[:url]
+ Rack::Utils.parse_query(URI.parse(next_url).query)
+ end
+
+ context 'on making requests with supported ordering structure' do
+ it 'paginates the records correctly' do
+ # first page
+ get api('/groups'), params: { pagination: 'keyset', per_page: 1 }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ records = json_response
+ expect(records.size).to eq(1)
+ expect(records.first['id']).to eq(group_1.id)
+
+ params_for_next_page = params_for_next_page(response)
+ expect(params_for_next_page).to include('cursor')
+
+ get api('/groups'), params: params_for_next_page
+
+ expect(response).to have_gitlab_http_status(:ok)
+ records = Gitlab::Json.parse(response.body)
+ expect(records.size).to eq(1)
+ expect(records.first['id']).to eq(group_2.id)
+ end
+
+ context 'when the feature flag `keyset_pagination_for_groups_api` is disabled' do
+ before do
+ stub_feature_flags(keyset_pagination_for_groups_api: false)
+ end
+
+ it 'ignores the keyset pagination params and performs offset pagination' do
+ get api('/groups'), params: { pagination: 'keyset', per_page: 1 }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ records = json_response
+ expect(records.size).to eq(1)
+ expect(records.first['id']).to eq(group_1.id)
+
+ params_for_next_page = params_for_next_page(response)
+ expect(params_for_next_page).not_to include('cursor')
+ end
+ end
+ end
+
+ context 'on making requests with unsupported ordering structure' do
+ it 'returns error' do
+ get api('/groups'), params: { pagination: 'keyset', per_page: 1, order_by: 'path', sort: 'desc' }
+
+ expect(response).to have_gitlab_http_status(:method_not_allowed)
+ expect(json_response['error']).to eq('Keyset pagination is not yet available for this type of request')
+ end
+ end
+ end
+ end
+ end
+
context "when authenticated as admin" do
it "admin: returns an array of all groups" do
get api("/groups", admin)
diff --git a/spec/requests/api/helm_packages_spec.rb b/spec/requests/api/helm_packages_spec.rb
index 08b4489a6e3..3236857c5fc 100644
--- a/spec/requests/api/helm_packages_spec.rb
+++ b/spec/requests/api/helm_packages_spec.rb
@@ -9,16 +9,32 @@ RSpec.describe API::HelmPackages do
let_it_be_with_reload(:project) { create(:project, :public) }
let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) }
let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
- let_it_be(:package) { create(:helm_package, project: project) }
+ let_it_be(:package) { create(:helm_package, project: project, without_package_files: true) }
+ let_it_be(:package_file1) { create(:helm_package_file, package: package) }
+ let_it_be(:package_file2) { create(:helm_package_file, package: package) }
+ let_it_be(:package2) { create(:helm_package, project: project, without_package_files: true) }
+ let_it_be(:package_file2_1) { create(:helm_package_file, package: package2, file_sha256: 'file2', file_name: 'filename2.tgz', description: 'hello from stable channel') }
+ let_it_be(:package_file2_2) { create(:helm_package_file, package: package2, file_sha256: 'file2', file_name: 'filename2.tgz', channel: 'test', description: 'hello from test channel') }
+ let_it_be(:other_package) { create(:npm_package, project: project) }
describe 'GET /api/v4/projects/:id/packages/helm/:channel/index.yaml' do
- it_behaves_like 'handling helm chart index requests' do
- let(:url) { "/projects/#{project.id}/packages/helm/#{package.package_files.first.helm_channel}/index.yaml" }
+ let(:url) { "/projects/#{project_id}/packages/helm/stable/index.yaml" }
+
+ context 'with a project id' do
+ let(:project_id) { project.id }
+
+ it_behaves_like 'handling helm chart index requests'
+ end
+
+ context 'with an url encoded project id' do
+ let(:project_id) { ERB::Util.url_encode(project.full_path) }
+
+ it_behaves_like 'handling helm chart index requests'
end
end
describe 'GET /api/v4/projects/:id/packages/helm/:channel/charts/:file_name.tgz' do
- let(:url) { "/projects/#{project.id}/packages/helm/#{package.package_files.first.helm_channel}/charts/#{package.name}-#{package.version}.tgz" }
+ let(:url) { "/projects/#{project.id}/packages/helm/stable/charts/#{package.name}-#{package.version}.tgz" }
subject { get api(url), headers: headers }
diff --git a/spec/requests/api/internal/kubernetes_spec.rb b/spec/requests/api/internal/kubernetes_spec.rb
index 2acf6951d50..24422f7b0dd 100644
--- a/spec/requests/api/internal/kubernetes_spec.rb
+++ b/spec/requests/api/internal/kubernetes_spec.rb
@@ -93,6 +93,48 @@ RSpec.describe API::Internal::Kubernetes do
end
end
+ describe 'POST /internal/kubernetes/agent_configuration' do
+ def send_request(headers: {}, params: {})
+ post api('/internal/kubernetes/agent_configuration'), params: params, headers: headers.reverse_merge(jwt_auth_headers)
+ end
+
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, namespace: group) }
+ let_it_be(:agent) { create(:cluster_agent, project: project) }
+ let_it_be(:config) do
+ {
+ ci_access: {
+ groups: [
+ { id: group.full_path, default_namespace: 'production' }
+ ],
+ projects: [
+ { id: project.full_path, default_namespace: 'staging' }
+ ]
+ }
+ }
+ end
+
+ include_examples 'authorization'
+
+ context 'agent exists' do
+ it 'configures the agent and returns a 204' do
+ send_request(params: { agent_id: agent.id, agent_config: config })
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(agent.authorized_groups).to contain_exactly(group)
+ expect(agent.authorized_projects).to contain_exactly(project)
+ end
+ end
+
+ context 'agent does not exist' do
+ it 'returns a 404' do
+ send_request(params: { agent_id: -1, agent_config: config })
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
describe 'GET /internal/kubernetes/agent_info' do
def send_request(headers: {}, params: {})
get api('/internal/kubernetes/agent_info'), params: params, headers: headers.reverse_merge(jwt_auth_headers)
diff --git a/spec/requests/api/issues/get_group_issues_spec.rb b/spec/requests/api/issues/get_group_issues_spec.rb
index cebde747210..3663a82891c 100644
--- a/spec/requests/api/issues/get_group_issues_spec.rb
+++ b/spec/requests/api/issues/get_group_issues_spec.rb
@@ -402,14 +402,7 @@ RSpec.describe API::Issues do
expect_paginated_array_response([group_closed_issue.id, group_issue.id])
end
- shared_examples 'labels parameter' do
- it 'returns an array of labeled group issues' do
- get api(base_url, user), params: { labels: group_label.title }
-
- expect_paginated_array_response(group_issue.id)
- expect(json_response.first['labels']).to eq([group_label.title])
- end
-
+ context 'labels parameter' do
it 'returns an array of labeled group issues' do
get api(base_url, user), params: { labels: group_label.title }
@@ -458,22 +451,6 @@ RSpec.describe API::Issues do
end
end
- context 'when `optimized_issuable_label_filter` feature flag is off' do
- before do
- stub_feature_flags(optimized_issuable_label_filter: false)
- end
-
- it_behaves_like 'labels parameter'
- end
-
- context 'when `optimized_issuable_label_filter` feature flag is on' do
- before do
- stub_feature_flags(optimized_issuable_label_filter: true)
- end
-
- it_behaves_like 'labels parameter'
- end
-
it 'returns issues matching given search string for title' do
get api(base_url, user), params: { search: group_issue.title }
diff --git a/spec/requests/api/issues/issues_spec.rb b/spec/requests/api/issues/issues_spec.rb
index 125db58ed69..8a33e63b80b 100644
--- a/spec/requests/api/issues/issues_spec.rb
+++ b/spec/requests/api/issues/issues_spec.rb
@@ -3,21 +3,25 @@
require 'spec_helper'
RSpec.describe API::Issues do
+ using RSpec::Parameterized::TableSyntax
+
let_it_be(:user) { create(:user) }
let_it_be(:project, reload: true) { create(:project, :public, :repository, creator_id: user.id, namespace: user.namespace) }
let_it_be(:private_mrs_project) do
create(:project, :public, :repository, creator_id: user.id, namespace: user.namespace, merge_requests_access_level: ProjectFeature::PRIVATE)
end
- let(:user2) { create(:user) }
- let(:non_member) { create(:user) }
+ let_it_be(:user2) { create(:user) }
+ let_it_be(:non_member) { create(:user) }
let_it_be(:guest) { create(:user) }
let_it_be(:author) { create(:author) }
let_it_be(:assignee) { create(:assignee) }
- let(:admin) { create(:user, :admin) }
- let(:issue_title) { 'foo' }
- let(:issue_description) { 'closed' }
- let!(:closed_issue) do
+ let_it_be(:admin) { create(:user, :admin) }
+
+ let_it_be(:milestone) { create(:milestone, title: '1.0.0', project: project) }
+ let_it_be(:empty_milestone) { create(:milestone, title: '2.0.0', project: project) }
+
+ let_it_be(:closed_issue) do
create :closed_issue,
author: user,
assignees: [user],
@@ -29,7 +33,7 @@ RSpec.describe API::Issues do
closed_at: 1.hour.ago
end
- let!(:confidential_issue) do
+ let_it_be(:confidential_issue) do
create :issue,
:confidential,
project: project,
@@ -39,7 +43,7 @@ RSpec.describe API::Issues do
updated_at: 2.hours.ago
end
- let!(:issue) do
+ let_it_be(:issue) do
create :issue,
author: user,
assignees: [user],
@@ -47,21 +51,16 @@ RSpec.describe API::Issues do
milestone: milestone,
created_at: generate(:past_time),
updated_at: 1.hour.ago,
- title: issue_title,
- description: issue_description
+ title: 'foo',
+ description: 'bar'
end
let_it_be(:label) do
create(:label, title: 'label', color: '#FFAABB', project: project)
end
- let!(:label_link) { create(:label_link, label: label, target: issue) }
- let(:milestone) { create(:milestone, title: '1.0.0', project: project) }
- let_it_be(:empty_milestone) do
- create(:milestone, title: '2.0.0', project: project)
- end
-
- let!(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) }
+ let_it_be(:label_link) { create(:label_link, label: label, target: issue) }
+ let_it_be(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) }
let(:no_milestone_title) { 'None' }
let(:any_milestone_title) { 'Any' }
@@ -683,6 +682,71 @@ RSpec.describe API::Issues do
end
end
+ context 'filtering by milestone_id' do
+ let_it_be(:upcoming_milestone) { create(:milestone, project: project, title: "upcoming milestone", start_date: 1.day.ago, due_date: 1.day.from_now) }
+ let_it_be(:started_milestone) { create(:milestone, project: project, title: "started milestone", start_date: 2.days.ago, due_date: 1.day.ago) }
+ let_it_be(:future_milestone) { create(:milestone, project: project, title: "future milestone", start_date: 7.days.from_now, due_date: 14.days.from_now) }
+ let_it_be(:issue_upcoming) { create(:issue, project: project, state: :opened, milestone: upcoming_milestone) }
+ let_it_be(:issue_started) { create(:issue, project: project, state: :opened, milestone: started_milestone) }
+ let_it_be(:issue_future) { create(:issue, project: project, state: :opened, milestone: future_milestone) }
+ let_it_be(:issue_none) { create(:issue, project: project, state: :opened) }
+
+ let(:wildcard_started) { 'Started' }
+ let(:wildcard_upcoming) { 'Upcoming' }
+ let(:wildcard_any) { 'Any' }
+ let(:wildcard_none) { 'None' }
+
+ where(:milestone_id, :not_milestone, :expected_issues) do
+ ref(:wildcard_none) | nil | lazy { [issue_none.id] }
+ ref(:wildcard_any) | nil | lazy { [issue_future.id, issue_started.id, issue_upcoming.id, issue.id, closed_issue.id] }
+ ref(:wildcard_started) | nil | lazy { [issue_started.id, issue_upcoming.id] }
+ ref(:wildcard_upcoming) | nil | lazy { [issue_upcoming.id] }
+ ref(:wildcard_any) | "upcoming milestone" | lazy { [issue_future.id, issue_started.id, issue.id, closed_issue.id] }
+ ref(:wildcard_upcoming) | "upcoming milestone" | []
+ end
+
+ with_them do
+ it "returns correct issues when filtering with 'milestone_id' and optionally negated 'milestone'" do
+ get api('/issues', user), params: { milestone_id: milestone_id, not: not_milestone ? { milestone: not_milestone } : {} }
+
+ expect_paginated_array_response(expected_issues)
+ end
+ end
+
+ context 'negated filtering' do
+ where(:not_milestone_id, :expected_issues) do
+ ref(:wildcard_started) | lazy { [issue_future.id] }
+ ref(:wildcard_upcoming) | lazy { [issue_started.id] }
+ end
+
+ with_them do
+ it "returns correct issues when filtering with negated 'milestone_id'" do
+ get api('/issues', user), params: { not: { milestone_id: not_milestone_id } }
+
+ expect_paginated_array_response(expected_issues)
+ end
+ end
+ end
+
+ context 'when mutually exclusive params are passed' do
+ where(:params) do
+ [
+ [lazy { { milestone: "foo", milestone_id: wildcard_any } }],
+ [lazy { { not: { milestone: "foo", milestone_id: wildcard_any } } }]
+ ]
+ end
+
+ with_them do
+ it "raises an error", :aggregate_failures do
+ get api('/issues', user), params: params
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response["error"]).to include("mutually exclusive")
+ end
+ end
+ end
+ end
+
it 'returns an array of issues found by iids' do
get api('/issues', user), params: { iids: [closed_issue.iid] }
@@ -711,8 +775,8 @@ RSpec.describe API::Issues do
milestone: milestone,
created_at: closed_issue.created_at,
updated_at: 1.hour.ago,
- title: issue_title,
- description: issue_description
+ title: 'foo',
+ description: 'bar'
end
it 'page breaks first page correctly' do
@@ -751,6 +815,18 @@ RSpec.describe API::Issues do
expect_paginated_array_response([closed_issue.id, issue.id])
end
+ it 'sorts by title asc when requested' do
+ get api('/issues', user), params: { order_by: :title, sort: :asc }
+
+ expect_paginated_array_response([issue.id, closed_issue.id])
+ end
+
+ it 'sorts by title desc when requested' do
+ get api('/issues', user), params: { order_by: :title, sort: :desc }
+
+ expect_paginated_array_response([closed_issue.id, issue.id])
+ end
+
context 'with issues list sort options' do
it 'accepts only predefined order by params' do
API::Helpers::IssuesHelpers.sort_options.each do |sort_opt|
@@ -760,7 +836,7 @@ RSpec.describe API::Issues do
end
it 'fails to sort with non predefined options' do
- %w(milestone title abracadabra).each do |sort_opt|
+ %w(milestone abracadabra).each do |sort_opt|
get api('/issues', user), params: { order_by: sort_opt, sort: 'asc' }
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -1001,13 +1077,15 @@ RSpec.describe API::Issues do
end
describe 'DELETE /projects/:id/issues/:issue_iid' do
+ let(:issue_for_deletion) { create(:issue, author: user, assignees: [user], project: project) }
+
it 'rejects a non member from deleting an issue' do
- delete api("/projects/#{project.id}/issues/#{issue.iid}", non_member)
+ delete api("/projects/#{project.id}/issues/#{issue_for_deletion.iid}", non_member)
expect(response).to have_gitlab_http_status(:forbidden)
end
it 'rejects a developer from deleting an issue' do
- delete api("/projects/#{project.id}/issues/#{issue.iid}", author)
+ delete api("/projects/#{project.id}/issues/#{issue_for_deletion.iid}", author)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -1016,13 +1094,13 @@ RSpec.describe API::Issues do
let(:project) { create(:project, namespace: owner.namespace) }
it 'deletes the issue if an admin requests it' do
- delete api("/projects/#{project.id}/issues/#{issue.iid}", owner)
+ delete api("/projects/#{project.id}/issues/#{issue_for_deletion.iid}", owner)
expect(response).to have_gitlab_http_status(:no_content)
end
it_behaves_like '412 response' do
- let(:request) { api("/projects/#{project.id}/issues/#{issue.iid}", owner) }
+ let(:request) { api("/projects/#{project.id}/issues/#{issue_for_deletion.iid}", owner) }
end
end
@@ -1035,7 +1113,7 @@ RSpec.describe API::Issues do
end
it 'returns 404 when using the issue ID instead of IID' do
- delete api("/projects/#{project.id}/issues/#{issue.id}", user)
+ delete api("/projects/#{project.id}/issues/#{issue_for_deletion.id}", user)
expect(response).to have_gitlab_http_status(:not_found)
end
diff --git a/spec/requests/api/lint_spec.rb b/spec/requests/api/lint_spec.rb
index 7fe516d3daa..d7f22b9d619 100644
--- a/spec/requests/api/lint_spec.rb
+++ b/spec/requests/api/lint_spec.rb
@@ -113,7 +113,6 @@ RSpec.describe API::Lint do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['status']).to eq('valid')
expect(json_response['warnings']).not_to be_empty
- expect(json_response['status']).to eq('valid')
expect(json_response['errors']).to eq([])
end
end
diff --git a/spec/requests/api/maven_packages_spec.rb b/spec/requests/api/maven_packages_spec.rb
index c3fd02dad51..07111dd1d62 100644
--- a/spec/requests/api/maven_packages_spec.rb
+++ b/spec/requests/api/maven_packages_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe API::MavenPackages do
let_it_be(:package_file) { package.package_files.with_file_name_like('%.xml').first }
let_it_be(:jar_file) { package.package_files.with_file_name_like('%.jar').first }
let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
- let_it_be(:job, reload: true) { create(:ci_build, user: user, status: :running) }
+ let_it_be(:job, reload: true) { create(:ci_build, user: user, status: :running, project: project) }
let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) }
let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
let_it_be(:deploy_token_for_group) { create(:deploy_token, :group, read_package_registry: true, write_package_registry: true) }
diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb
index 48ded93d85f..a1daf86de31 100644
--- a/spec/requests/api/members_spec.rb
+++ b/spec/requests/api/members_spec.rb
@@ -198,7 +198,7 @@ RSpec.describe API::Members do
# Member attributes
expect(json_response['access_level']).to eq(Member::DEVELOPER)
- expect(json_response['created_at'].to_time).to be_like_time(developer.created_at)
+ expect(json_response['created_at'].to_time).to be_present
end
end
end
@@ -311,36 +311,6 @@ RSpec.describe API::Members do
expect(json_response['status']).to eq('error')
expect(json_response['message']).to eq(error_message)
end
-
- context 'with invite_source considerations', :snowplow do
- let(:params) { { user_id: user_ids, access_level: Member::DEVELOPER } }
-
- it 'tracks the invite source as api' do
- post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
- params: params
-
- expect_snowplow_event(
- category: 'Members::CreateService',
- action: 'create_member',
- label: 'members-api',
- property: 'existing_user',
- user: maintainer
- )
- end
-
- it 'tracks the invite source from params' do
- post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
- params: params.merge(invite_source: '_invite_source_')
-
- expect_snowplow_event(
- category: 'Members::CreateService',
- action: 'create_member',
- label: '_invite_source_',
- property: 'existing_user',
- user: maintainer
- )
- end
- end
end
end
@@ -410,48 +380,28 @@ RSpec.describe API::Members do
end
context 'with areas_of_focus considerations', :snowplow do
- context 'when there is 1 user to add' do
- let(:user_id) { stranger.id }
+ let(:user_id) { stranger.id }
- context 'when areas_of_focus is present in params' do
- it 'tracks the areas_of_focus' do
- post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
- params: { user_id: user_id, access_level: Member::DEVELOPER, areas_of_focus: 'Other' }
-
- expect_snowplow_event(
- category: 'Members::CreateService',
- action: 'area_of_focus',
- label: 'Other',
- property: source.members.last.id.to_s
- )
- end
- end
-
- context 'when areas_of_focus is not present in params' do
- it 'does not track the areas_of_focus' do
- post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
- params: { user_id: user_id, access_level: Member::DEVELOPER }
+ context 'when areas_of_focus is present in params' do
+ it 'tracks the areas_of_focus' do
+ post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
+ params: { user_id: user_id, access_level: Member::DEVELOPER, areas_of_focus: 'Other' }
- expect_no_snowplow_event(category: 'Members::CreateService', action: 'area_of_focus')
- end
+ expect_snowplow_event(
+ category: 'Members::CreateService',
+ action: 'area_of_focus',
+ label: 'Other',
+ property: source.members.last.id.to_s
+ )
end
end
- context 'when there are multiple users to add' do
- let(:user_id) { [developer.id, stranger.id].join(',') }
+ context 'when areas_of_focus is not present in params' do
+ it 'does not track the areas_of_focus' do
+ post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
+ params: { user_id: user_id, access_level: Member::DEVELOPER }
- context 'when areas_of_focus is present in params' do
- it 'tracks the areas_of_focus' do
- post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
- params: { user_id: user_id, access_level: Member::DEVELOPER, areas_of_focus: 'Other' }
-
- expect_snowplow_event(
- category: 'Members::CreateService',
- action: 'area_of_focus',
- label: 'Other',
- property: source.members.last.id.to_s
- )
- end
+ expect_no_snowplow_event(category: 'Members::CreateService', action: 'area_of_focus')
end
end
end
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index 4b5fc57571b..7a587e82683 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -1072,7 +1072,7 @@ RSpec.describe API::MergeRequests do
end
describe "GET /groups/:id/merge_requests" do
- let_it_be(:group) { create(:group, :public) }
+ let_it_be(:group, reload: true) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, :repository, creator: user, namespace: group, only_allow_merge_if_pipeline_succeeds: false) }
include_context 'with merge requests'
diff --git a/spec/requests/api/notification_settings_spec.rb b/spec/requests/api/notification_settings_spec.rb
index 7b4a58e63da..b5551c21738 100644
--- a/spec/requests/api/notification_settings_spec.rb
+++ b/spec/requests/api/notification_settings_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe API::NotificationSettings do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_a Hash
- expect(json_response['notification_email']).to eq(user.notification_email)
+ expect(json_response['notification_email']).to eq(user.notification_email_or_default)
expect(json_response['level']).to eq(user.global_notification_setting.level)
end
end
diff --git a/spec/requests/api/npm_project_packages_spec.rb b/spec/requests/api/npm_project_packages_spec.rb
index 8c35a1642e2..0d04c2cad5b 100644
--- a/spec/requests/api/npm_project_packages_spec.rb
+++ b/spec/requests/api/npm_project_packages_spec.rb
@@ -120,9 +120,11 @@ RSpec.describe API::NpmProjectPackages do
project.add_developer(user)
end
+ subject(:upload_package_with_token) { upload_with_token(package_name, params) }
+
shared_examples 'handling invalid record with 400 error' do
it 'handles an ActiveRecord::RecordInvalid exception with 400 error' do
- expect { upload_package_with_token(package_name, params) }
+ expect { upload_package_with_token }
.not_to change { project.packages.count }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -136,6 +138,7 @@ RSpec.describe API::NpmProjectPackages do
let(:params) { upload_params(package_name: package_name) }
it_behaves_like 'handling invalid record with 400 error'
+ it_behaves_like 'not a package tracking event'
end
context 'invalid package version' do
@@ -157,6 +160,7 @@ RSpec.describe API::NpmProjectPackages do
let(:params) { upload_params(package_name: package_name, package_version: version) }
it_behaves_like 'handling invalid record with 400 error'
+ it_behaves_like 'not a package tracking event'
end
end
end
@@ -169,8 +173,6 @@ RSpec.describe API::NpmProjectPackages do
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
@@ -184,7 +186,7 @@ RSpec.describe API::NpmProjectPackages do
end
it 'creates npm package with file with job token' do
- expect { upload_package_with_job_token(package_name, params) }
+ expect { upload_with_job_token(package_name, params) }
.to change { project.packages.count }.by(1)
.and change { Packages::PackageFile.count }.by(1)
@@ -205,7 +207,7 @@ RSpec.describe API::NpmProjectPackages do
end
it 'creates the package metadata' do
- upload_package_with_token(package_name, params)
+ upload_package_with_token
expect(response).to have_gitlab_http_status(:ok)
expect(project.reload.packages.find(json_response['id']).original_build_info.pipeline).to eq job.pipeline
@@ -215,7 +217,7 @@ RSpec.describe API::NpmProjectPackages do
shared_examples 'uploading the package' do
it 'uploads the package' do
- expect { upload_package_with_token(package_name, params) }
+ expect { upload_package_with_token }
.to change { project.packages.count }.by(1)
expect(response).to have_gitlab_http_status(:ok)
@@ -249,6 +251,7 @@ RSpec.describe API::NpmProjectPackages do
let(:package_name) { "@#{group.path}/test" }
it_behaves_like 'handling invalid record with 400 error'
+ it_behaves_like 'not a package tracking event'
context 'with a new version' do
let_it_be(:version) { '4.5.6' }
@@ -271,9 +274,14 @@ RSpec.describe API::NpmProjectPackages do
let(:package_name) { "@#{group.path}/my_package_name" }
let(:params) { upload_params(package_name: package_name) }
- it 'returns an error if the package already exists' do
+ before do
create(:npm_package, project: project, version: '1.0.1', name: "@#{group.path}/my_package_name")
- expect { upload_package_with_token(package_name, params) }
+ end
+
+ it_behaves_like 'not a package tracking event'
+
+ it 'returns an error if the package already exists' do
+ expect { upload_package_with_token }
.not_to change { project.packages.count }
expect(response).to have_gitlab_http_status(:forbidden)
@@ -285,7 +293,7 @@ RSpec.describe API::NpmProjectPackages do
let(:params) { upload_params(package_name: package_name, file: 'npm/payload_with_duplicated_packages.json') }
it 'creates npm package with file and dependencies' do
- expect { upload_package_with_token(package_name, params) }
+ expect { upload_package_with_token }
.to change { project.packages.count }.by(1)
.and change { Packages::PackageFile.count }.by(1)
.and change { Packages::Dependency.count}.by(4)
@@ -297,11 +305,11 @@ RSpec.describe API::NpmProjectPackages do
context 'with existing dependencies' do
before do
name = "@#{group.path}/existing_package"
- upload_package_with_token(name, upload_params(package_name: name, file: 'npm/payload_with_duplicated_packages.json'))
+ upload_with_token(name, upload_params(package_name: name, file: 'npm/payload_with_duplicated_packages.json'))
end
it 'reuses them' do
- expect { upload_package_with_token(package_name, params) }
+ expect { upload_package_with_token }
.to change { project.packages.count }.by(1)
.and change { Packages::PackageFile.count }.by(1)
.and not_change { Packages::Dependency.count}
@@ -317,11 +325,11 @@ RSpec.describe API::NpmProjectPackages do
put api("/projects/#{project.id}/packages/npm/#{package_name.sub('/', '%2f')}"), params: params, headers: headers
end
- def upload_package_with_token(package_name, params = {})
+ def upload_with_token(package_name, params = {})
upload_package(package_name, params.merge(access_token: token.token))
end
- def upload_package_with_job_token(package_name, params = {})
+ def upload_with_job_token(package_name, params = {})
upload_package(package_name, params.merge(job_token: job.token))
end
diff --git a/spec/requests/api/pages/pages_spec.rb b/spec/requests/api/pages/pages_spec.rb
index f4c6de00e40..0eb2ae64f43 100644
--- a/spec/requests/api/pages/pages_spec.rb
+++ b/spec/requests/api/pages/pages_spec.rb
@@ -36,12 +36,7 @@ RSpec.describe API::Pages do
end
it 'removes the pages' do
- expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project).and_return true
- expect(PagesWorker).to receive(:perform_in).with(5.minutes, :remove, project.namespace.full_path, anything)
-
- Sidekiq::Testing.inline! do
- delete api("/projects/#{project.id}/pages", admin )
- end
+ delete api("/projects/#{project.id}/pages", admin )
expect(project.reload.pages_metadatum.deployed?).to be(false)
end
diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml
index c5bcedd491a..9174356f123 100644
--- a/spec/requests/api/project_attributes.yml
+++ b/spec/requests/api/project_attributes.yml
@@ -32,6 +32,7 @@ itself: # project
- pages_https_only
- pending_delete
- pool_repository_id
+ - project_namespace_id
- pull_mirror_available_overridden
- pull_mirror_branch_prefix
- remote_mirror_available_overridden
@@ -55,6 +56,7 @@ itself: # project
- can_create_merge_request_in
- compliance_frameworks
- container_expiration_policy
+ - container_registry_enabled
- container_registry_image_prefix
- default_branch
- empty_repo
@@ -149,6 +151,7 @@ build_service_desk_setting: # service_desk_setting
unexposed_attributes:
- project_id
- issue_template_key
+ - file_template_project_id
- outgoing_name
remapped_attributes:
project_key: service_desk_address
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index 3622eedfed5..80bccdfee0c 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -2616,6 +2616,23 @@ RSpec.describe API::Projects do
expect(json_response).to have_key 'service_desk_enabled'
expect(json_response).to have_key 'service_desk_address'
end
+
+ context 'when project is shared to multiple groups' do
+ it 'avoids N+1 queries' do
+ create(:project_group_link, project: project)
+ get api("/projects/#{project.id}", user)
+
+ control = ActiveRecord::QueryRecorder.new do
+ get api("/projects/#{project.id}", user)
+ end
+
+ create(:project_group_link, project: project)
+
+ expect do
+ get api("/projects/#{project.id}", user)
+ end.not_to exceed_query_limit(control)
+ end
+ end
end
describe 'GET /projects/:id/users' do
diff --git a/spec/requests/api/pypi_packages_spec.rb b/spec/requests/api/pypi_packages_spec.rb
index 8df2460a2b6..c17d0600aca 100644
--- a/spec/requests/api/pypi_packages_spec.rb
+++ b/spec/requests/api/pypi_packages_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe API::PypiPackages do
let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) }
let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
- let_it_be(:job) { create(:ci_build, :running, user: user) }
+ let_it_be(:job) { create(:ci_build, :running, user: user, project: project) }
let(:headers) { {} }
diff --git a/spec/requests/api/releases_spec.rb b/spec/requests/api/releases_spec.rb
index 87b08587904..90b03a480a8 100644
--- a/spec/requests/api/releases_spec.rb
+++ b/spec/requests/api/releases_spec.rb
@@ -839,7 +839,7 @@ RSpec.describe API::Releases do
context 'when a valid token is provided' do
it 'creates the release for a running job' do
- job.update!(status: :running)
+ job.update!(status: :running, project: project)
post api("/projects/#{project.id}/releases"), params: params.merge(job_token: job.token)
expect(response).to have_gitlab_http_status(:created)
diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb
index d3262b8056b..a576e1ab1ee 100644
--- a/spec/requests/api/repositories_spec.rb
+++ b/spec/requests/api/repositories_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe API::Repositories 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).to be_an(Array)
first_commit = json_response.first
expect(first_commit['name']).to eq('bar')
@@ -73,6 +73,25 @@ RSpec.describe API::Repositories do
end
end
end
+
+ context 'keyset pagination mode' do
+ let(:first_response) do
+ get api(route, current_user), params: { pagination: "keyset" }
+
+ Gitlab::Json.parse(response.body)
+ end
+
+ it 'paginates using keysets' do
+ page_token = first_response.last["id"]
+
+ get api(route, current_user), params: { pagination: "keyset", page_token: page_token }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an(Array)
+ expect(json_response).not_to eq(first_response)
+ expect(json_response.map { |t| t["id"] }).not_to include(page_token)
+ end
+ end
end
context 'when unauthenticated', 'and project is public' do
@@ -354,6 +373,7 @@ RSpec.describe API::Repositories do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['commits']).to be_present
expect(json_response['diffs']).to be_present
+ expect(json_response['web_url']).to be_present
end
it "compares branches with explicit merge-base mode" do
@@ -365,6 +385,7 @@ RSpec.describe API::Repositories do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['commits']).to be_present
expect(json_response['diffs']).to be_present
+ expect(json_response['web_url']).to be_present
end
it "compares branches with explicit straight mode" do
@@ -376,6 +397,7 @@ RSpec.describe API::Repositories do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['commits']).to be_present
expect(json_response['diffs']).to be_present
+ expect(json_response['web_url']).to be_present
end
it "compares tags" do
@@ -384,6 +406,7 @@ RSpec.describe API::Repositories do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['commits']).to be_present
expect(json_response['diffs']).to be_present
+ expect(json_response['web_url']).to be_present
end
it "compares commits" do
@@ -393,6 +416,7 @@ RSpec.describe API::Repositories do
expect(json_response['commits']).to be_empty
expect(json_response['diffs']).to be_empty
expect(json_response['compare_same_ref']).to be_falsey
+ expect(json_response['web_url']).to be_present
end
it "compares commits in reverse order" do
@@ -401,6 +425,7 @@ RSpec.describe API::Repositories do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['commits']).to be_present
expect(json_response['diffs']).to be_present
+ expect(json_response['web_url']).to be_present
end
it "compare commits between different projects with non-forked relation" do
diff --git a/spec/requests/api/rubygem_packages_spec.rb b/spec/requests/api/rubygem_packages_spec.rb
index afa7adad80c..9b104520b52 100644
--- a/spec/requests/api/rubygem_packages_spec.rb
+++ b/spec/requests/api/rubygem_packages_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe API::RubygemPackages do
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) }
+ let_it_be(:job) { create(:ci_build, :running, user: user, project: project) }
let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) }
let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
let_it_be(:headers) { {} }
diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb
index 4008b57a1cf..f5d261ba4c6 100644
--- a/spec/requests/api/settings_spec.rb
+++ b/spec/requests/api/settings_spec.rb
@@ -47,6 +47,7 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do
expect(json_response['personal_access_token_prefix']).to be_nil
expect(json_response['admin_mode']).to be(false)
expect(json_response['whats_new_variant']).to eq('all_tiers')
+ expect(json_response['user_deactivation_emails_enabled']).to be(true)
end
end
@@ -133,6 +134,7 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do
import_sources: 'github,bitbucket',
wiki_page_max_content_bytes: 12345,
personal_access_token_prefix: "GL-",
+ user_deactivation_emails_enabled: false,
admin_mode: true
}
@@ -184,6 +186,7 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do
expect(json_response['wiki_page_max_content_bytes']).to eq(12345)
expect(json_response['personal_access_token_prefix']).to eq("GL-")
expect(json_response['admin_mode']).to be(true)
+ expect(json_response['user_deactivation_emails_enabled']).to be(false)
end
end
@@ -222,6 +225,45 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do
expect(json_response['asset_proxy_allowlist']).to eq(['example.com', '*.example.com', 'localhost'])
end
+ it 'supports the deprecated `throttle_unauthenticated_*` attributes' do
+ put api('/application/settings', admin), params: {
+ throttle_unauthenticated_enabled: true,
+ throttle_unauthenticated_period_in_seconds: 123,
+ throttle_unauthenticated_requests_per_period: 456
+ }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to include(
+ 'throttle_unauthenticated_enabled' => true,
+ 'throttle_unauthenticated_period_in_seconds' => 123,
+ 'throttle_unauthenticated_requests_per_period' => 456,
+ 'throttle_unauthenticated_web_enabled' => true,
+ 'throttle_unauthenticated_web_period_in_seconds' => 123,
+ 'throttle_unauthenticated_web_requests_per_period' => 456
+ )
+ end
+
+ it 'prefers the new `throttle_unauthenticated_web_*` attributes' do
+ put api('/application/settings', admin), params: {
+ throttle_unauthenticated_enabled: false,
+ throttle_unauthenticated_period_in_seconds: 0,
+ throttle_unauthenticated_requests_per_period: 0,
+ throttle_unauthenticated_web_enabled: true,
+ throttle_unauthenticated_web_period_in_seconds: 123,
+ throttle_unauthenticated_web_requests_per_period: 456
+ }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to include(
+ 'throttle_unauthenticated_enabled' => true,
+ 'throttle_unauthenticated_period_in_seconds' => 123,
+ 'throttle_unauthenticated_requests_per_period' => 456,
+ 'throttle_unauthenticated_web_enabled' => true,
+ 'throttle_unauthenticated_web_period_in_seconds' => 123,
+ 'throttle_unauthenticated_web_requests_per_period' => 456
+ )
+ end
+
it 'disables ability to switch to legacy storage' do
put api("/application/settings", admin),
params: { hashed_storage_enabled: false }
@@ -552,5 +594,20 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do
expect(json_response['error']).to eq('whats_new_variant does not have a valid value')
end
end
+
+ context 'sidekiq job limit settings' do
+ it 'updates the settings' do
+ settings = {
+ sidekiq_job_limiter_mode: 'track',
+ sidekiq_job_limiter_compression_threshold_bytes: 1,
+ sidekiq_job_limiter_limit_bytes: 2
+ }.stringify_keys
+
+ put api("/application/settings", admin), params: settings
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.slice(*settings.keys)).to eq(settings)
+ end
+ end
end
end
diff --git a/spec/requests/api/terraform/modules/v1/packages_spec.rb b/spec/requests/api/terraform/modules/v1/packages_spec.rb
index 6803c09b8c2..b04f5ad9a94 100644
--- a/spec/requests/api/terraform/modules/v1/packages_spec.rb
+++ b/spec/requests/api/terraform/modules/v1/packages_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe API::Terraform::Modules::V1::Packages do
let_it_be(:package) { create(:terraform_module_package, project: 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) }
+ let_it_be(:job) { create(:ci_build, :running, user: user, project: project) }
let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) }
let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
diff --git a/spec/requests/api/unleash_spec.rb b/spec/requests/api/unleash_spec.rb
index 0718710f15c..6cb801538c6 100644
--- a/spec/requests/api/unleash_spec.rb
+++ b/spec/requests/api/unleash_spec.rb
@@ -176,25 +176,6 @@ RSpec.describe API::Unleash do
it_behaves_like 'authenticated request'
- context 'with version 1 (legacy) feature flags' do
- let(:feature_flag) { create(:operations_feature_flag, :legacy_flag, project: project, name: 'feature1', active: true, version: 1) }
-
- it 'does not return a legacy feature flag' do
- create(:operations_feature_flag_scope,
- feature_flag: feature_flag,
- environment_scope: 'sandbox',
- active: true,
- strategies: [{ name: "gradualRolloutUserId",
- parameters: { groupId: "default", percentage: "50" } }])
- headers = { "UNLEASH-INSTANCEID" => client.token, "UNLEASH-APPNAME" => "sandbox" }
-
- get api(features_url), headers: headers
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['features']).to be_empty
- end
- end
-
context 'with version 2 feature flags' do
it 'does not return a flag without any strategies' do
create(:operations_feature_flag, project: project,
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index 383940ce34a..527e548ad19 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -9,9 +9,13 @@ RSpec.describe API::Users do
let_it_be(:gpg_key) { create(:gpg_key, user: user) }
let_it_be(:email) { create(:email, user: user) }
+ let(:blocked_user) { create(:user, :blocked) }
let(:omniauth_user) { create(:omniauth_user) }
let(:ldap_blocked_user) { create(:omniauth_user, provider: 'ldapmain', state: 'ldap_blocked') }
let(:private_user) { create(:user, private_profile: true) }
+ let(:deactivated_user) { create(:user, state: 'deactivated') }
+ let(:banned_user) { create(:user, :banned) }
+ let(:internal_user) { create(:user, :bot) }
context 'admin notes' do
let_it_be(:admin) { create(:admin, note: '2019-10-06 | 2FA added | user requested | www.gitlab.com') }
@@ -1199,7 +1203,7 @@ RSpec.describe API::Users do
it 'updates user with a new email' do
old_email = user.email
- old_notification_email = user.notification_email
+ old_notification_email = user.notification_email_or_default
put api("/users/#{user.id}", admin), params: { email: 'new@email.com' }
user.reload
@@ -1207,7 +1211,7 @@ RSpec.describe API::Users do
expect(response).to have_gitlab_http_status(:ok)
expect(user).to be_confirmed
expect(user.email).to eq(old_email)
- expect(user.notification_email).to eq(old_notification_email)
+ expect(user.notification_email_or_default).to eq(old_notification_email)
expect(user.unconfirmed_email).to eq('new@email.com')
end
@@ -2599,15 +2603,13 @@ RSpec.describe API::Users do
let(:api_user) { admin }
context 'for a deactivated user' do
- before do
- user.deactivate
- end
+ let(:user_id) { deactivated_user.id }
it 'activates a deactivated user' do
activate
expect(response).to have_gitlab_http_status(:created)
- expect(user.reload.state).to eq('active')
+ expect(deactivated_user.reload.state).to eq('active')
end
end
@@ -2625,16 +2627,14 @@ RSpec.describe API::Users do
end
context 'for a blocked user' do
- before do
- user.block
- end
+ let(:user_id) { blocked_user.id }
it 'returns 403' do
activate
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response['message']).to eq('403 Forbidden - A blocked user must be unblocked to be activated')
- expect(user.reload.state).to eq('blocked')
+ expect(blocked_user.reload.state).to eq('blocked')
end
end
@@ -2711,29 +2711,25 @@ RSpec.describe API::Users do
end
context 'for a deactivated user' do
- before do
- user.deactivate
- end
+ let(:user_id) { deactivated_user.id }
it 'returns 201' do
deactivate
expect(response).to have_gitlab_http_status(:created)
- expect(user.reload.state).to eq('deactivated')
+ expect(deactivated_user.reload.state).to eq('deactivated')
end
end
context 'for a blocked user' do
- before do
- user.block
- end
+ let(:user_id) { blocked_user.id }
it 'returns 403' do
deactivate
expect(response).to have_gitlab_http_status(:forbidden)
expect(json_response['message']).to eq('403 Forbidden - A blocked user cannot be deactivated by the API')
- expect(user.reload.state).to eq('blocked')
+ expect(blocked_user.reload.state).to eq('blocked')
end
end
@@ -2775,7 +2771,9 @@ RSpec.describe API::Users do
end
end
- context 'approve pending user' do
+ context 'approve and reject pending user' do
+ let(:pending_user) { create(:user, :blocked_pending_approval) }
+
shared_examples '404' do
it 'returns 404' do
expect(response).to have_gitlab_http_status(:not_found)
@@ -2786,10 +2784,6 @@ RSpec.describe API::Users do
describe 'POST /users/:id/approve' do
subject(:approve) { post api("/users/#{user_id}/approve", api_user) }
- let_it_be(:pending_user) { create(:user, :blocked_pending_approval) }
- let_it_be(:deactivated_user) { create(:user, :deactivated) }
- let_it_be(:blocked_user) { create(:user, :blocked) }
-
context 'performed by a non-admin user' do
let(:api_user) { user }
let(:user_id) { pending_user.id }
@@ -2865,102 +2859,403 @@ RSpec.describe API::Users do
end
end
end
- end
- describe 'POST /users/:id/block' do
- let(:blocked_user) { create(:user, state: 'blocked') }
+ describe 'POST /users/:id/reject', :aggregate_failures do
+ subject(:reject) { post api("/users/#{user_id}/reject", api_user) }
- it 'blocks existing user' do
- post api("/users/#{user.id}/block", admin)
+ shared_examples 'returns 409' do
+ it 'returns 409' do
+ reject
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:created)
- expect(response.body).to eq('true')
- expect(user.reload.state).to eq('blocked')
+ expect(response).to have_gitlab_http_status(:conflict)
+ expect(json_response['message']).to eq('User does not have a pending request')
+ end
+ end
+
+ context 'performed by a non-admin user' do
+ let(:api_user) { user }
+ let(:user_id) { pending_user.id }
+
+ it 'returns 403' do
+ expect { reject }.not_to change { pending_user.reload.state }
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(json_response['message']).to eq('You are not allowed to reject a user')
+ end
+ end
+
+ context 'performed by an admin user' do
+ let(:api_user) { admin }
+
+ context 'for an pending approval user' do
+ let(:user_id) { pending_user.id }
+
+ it 'returns 200' do
+ reject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['message']).to eq('Success')
+ end
+ end
+
+ context 'for a deactivated user' do
+ let(:user_id) { deactivated_user.id }
+
+ it 'does not reject a deactivated user' do
+ expect { reject }.not_to change { deactivated_user.reload.state }
+ end
+
+ it_behaves_like 'returns 409'
+ end
+
+ context 'for an active user' do
+ let(:user_id) { user.id }
+
+ it 'does not reject an active user' do
+ expect { reject }.not_to change { user.reload.state }
+ end
+
+ it_behaves_like 'returns 409'
+ end
+
+ context 'for a blocked user' do
+ let(:user_id) { blocked_user.id }
+
+ it 'does not reject a blocked user' do
+ expect { reject }.not_to change { blocked_user.reload.state }
+ end
+
+ it_behaves_like 'returns 409'
+ end
+
+ context 'for a ldap blocked user' do
+ let(:user_id) { ldap_blocked_user.id }
+
+ it 'does not reject a ldap blocked user' do
+ expect { reject }.not_to change { ldap_blocked_user.reload.state }
+ end
+
+ it_behaves_like 'returns 409'
+ end
+
+ context 'for a user that does not exist' do
+ let(:user_id) { non_existing_record_id }
+
+ before do
+ reject
+ end
+
+ it_behaves_like '404'
+ end
end
end
+ end
- it 'does not re-block ldap blocked users' do
- post api("/users/#{ldap_blocked_user.id}/block", admin)
- expect(response).to have_gitlab_http_status(:forbidden)
- expect(ldap_blocked_user.reload.state).to eq('ldap_blocked')
+ describe 'POST /users/:id/block', :aggregate_failures do
+ context 'when admin' do
+ subject(:block_user) { post api("/users/#{user_id}/block", admin) }
+
+ context 'with an existing user' do
+ let(:user_id) { user.id }
+
+ it 'blocks existing user' do
+ block_user
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response.body).to eq('true')
+ expect(user.reload.state).to eq('blocked')
+ end
+ end
+
+ context 'with an ldap blocked user' do
+ let(:user_id) { ldap_blocked_user.id }
+
+ it 'does not re-block ldap blocked users' do
+ block_user
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(ldap_blocked_user.reload.state).to eq('ldap_blocked')
+ end
+ end
+
+ context 'with a non existent user' do
+ let(:user_id) { non_existing_record_id }
+
+ it 'does not block non existent user, returns 404' do
+ block_user
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 User Not Found')
+ end
+ end
+
+ context 'with an internal user' do
+ let(:user_id) { internal_user.id }
+
+ it 'does not block internal user, returns 403' do
+ block_user
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(json_response['message']).to eq('An internal user cannot be blocked')
+ end
+ end
+
+ context 'with a blocked user' do
+ let(:user_id) { blocked_user.id }
+
+ it 'returns a 201 if user is already blocked' do
+ block_user
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response.body).to eq('null')
+ end
+ end
end
- it 'does not be available for non admin users' do
+ it 'is not available for non admin users' do
post api("/users/#{user.id}/block", user)
+
expect(response).to have_gitlab_http_status(:forbidden)
expect(user.reload.state).to eq('active')
end
+ end
- it 'returns a 404 error if user id not found' do
- post api('/users/0/block', admin)
- expect(response).to have_gitlab_http_status(:not_found)
- expect(json_response['message']).to eq('404 User Not Found')
- end
+ describe 'POST /users/:id/unblock', :aggregate_failures do
+ context 'when admin' do
+ subject(:unblock_user) { post api("/users/#{user_id}/unblock", admin) }
- it 'returns a 403 error if user is internal' do
- internal_user = create(:user, :bot)
+ context 'with an existing user' do
+ let(:user_id) { user.id }
- post api("/users/#{internal_user.id}/block", admin)
+ it 'unblocks existing user' do
+ unblock_user
- expect(response).to have_gitlab_http_status(:forbidden)
- expect(json_response['message']).to eq('An internal user cannot be blocked')
- end
+ expect(response).to have_gitlab_http_status(:created)
+ expect(user.reload.state).to eq('active')
+ end
+ end
- it 'returns a 201 if user is already blocked' do
- post api("/users/#{blocked_user.id}/block", admin)
+ context 'with a blocked user' do
+ let(:user_id) { blocked_user.id }
- aggregate_failures do
- expect(response).to have_gitlab_http_status(:created)
- expect(response.body).to eq('null')
+ it 'unblocks a blocked user' do
+ unblock_user
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(blocked_user.reload.state).to eq('active')
+ end
end
- end
- end
- describe 'POST /users/:id/unblock' do
- let(:blocked_user) { create(:user, state: 'blocked') }
- let(:deactivated_user) { create(:user, state: 'deactivated') }
+ context 'with a ldap blocked user' do
+ let(:user_id) { ldap_blocked_user.id }
- it 'unblocks existing user' do
- post api("/users/#{user.id}/unblock", admin)
- expect(response).to have_gitlab_http_status(:created)
- expect(user.reload.state).to eq('active')
- end
+ it 'does not unblock ldap blocked users' do
+ unblock_user
- it 'unblocks a blocked user' do
- post api("/users/#{blocked_user.id}/unblock", admin)
- expect(response).to have_gitlab_http_status(:created)
- expect(blocked_user.reload.state).to eq('active')
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(ldap_blocked_user.reload.state).to eq('ldap_blocked')
+ end
+ end
+
+ context 'with a deactivated user' do
+ let(:user_id) { deactivated_user.id }
+
+ it 'does not unblock deactivated users' do
+ unblock_user
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(deactivated_user.reload.state).to eq('deactivated')
+ end
+ end
+
+ context 'with a non existent user' do
+ let(:user_id) { non_existing_record_id }
+
+ it 'returns a 404 error if user id not found' do
+ unblock_user
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 User Not Found')
+ end
+ end
+
+ context 'with an invalid user id' do
+ let(:user_id) { 'ASDF' }
+
+ it 'returns a 404' do
+ unblock_user
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
end
- it 'does not unblock ldap blocked users' do
- post api("/users/#{ldap_blocked_user.id}/unblock", admin)
+ it 'is not available for non admin users' do
+ post api("/users/#{user.id}/unblock", user)
expect(response).to have_gitlab_http_status(:forbidden)
- expect(ldap_blocked_user.reload.state).to eq('ldap_blocked')
+ expect(user.reload.state).to eq('active')
end
+ end
- it 'does not unblock deactivated users' do
- post api("/users/#{deactivated_user.id}/unblock", admin)
- expect(response).to have_gitlab_http_status(:forbidden)
- expect(deactivated_user.reload.state).to eq('deactivated')
+ describe 'POST /users/:id/ban', :aggregate_failures do
+ context 'when admin' do
+ subject(:ban_user) { post api("/users/#{user_id}/ban", admin) }
+
+ context 'with an active user' do
+ let(:user_id) { user.id }
+
+ it 'bans an active user' do
+ ban_user
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response.body).to eq('true')
+ expect(user.reload.state).to eq('banned')
+ end
+ end
+
+ context 'with an ldap blocked user' do
+ let(:user_id) { ldap_blocked_user.id }
+
+ it 'does not ban ldap blocked users' do
+ ban_user
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(json_response['message']).to eq('You cannot ban ldap_blocked users.')
+ expect(ldap_blocked_user.reload.state).to eq('ldap_blocked')
+ end
+ end
+
+ context 'with a deactivated user' do
+ let(:user_id) { deactivated_user.id }
+
+ it 'does not ban deactivated users' do
+ ban_user
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(json_response['message']).to eq('You cannot ban deactivated users.')
+ expect(deactivated_user.reload.state).to eq('deactivated')
+ end
+ end
+
+ context 'with a banned user' do
+ let(:user_id) { banned_user.id }
+
+ it 'does not ban banned users' do
+ ban_user
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(json_response['message']).to eq('You cannot ban banned users.')
+ expect(banned_user.reload.state).to eq('banned')
+ end
+ end
+
+ context 'with a non existent user' do
+ let(:user_id) { non_existing_record_id }
+
+ it 'does not ban non existent users' do
+ ban_user
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 User Not Found')
+ end
+ end
+
+ context 'with an invalid id' do
+ let(:user_id) { 'ASDF' }
+
+ it 'does not ban invalid id users' do
+ ban_user
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
end
- it 'is not available for non admin users' do
- post api("/users/#{user.id}/unblock", user)
+ it 'is not available for non-admin users' do
+ post api("/users/#{user.id}/ban", user)
+
expect(response).to have_gitlab_http_status(:forbidden)
expect(user.reload.state).to eq('active')
end
+ end
- it 'returns a 404 error if user id not found' do
- post api('/users/0/block', admin)
- expect(response).to have_gitlab_http_status(:not_found)
- expect(json_response['message']).to eq('404 User Not Found')
+ describe 'POST /users/:id/unban', :aggregate_failures do
+ context 'when admin' do
+ subject(:unban_user) { post api("/users/#{user_id}/unban", admin) }
+
+ context 'with a banned user' do
+ let(:user_id) { banned_user.id }
+
+ it 'activates a banned user' do
+ unban_user
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(banned_user.reload.state).to eq('active')
+ end
+ end
+
+ context 'with an ldap_blocked user' do
+ let(:user_id) { ldap_blocked_user.id }
+
+ it 'does not unban ldap_blocked users' do
+ unban_user
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(json_response['message']).to eq('You cannot unban ldap_blocked users.')
+ expect(ldap_blocked_user.reload.state).to eq('ldap_blocked')
+ end
+ end
+
+ context 'with a deactivated user' do
+ let(:user_id) { deactivated_user.id }
+
+ it 'does not unban deactivated users' do
+ unban_user
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(json_response['message']).to eq('You cannot unban deactivated users.')
+ expect(deactivated_user.reload.state).to eq('deactivated')
+ end
+ end
+
+ context 'with an active user' do
+ let(:user_id) { user.id }
+
+ it 'does not unban active users' do
+ unban_user
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(json_response['message']).to eq('You cannot unban active users.')
+ expect(user.reload.state).to eq('active')
+ end
+ end
+
+ context 'with a non existent user' do
+ let(:user_id) { non_existing_record_id }
+
+ it 'does not unban non existent users' do
+ unban_user
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 User Not Found')
+ end
+ end
+
+ context 'with an invalid id user' do
+ let(:user_id) { 'ASDF' }
+
+ it 'does not unban invalid id users' do
+ unban_user
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
end
- it "returns a 404 for invalid ID" do
- post api("/users/ASDF/block", admin)
+ it 'is not available for non admin users' do
+ post api("/users/#{banned_user.id}/unban", user)
- expect(response).to have_gitlab_http_status(:not_found)
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(user.reload.state).to eq('active')
end
end
diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb
index e4a0c034b20..a16f5abf608 100644
--- a/spec/requests/git_http_spec.rb
+++ b/spec/requests/git_http_spec.rb
@@ -882,6 +882,10 @@ RSpec.describe 'Git HTTP requests' do
before do
build.update!(user: user)
project.add_reporter(user)
+ create(:ci_job_token_project_scope_link,
+ source_project: project,
+ target_project: other_project,
+ added_by: user)
end
shared_examples 'can download code only' do
@@ -1447,6 +1451,10 @@ RSpec.describe 'Git HTTP requests' do
before do
build.update!(project: project) # can't associate it on factory create
+ create(:ci_job_token_project_scope_link,
+ source_project: project,
+ target_project: other_project,
+ added_by: user)
end
context 'when build created by system is authenticated' do
diff --git a/spec/requests/jira_connect/installations_controller_spec.rb b/spec/requests/jira_connect/installations_controller_spec.rb
new file mode 100644
index 00000000000..6315c66a41a
--- /dev/null
+++ b/spec/requests/jira_connect/installations_controller_spec.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnect::InstallationsController do
+ let_it_be(:installation) { create(:jira_connect_installation) }
+
+ describe 'GET /-/jira_connect/installations' do
+ before do
+ get '/-/jira_connect/installations', params: { jwt: jwt }
+ end
+
+ context 'without JWT' do
+ let(:jwt) { nil }
+
+ it 'returns 403' do
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'with valid JWT' do
+ let(:qsh) { Atlassian::Jwt.create_query_string_hash('https://gitlab.test/installations', 'GET', 'https://gitlab.test') }
+ let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret) }
+
+ it 'returns status ok' do
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'returns the installation as json' do
+ expect(json_response).to eq({
+ 'gitlab_com' => true,
+ 'instance_url' => nil
+ })
+ end
+
+ context 'with instance_url' do
+ let_it_be(:installation) { create(:jira_connect_installation, instance_url: 'https://example.com') }
+
+ it 'returns the installation as json' do
+ expect(json_response).to eq({
+ 'gitlab_com' => false,
+ 'instance_url' => 'https://example.com'
+ })
+ end
+ end
+ end
+ end
+
+ describe 'PUT /-/jira_connect/installations' do
+ before do
+ put '/-/jira_connect/installations', params: { jwt: jwt, installation: { instance_url: update_instance_url } }
+ end
+
+ let(:update_instance_url) { 'https://example.com' }
+
+ context 'without JWT' do
+ let(:jwt) { nil }
+
+ it 'returns 403' do
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'with valid JWT' do
+ let(:qsh) { Atlassian::Jwt.create_query_string_hash('https://gitlab.test/subscriptions', 'GET', 'https://gitlab.test') }
+ let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret) }
+
+ it 'returns 200' do
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'updates the instance_url' do
+ expect(json_response).to eq({
+ 'gitlab_com' => false,
+ 'instance_url' => 'https://example.com'
+ })
+ end
+
+ context 'invalid URL' do
+ let(:update_instance_url) { 'invalid url' }
+
+ it 'returns 422 and errors', :aggregate_failures do
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ expect(json_response).to eq({
+ 'errors' => {
+ 'instance_url' => [
+ 'is blocked: Only allowed schemes are http, https'
+ ]
+ }
+ })
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/members/mailgun/permanent_failure_spec.rb b/spec/requests/members/mailgun/permanent_failure_spec.rb
new file mode 100644
index 00000000000..e47aedf8e94
--- /dev/null
+++ b/spec/requests/members/mailgun/permanent_failure_spec.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'receive a permanent failure' do
+ describe 'POST /members/mailgun/permanent_failures', :aggregate_failures do
+ let_it_be(:member) { create(:project_member, :invited) }
+
+ let(:raw_invite_token) { member.raw_invite_token }
+ let(:mailgun_events) { true }
+ let(:mailgun_signing_key) { 'abc123' }
+
+ subject(:post_request) { post members_mailgun_permanent_failures_path(standard_params) }
+
+ before do
+ stub_application_setting(mailgun_events_enabled: mailgun_events, mailgun_signing_key: mailgun_signing_key)
+ end
+
+ it 'marks the member invite email success as false' do
+ expect { post_request }.to change { member.reload.invite_email_success }.from(true).to(false)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ context 'when the change to a member is not made' do
+ context 'with incorrect signing key' do
+ context 'with incorrect signing key' do
+ let(:mailgun_signing_key) { '_foobar_' }
+
+ it 'does not change member status and responds as not_found' do
+ expect { post_request }.not_to change { member.reload.invite_email_success }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'with nil signing key' do
+ let(:mailgun_signing_key) { nil }
+
+ it 'does not change member status and responds as not_found' do
+ expect { post_request }.not_to change { member.reload.invite_email_success }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'when the feature is not enabled' do
+ let(:mailgun_events) { false }
+
+ it 'does not change member status and responds as expected' do
+ expect { post_request }.not_to change { member.reload.invite_email_success }
+
+ expect(response).to have_gitlab_http_status(:not_acceptable)
+ end
+ end
+
+ context 'when it is not an invite email' do
+ before do
+ stub_const('::Members::Mailgun::INVITE_EMAIL_TAG', '_foobar_')
+ end
+
+ it 'does not change member status and responds as expected' do
+ expect { post_request }.not_to change { member.reload.invite_email_success }
+
+ expect(response).to have_gitlab_http_status(:not_acceptable)
+ end
+ end
+ end
+
+ def standard_params
+ {
+ "signature": {
+ "timestamp": "1625056677",
+ "token": "eb944d0ace7227667a1b97d2d07276ae51d2b849ed2cfa68f3",
+ "signature": "9790cc6686eb70f0b1f869180d906870cdfd496d27fee81da0aa86b9e539e790"
+ },
+ "event-data": {
+ "severity": "permanent",
+ "tags": ["invite_email"],
+ "timestamp": 1521233195.375624,
+ "storage": {
+ "url": "_anything_",
+ "key": "_anything_"
+ },
+ "log-level": "error",
+ "id": "_anything_",
+ "campaigns": [],
+ "reason": "suppress-bounce",
+ "user-variables": {
+ "invite_token": raw_invite_token
+ },
+ "flags": {
+ "is-routed": false,
+ "is-authenticated": true,
+ "is-system-test": false,
+ "is-test-mode": false
+ },
+ "recipient-domain": "example.com",
+ "envelope": {
+ "sender": "bob@mg.gitlab.com",
+ "transport": "smtp",
+ "targets": "alice@example.com"
+ },
+ "message": {
+ "headers": {
+ "to": "Alice <alice@example.com>",
+ "message-id": "20130503192659.13651.20287@mg.gitlab.com",
+ "from": "Bob <bob@mg.gitlab.com>",
+ "subject": "Test permanent_fail webhook"
+ },
+ "attachments": [],
+ "size": 111
+ },
+ "recipient": "alice@example.com",
+ "event": "failed",
+ "delivery-status": {
+ "attempt-no": 1,
+ "message": "",
+ "code": 605,
+ "description": "Not delivering to previously bounced address",
+ "session-seconds": 0
+ }
+ }
+ }
+ end
+ end
+end
diff --git a/spec/requests/oauth_tokens_spec.rb b/spec/requests/oauth_tokens_spec.rb
index 6d944bbc783..fdcc76f42cc 100644
--- a/spec/requests/oauth_tokens_spec.rb
+++ b/spec/requests/oauth_tokens_spec.rb
@@ -55,5 +55,29 @@ RSpec.describe 'OAuth Tokens requests' do
expect(json_response['access_token']).not_to be_nil
end
+
+ context 'when the application is configured to use expiring tokens' do
+ before do
+ application.update!(expire_access_tokens: true)
+ end
+
+ it 'generates an access token with an expiration' do
+ request_access_token(user)
+
+ expect(json_response['expires_in']).not_to be_nil
+ end
+ end
+
+ context 'when the application is configured not to use expiring tokens' do
+ before do
+ application.update!(expire_access_tokens: false)
+ end
+
+ it 'generates an access token without an expiration' do
+ request_access_token(user)
+
+ expect(json_response.key?('expires_in')).to eq(false)
+ end
+ end
end
end
diff --git a/spec/requests/openid_connect_spec.rb b/spec/requests/openid_connect_spec.rb
index 5bf786f2290..5ec23382698 100644
--- a/spec/requests/openid_connect_spec.rb
+++ b/spec/requests/openid_connect_spec.rb
@@ -149,7 +149,15 @@ RSpec.describe 'OpenID Connect requests' do
end
context 'ID token payload' do
+ let!(:group1) { create :group }
+ let!(:group2) { create :group }
+ let!(:group3) { create :group, parent: group2 }
+ let!(:group4) { create :group, parent: group3 }
+
before do
+ group1.add_user(user, Gitlab::Access::OWNER)
+ group3.add_user(user, Gitlab::Access::DEVELOPER)
+
request_access_token!
@payload = JSON::JWT.decode(json_response['id_token'], :skip_verification)
end
@@ -175,7 +183,12 @@ RSpec.describe 'OpenID Connect requests' do
end
it 'does not include any unknown properties' do
- expect(@payload.keys).to eq %w[iss sub aud exp iat auth_time sub_legacy email email_verified]
+ expect(@payload.keys).to eq %w[iss sub aud exp iat auth_time sub_legacy email email_verified groups_direct]
+ end
+
+ it 'does include groups' do
+ expected_groups = [group1.full_path, group3.full_path]
+ expect(@payload['groups_direct']).to match_array(expected_groups)
end
end
@@ -331,7 +344,15 @@ RSpec.describe 'OpenID Connect requests' do
end
context 'ID token payload' do
+ let!(:group1) { create :group }
+ let!(:group2) { create :group }
+ let!(:group3) { create :group, parent: group2 }
+ let!(:group4) { create :group, parent: group3 }
+
before do
+ group1.add_user(user, Gitlab::Access::OWNER)
+ group3.add_user(user, Gitlab::Access::DEVELOPER)
+
request_access_token!
@payload = JSON::JWT.decode(json_response['id_token'], :skip_verification)
end
@@ -343,6 +364,11 @@ RSpec.describe 'OpenID Connect requests' do
it 'has true in email_verified claim' do
expect(@payload['email_verified']).to eq(true)
end
+
+ it 'does include groups' do
+ expected_groups = [group1.full_path, group3.full_path]
+ expect(@payload['groups_direct']).to match_array(expected_groups)
+ end
end
end
end
diff --git a/spec/requests/projects/merge_requests_discussions_spec.rb b/spec/requests/projects/merge_requests_discussions_spec.rb
index c68745b9271..8057a091bba 100644
--- a/spec/requests/projects/merge_requests_discussions_spec.rb
+++ b/spec/requests/projects/merge_requests_discussions_spec.rb
@@ -59,6 +59,7 @@ RSpec.describe 'merge requests discussions' do
let!(:first_note) { create(:diff_note_on_merge_request, author: author, noteable: merge_request, project: project, note: "reference: #{reference.to_reference}") }
let!(:second_note) { create(:diff_note_on_merge_request, in_reply_to: first_note, noteable: merge_request, project: project) }
let!(:award_emoji) { create(:award_emoji, awardable: first_note) }
+ let!(:author_membership) { project.add_maintainer(author) }
before do
# Make a request to cache the discussions
@@ -229,6 +230,16 @@ RSpec.describe 'merge requests discussions' do
end
end
+ context 'when author role changes' do
+ before do
+ Members::UpdateService.new(user, access_level: Gitlab::Access::GUEST).execute(author_membership)
+ end
+
+ it_behaves_like 'cache miss' do
+ let(:changed_notes) { [first_note, second_note] }
+ end
+ end
+
context 'when merge_request_discussion_cache is disabled' do
before do
stub_feature_flags(merge_request_discussion_cache: false)
diff --git a/spec/requests/projects/usage_quotas_spec.rb b/spec/requests/projects/usage_quotas_spec.rb
new file mode 100644
index 00000000000..04e01da61ef
--- /dev/null
+++ b/spec/requests/projects/usage_quotas_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Project Usage Quotas' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:role) { :maintainer }
+ let_it_be(:user) { create(:user) }
+
+ before do
+ project.add_role(user, role)
+ login_as(user)
+ end
+
+ shared_examples 'response with 404 status' do
+ it 'renders :not_found' do
+ get project_usage_quotas_path(project)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(response.body).not_to include(project_usage_quotas_path(project))
+ end
+ end
+
+ describe 'GET /:namespace/:project/usage_quotas' do
+ context 'with project_storage_ui feature flag enabled' do
+ before do
+ stub_feature_flags(project_storage_ui: true)
+ end
+
+ it 'renders usage quotas path' do
+ mock_storage_app_data = {
+ project_path: project.full_path,
+ usage_quotas_help_page_path: help_page_path('user/usage_quotas'),
+ build_artifacts_help_page_path: help_page_path('ci/pipelines/job_artifacts', anchor: 'when-job-artifacts-are-deleted'),
+ packages_help_page_path: help_page_path('user/packages/package_registry/index.md', anchor: 'delete-a-package'),
+ repository_help_page_path: help_page_path('user/project/repository/reducing_the_repo_size_using_git'),
+ snippets_help_page_path: help_page_path('user/snippets', anchor: 'reduce-snippets-repository-size'),
+ wiki_help_page_path: help_page_path('administration/wikis/index.md', anchor: 'reduce-wiki-repository-size')
+ }
+ get project_usage_quotas_path(project)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.body).to include(project_usage_quotas_path(project))
+ expect(assigns[:storage_app_data]).to eq(mock_storage_app_data)
+ expect(response.body).to include("Usage of project resources across the <strong>#{project.name}</strong> project")
+ end
+
+ context 'renders :not_found for user without permission' do
+ let(:role) { :developer }
+
+ it_behaves_like 'response with 404 status'
+ end
+ end
+
+ context 'with project_storage_ui feature flag disabled' do
+ before do
+ stub_feature_flags(project_storage_ui: false)
+ end
+
+ it_behaves_like 'response with 404 status'
+ end
+ end
+end
diff --git a/spec/requests/rack_attack_global_spec.rb b/spec/requests/rack_attack_global_spec.rb
index a0f9d4c11ed..87ef6fa1a18 100644
--- a/spec/requests/rack_attack_global_spec.rb
+++ b/spec/requests/rack_attack_global_spec.rb
@@ -11,6 +11,8 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac
# the right settings are being exercised
let(:settings_to_set) do
{
+ throttle_unauthenticated_api_requests_per_period: 100,
+ throttle_unauthenticated_api_period_in_seconds: 1,
throttle_unauthenticated_requests_per_period: 100,
throttle_unauthenticated_period_in_seconds: 1,
throttle_authenticated_api_requests_per_period: 100,
@@ -22,7 +24,13 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac
throttle_unauthenticated_packages_api_requests_per_period: 100,
throttle_unauthenticated_packages_api_period_in_seconds: 1,
throttle_authenticated_packages_api_requests_per_period: 100,
- throttle_authenticated_packages_api_period_in_seconds: 1
+ throttle_authenticated_packages_api_period_in_seconds: 1,
+ throttle_authenticated_git_lfs_requests_per_period: 100,
+ throttle_authenticated_git_lfs_period_in_seconds: 1,
+ throttle_unauthenticated_files_api_requests_per_period: 100,
+ throttle_unauthenticated_files_api_period_in_seconds: 1,
+ throttle_authenticated_files_api_requests_per_period: 100,
+ throttle_authenticated_files_api_period_in_seconds: 1
}
end
@@ -33,186 +41,21 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac
include_context 'rack attack cache store'
- describe 'unauthenticated requests' do
- let(:url_that_does_not_require_authentication) { '/users/sign_in' }
- let(:url_api_internal) { '/api/v4/internal/check' }
-
- before do
- # Disabling protected paths throttle, otherwise requests to
- # '/users/sign_in' are caught by this throttle.
- settings_to_set[:throttle_protected_paths_enabled] = false
-
- # Set low limits
- settings_to_set[:throttle_unauthenticated_requests_per_period] = requests_per_period
- settings_to_set[:throttle_unauthenticated_period_in_seconds] = period_in_seconds
- end
-
- context 'when the throttle is enabled' do
- before do
- settings_to_set[:throttle_unauthenticated_enabled] = true
- stub_application_setting(settings_to_set)
- end
-
- it 'rejects requests over the rate limit' do
- # At first, allow requests under the rate limit.
- requests_per_period.times do
- get url_that_does_not_require_authentication
- expect(response).to have_gitlab_http_status(:ok)
- end
-
- # the last straw
- expect_rejection { get url_that_does_not_require_authentication }
- end
-
- context 'with custom response text' do
- before do
- stub_application_setting(rate_limiting_response_text: 'Custom response')
- end
-
- it 'rejects requests over the rate limit' do
- # At first, allow requests under the rate limit.
- requests_per_period.times do
- get url_that_does_not_require_authentication
- expect(response).to have_gitlab_http_status(:ok)
- end
-
- # the last straw
- expect_rejection { get url_that_does_not_require_authentication }
- expect(response.body).to eq("Custom response\n")
- end
- end
-
- it 'allows requests after throttling and then waiting for the next period' do
- requests_per_period.times do
- get url_that_does_not_require_authentication
- expect(response).to have_gitlab_http_status(:ok)
- end
-
- expect_rejection { get url_that_does_not_require_authentication }
-
- travel_to(period.from_now) do
- requests_per_period.times do
- get url_that_does_not_require_authentication
- expect(response).to have_gitlab_http_status(:ok)
- end
-
- expect_rejection { get url_that_does_not_require_authentication }
- end
- end
-
- it 'counts requests from different IPs separately' do
- requests_per_period.times do
- get url_that_does_not_require_authentication
- expect(response).to have_gitlab_http_status(:ok)
- end
-
- expect_next_instance_of(Rack::Attack::Request) do |instance|
- expect(instance).to receive(:ip).at_least(:once).and_return('1.2.3.4')
- end
-
- # would be over limit for the same IP
- get url_that_does_not_require_authentication
- expect(response).to have_gitlab_http_status(:ok)
- end
-
- context 'when the request is to the api internal endpoints' do
- it 'allows requests over the rate limit' do
- (1 + requests_per_period).times do
- get url_api_internal, params: { secret_token: Gitlab::Shell.secret_token }
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
- end
-
- context 'when the request is authenticated by a runner token' do
- let(:request_jobs_url) { '/api/v4/jobs/request' }
- let(:runner) { create(:ci_runner) }
-
- it 'does not count as unauthenticated' do
- (1 + requests_per_period).times do
- post request_jobs_url, params: { token: runner.token }
- expect(response).to have_gitlab_http_status(:no_content)
- end
- end
- end
-
- context 'when the request is to a health endpoint' do
- let(:health_endpoint) { '/-/metrics' }
-
- it 'does not throttle the requests' do
- (1 + requests_per_period).times do
- get health_endpoint
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
- end
-
- context 'when the request is to a container registry notification endpoint' do
- let(:secret_token) { 'secret_token' }
- let(:events) { [{ action: 'push' }] }
- let(:registry_endpoint) { '/api/v4/container_registry_event/events' }
- let(:registry_headers) { { 'Content-Type' => ::API::ContainerRegistryEvent::DOCKER_DISTRIBUTION_EVENTS_V1_JSON } }
-
- before do
- allow(Gitlab.config.registry).to receive(:notification_secret) { secret_token }
-
- event = spy(:event)
- allow(::ContainerRegistry::Event).to receive(:new).and_return(event)
- allow(event).to receive(:supported?).and_return(true)
- end
-
- it 'does not throttle the requests' do
- (1 + requests_per_period).times do
- post registry_endpoint,
- params: { events: events }.to_json,
- headers: registry_headers.merge('Authorization' => secret_token)
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
- end
-
- it 'logs RackAttack info into structured logs' do
- requests_per_period.times do
- get url_that_does_not_require_authentication
- expect(response).to have_gitlab_http_status(:ok)
- end
-
- arguments = a_hash_including({
- message: 'Rack_Attack',
- env: :throttle,
- remote_ip: '127.0.0.1',
- request_method: 'GET',
- path: '/users/sign_in',
- matched: 'throttle_unauthenticated'
- })
-
- expect(Gitlab::AuthLogger).to receive(:error).with(arguments)
-
- get url_that_does_not_require_authentication
- end
-
- it_behaves_like 'tracking when dry-run mode is set' do
- let(:throttle_name) { 'throttle_unauthenticated' }
-
- def do_request
- get url_that_does_not_require_authentication
- end
- end
+ describe 'unauthenticated API requests' do
+ it_behaves_like 'rate-limited unauthenticated requests' do
+ let(:throttle_name) { 'throttle_unauthenticated_api' }
+ let(:throttle_setting_prefix) { 'throttle_unauthenticated_api' }
+ let(:url_that_does_not_require_authentication) { '/api/v4/projects' }
+ let(:url_that_is_not_matched) { '/users/sign_in' }
end
+ end
- context 'when the throttle is disabled' do
- before do
- settings_to_set[:throttle_unauthenticated_enabled] = false
- stub_application_setting(settings_to_set)
- end
-
- it 'allows requests over the rate limit' do
- (1 + requests_per_period).times do
- get url_that_does_not_require_authentication
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
+ describe 'unauthenticated web requests' do
+ it_behaves_like 'rate-limited unauthenticated requests' do
+ let(:throttle_name) { 'throttle_unauthenticated_web' }
+ let(:throttle_setting_prefix) { 'throttle_unauthenticated' }
+ let(:url_that_does_not_require_authentication) { '/users/sign_in' }
+ let(:url_that_is_not_matched) { '/api/v4/projects' }
end
end
@@ -473,9 +316,9 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac
context 'when unauthenticated api throttle is enabled' do
before do
- settings_to_set[:throttle_unauthenticated_requests_per_period] = requests_per_period
- settings_to_set[:throttle_unauthenticated_period_in_seconds] = period_in_seconds
- settings_to_set[:throttle_unauthenticated_enabled] = true
+ settings_to_set[:throttle_unauthenticated_api_requests_per_period] = requests_per_period
+ settings_to_set[:throttle_unauthenticated_api_period_in_seconds] = period_in_seconds
+ settings_to_set[:throttle_unauthenticated_api_enabled] = true
stub_application_setting(settings_to_set)
end
@@ -488,6 +331,22 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac
expect_rejection { do_request }
end
end
+
+ context 'when unauthenticated web throttle is enabled' do
+ before do
+ settings_to_set[:throttle_unauthenticated_web_requests_per_period] = requests_per_period
+ settings_to_set[:throttle_unauthenticated_web_period_in_seconds] = period_in_seconds
+ settings_to_set[:throttle_unauthenticated_web_enabled] = true
+ stub_application_setting(settings_to_set)
+ end
+
+ it 'ignores unauthenticated web throttle' do
+ (1 + requests_per_period).times do
+ do_request
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
end
context 'when unauthenticated packages api throttle is enabled' do
@@ -509,9 +368,9 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac
context 'when unauthenticated api throttle is lower' do
before do
- settings_to_set[:throttle_unauthenticated_requests_per_period] = 0
- settings_to_set[:throttle_unauthenticated_period_in_seconds] = period_in_seconds
- settings_to_set[:throttle_unauthenticated_enabled] = true
+ settings_to_set[:throttle_unauthenticated_api_requests_per_period] = 0
+ settings_to_set[:throttle_unauthenticated_api_period_in_seconds] = period_in_seconds
+ settings_to_set[:throttle_unauthenticated_api_enabled] = true
stub_application_setting(settings_to_set)
end
@@ -620,6 +479,317 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac
end
end
+ describe 'authenticated git lfs requests', :api do
+ let_it_be(:project) { create(:project, :internal) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:token) { create(:personal_access_token, user: user) }
+ let_it_be(:other_user) { create(:user) }
+ let_it_be(:other_user_token) { create(:personal_access_token, user: other_user) }
+
+ let(:request_method) { 'GET' }
+ let(:throttle_setting_prefix) { 'throttle_authenticated_git_lfs' }
+ let(:git_lfs_url) { "/#{project.full_path}.git/info/lfs/locks" }
+
+ before do
+ allow(Gitlab.config.lfs).to receive(:enabled).and_return(true)
+ stub_application_setting(settings_to_set)
+ end
+
+ context 'with regular login' do
+ let(:url_that_requires_authentication) { git_lfs_url }
+
+ it_behaves_like 'rate-limited web authenticated requests'
+ end
+
+ context 'with the token in the headers' do
+ let(:request_args) { [git_lfs_url, { headers: basic_auth_headers(user, token) }] }
+ let(:other_user_request_args) { [git_lfs_url, { headers: basic_auth_headers(other_user, other_user_token) }] }
+
+ it_behaves_like 'rate-limited token-authenticated requests'
+ end
+
+ context 'precedence over authenticated web throttle' do
+ before do
+ settings_to_set[:throttle_authenticated_git_lfs_requests_per_period] = requests_per_period
+ settings_to_set[:throttle_authenticated_git_lfs_period_in_seconds] = period_in_seconds
+ end
+
+ def do_request
+ get git_lfs_url, headers: basic_auth_headers(user, token)
+ end
+
+ context 'when authenticated git lfs throttle is enabled' do
+ before do
+ settings_to_set[:throttle_authenticated_git_lfs_enabled] = true
+ end
+
+ context 'when authenticated web throttle is lower' do
+ before do
+ settings_to_set[:throttle_authenticated_web_requests_per_period] = 0
+ settings_to_set[:throttle_authenticated_web_period_in_seconds] = period_in_seconds
+ settings_to_set[:throttle_authenticated_web_enabled] = true
+ stub_application_setting(settings_to_set)
+ end
+
+ it 'ignores authenticated web throttle' do
+ requests_per_period.times do
+ do_request
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ expect_rejection { do_request }
+ end
+ end
+ end
+
+ context 'when authenticated git lfs throttle is disabled' do
+ before do
+ settings_to_set[:throttle_authenticated_git_lfs_enabled] = false
+ end
+
+ context 'when authenticated web throttle is enabled' do
+ before do
+ settings_to_set[:throttle_authenticated_web_requests_per_period] = requests_per_period
+ settings_to_set[:throttle_authenticated_web_period_in_seconds] = period_in_seconds
+ settings_to_set[:throttle_authenticated_web_enabled] = true
+ stub_application_setting(settings_to_set)
+ end
+
+ it 'rejects requests over the authenticated web rate limit' do
+ requests_per_period.times do
+ do_request
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ expect_rejection { do_request }
+ end
+ end
+ end
+ end
+ end
+
+ describe 'Files API' do
+ let(:request_method) { 'GET' }
+
+ context 'unauthenticated' do
+ let_it_be(:project) { create(:project, :public, :custom_repo, files: { 'README' => 'foo' }) }
+
+ let(:throttle_setting_prefix) { 'throttle_unauthenticated_files_api' }
+ let(:files_path_that_does_not_require_authentication) { "/api/v4/projects/#{project.id}/repository/files/README?ref=master" }
+
+ def do_request
+ get files_path_that_does_not_require_authentication
+ end
+
+ before do
+ settings_to_set[:throttle_unauthenticated_files_api_requests_per_period] = requests_per_period
+ settings_to_set[:throttle_unauthenticated_files_api_period_in_seconds] = period_in_seconds
+ end
+
+ context 'when unauthenticated files api throttle is disabled' do
+ before do
+ settings_to_set[:throttle_unauthenticated_files_api_enabled] = false
+ stub_application_setting(settings_to_set)
+ end
+
+ it 'allows requests over the rate limit' do
+ (1 + requests_per_period).times do
+ do_request
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'when unauthenticated api throttle is enabled' do
+ before do
+ settings_to_set[:throttle_unauthenticated_api_requests_per_period] = requests_per_period
+ settings_to_set[:throttle_unauthenticated_api_period_in_seconds] = period_in_seconds
+ settings_to_set[:throttle_unauthenticated_api_enabled] = true
+ stub_application_setting(settings_to_set)
+ end
+
+ it 'rejects requests over the unauthenticated api rate limit' do
+ requests_per_period.times do
+ do_request
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ expect_rejection { do_request }
+ end
+ end
+
+ context 'when unauthenticated web throttle is enabled' do
+ before do
+ settings_to_set[:throttle_unauthenticated_web_requests_per_period] = requests_per_period
+ settings_to_set[:throttle_unauthenticated_web_period_in_seconds] = period_in_seconds
+ settings_to_set[:throttle_unauthenticated_web_enabled] = true
+ stub_application_setting(settings_to_set)
+ end
+
+ it 'ignores unauthenticated web throttle' do
+ (1 + requests_per_period).times do
+ do_request
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+ end
+
+ context 'when unauthenticated files api throttle is enabled' do
+ before do
+ settings_to_set[:throttle_unauthenticated_files_api_requests_per_period] = requests_per_period # 1
+ settings_to_set[:throttle_unauthenticated_files_api_period_in_seconds] = period_in_seconds # 10_000
+ settings_to_set[:throttle_unauthenticated_files_api_enabled] = true
+ stub_application_setting(settings_to_set)
+ end
+
+ it 'rejects requests over the rate limit' do
+ requests_per_period.times do
+ do_request
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ expect_rejection { do_request }
+ end
+
+ context 'when feature flag is off' do
+ before do
+ stub_feature_flags(files_api_throttling: false)
+ end
+
+ it 'allows requests over the rate limit' do
+ (1 + requests_per_period).times do
+ do_request
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ context 'when unauthenticated api throttle is lower' do
+ before do
+ settings_to_set[:throttle_unauthenticated_api_requests_per_period] = 0
+ settings_to_set[:throttle_unauthenticated_api_period_in_seconds] = period_in_seconds
+ settings_to_set[:throttle_unauthenticated_api_enabled] = true
+ stub_application_setting(settings_to_set)
+ end
+
+ it 'ignores unauthenticated api throttle' do
+ requests_per_period.times do
+ do_request
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ expect_rejection { do_request }
+ end
+ end
+
+ it_behaves_like 'tracking when dry-run mode is set' do
+ let(:throttle_name) { 'throttle_unauthenticated_files_api' }
+ end
+ end
+ end
+
+ context 'authenticated', :api do
+ let_it_be(:project) { create(:project, :internal, :custom_repo, files: { 'README' => 'foo' }) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:token) { create(:personal_access_token, user: user) }
+ let_it_be(:other_user) { create(:user) }
+ let_it_be(:other_user_token) { create(:personal_access_token, user: other_user) }
+
+ let(:throttle_setting_prefix) { 'throttle_authenticated_files_api' }
+ let(:api_partial_url) { "/projects/#{project.id}/repository/files/README?ref=master" }
+
+ before do
+ stub_application_setting(settings_to_set)
+ end
+
+ context 'with the token in the query string' do
+ let(:request_args) { [api(api_partial_url, personal_access_token: token), {}] }
+ let(:other_user_request_args) { [api(api_partial_url, personal_access_token: other_user_token), {}] }
+
+ it_behaves_like 'rate-limited token-authenticated requests'
+ end
+
+ context 'with the token in the headers' do
+ let(:request_args) { api_get_args_with_token_headers(api_partial_url, personal_access_token_headers(token)) }
+ let(:other_user_request_args) { api_get_args_with_token_headers(api_partial_url, personal_access_token_headers(other_user_token)) }
+
+ it_behaves_like 'rate-limited token-authenticated requests'
+ end
+
+ context 'precedence over authenticated api throttle' do
+ before do
+ settings_to_set[:throttle_authenticated_files_api_requests_per_period] = requests_per_period
+ settings_to_set[:throttle_authenticated_files_api_period_in_seconds] = period_in_seconds
+ end
+
+ def do_request
+ get api(api_partial_url, personal_access_token: token)
+ end
+
+ context 'when authenticated files api throttle is enabled' do
+ before do
+ settings_to_set[:throttle_authenticated_files_api_enabled] = true
+ end
+
+ context 'when authenticated api throttle is lower' do
+ before do
+ settings_to_set[:throttle_authenticated_api_requests_per_period] = 0
+ settings_to_set[:throttle_authenticated_api_period_in_seconds] = period_in_seconds
+ settings_to_set[:throttle_authenticated_api_enabled] = true
+ stub_application_setting(settings_to_set)
+ end
+
+ it 'ignores authenticated api throttle' do
+ requests_per_period.times do
+ do_request
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ expect_rejection { do_request }
+ end
+ end
+
+ context 'when feature flag is off' do
+ before do
+ stub_feature_flags(files_api_throttling: false)
+ end
+
+ it 'allows requests over the rate limit' do
+ (1 + requests_per_period).times do
+ do_request
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+ end
+
+ context 'when authenticated files api throttle is disabled' do
+ before do
+ settings_to_set[:throttle_authenticated_files_api_enabled] = false
+ end
+
+ context 'when authenticated api throttle is enabled' do
+ before do
+ settings_to_set[:throttle_authenticated_api_requests_per_period] = requests_per_period
+ settings_to_set[:throttle_authenticated_api_period_in_seconds] = period_in_seconds
+ settings_to_set[:throttle_authenticated_api_enabled] = true
+ stub_application_setting(settings_to_set)
+ end
+
+ it 'rejects requests over the authenticated api rate limit' do
+ requests_per_period.times do
+ do_request
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ expect_rejection { do_request }
+ end
+ end
+ end
+ end
+ end
+ end
+
describe 'throttle bypass header' do
let(:headers) { {} }
let(:bypass_header) { 'gitlab-bypass-rate-limiting' }
diff --git a/spec/requests/users/group_callouts_spec.rb b/spec/requests/users/group_callouts_spec.rb
new file mode 100644
index 00000000000..a8680c3add4
--- /dev/null
+++ b/spec/requests/users/group_callouts_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Group callouts' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+
+ before do
+ sign_in(user)
+ end
+
+ describe 'POST /-/users/group_callouts' do
+ let(:params) { { feature_name: feature_name, group_id: group.id } }
+
+ subject { post group_callouts_path, params: params, headers: { 'ACCEPT' => 'application/json' } }
+
+ context 'with valid feature name and group' do
+ let(:feature_name) { Users::GroupCallout.feature_names.each_key.first }
+
+ context 'when callout entry does not exist' do
+ it 'creates a callout entry with dismissed state' do
+ expect { subject }.to change { Users::GroupCallout.count }.by(1)
+ end
+
+ it 'returns success' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'when callout entry already exists' do
+ let!(:callout) do
+ create(:group_callout,
+ feature_name: Users::GroupCallout.feature_names.each_key.first,
+ user: user,
+ group: group)
+ end
+
+ it 'returns success', :aggregate_failures do
+ expect { subject }.not_to change { Users::GroupCallout.count }
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ context 'with invalid feature name' do
+ let(:feature_name) { 'bogus_feature_name' }
+
+ it 'returns bad request' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+ end
+end
diff --git a/spec/rubocop/cop/gitlab/mark_used_feature_flags_spec.rb b/spec/rubocop/cop/gitlab/mark_used_feature_flags_spec.rb
index 35b21477d80..6b5b07fb357 100644
--- a/spec/rubocop/cop/gitlab/mark_used_feature_flags_spec.rb
+++ b/spec/rubocop/cop/gitlab/mark_used_feature_flags_spec.rb
@@ -16,6 +16,7 @@ RSpec.describe RuboCop::Cop::Gitlab::MarkUsedFeatureFlags do
stub_const("#{described_class}::DYNAMIC_FEATURE_FLAGS", [])
allow(cop).to receive(:defined_feature_flags).and_return(defined_feature_flags)
allow(cop).to receive(:usage_data_counters_known_event_feature_flags).and_return([])
+ described_class.feature_flags_already_tracked = false
end
def feature_flag_path(feature_flag_name)
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 899872859a9..f6bed0d74fb 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
@@ -11,6 +11,7 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do
before do
allow(cop).to receive(:in_migration?).and_return(true)
+ allow(cop).to receive(:version).and_return(described_class::TEXT_LIMIT_ATTRIBUTE_ALLOWED_SINCE + 5)
end
context 'when text columns are defined without a limit' do
@@ -26,7 +27,7 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do
^^^^ #{msg}
end
- create_table_with_constraints :test_text_limits_create do |t|
+ create_table :test_text_limits_create do |t|
t.integer :test_id, null: false
t.text :title
t.text :description
@@ -61,13 +62,10 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do
t.text :name
end
- create_table_with_constraints :test_text_limits_create do |t|
+ create_table :test_text_limits_create do |t|
t.integer :test_id, null: false
- t.text :title
- t.text :description
-
- t.text_limit :title, 100
- t.text_limit :description, 255
+ t.text :title, limit: 100
+ t.text :description, limit: 255
end
add_column :test_text_limits, :email, :text
@@ -82,6 +80,30 @@ RSpec.describe RuboCop::Cop::Migration::AddLimitToTextColumns do
end
RUBY
end
+
+ context 'for migrations before 2021_09_10_00_00_00' do
+ it 'when limit: attribute is used (which is not supported yet for this version): registers an offense' do
+ allow(cop).to receive(:version).and_return(described_class::TEXT_LIMIT_ATTRIBUTE_ALLOWED_SINCE - 5)
+
+ expect_offense(<<~RUBY)
+ class TestTextLimits < ActiveRecord::Migration[6.0]
+ def up
+ create_table :test_text_limit_attribute do |t|
+ t.integer :test_id, null: false
+ t.text :name, limit: 100
+ ^^^^ Text columns should always have a limit set (255 is suggested). Using limit: is not supported in this version. You can add a limit to a `text` column by using `add_text_limit` or `.text_limit` inside `create_table`
+ end
+
+ create_table_with_constraints :test_text_limit_attribute do |t|
+ t.integer :test_id, null: false
+ t.text :name, limit: 100
+ ^^^^ Text columns should always have a limit set (255 is suggested). Using limit: is not supported in this version. You can add a limit to a `text` column by using `add_text_limit` or `.text_limit` inside `create_table`
+ end
+ end
+ end
+ RUBY
+ end
+ end
end
context 'when text array columns are defined without a limit' do
diff --git a/spec/rubocop/cop/migration/prevent_index_creation_spec.rb b/spec/rubocop/cop/migration/prevent_index_creation_spec.rb
index a3965f54bbd..ed7c8974d8d 100644
--- a/spec/rubocop/cop/migration/prevent_index_creation_spec.rb
+++ b/spec/rubocop/cop/migration/prevent_index_creation_spec.rb
@@ -6,28 +6,76 @@ require_relative '../../../../rubocop/cop/migration/prevent_index_creation'
RSpec.describe RuboCop::Cop::Migration::PreventIndexCreation do
subject(:cop) { described_class.new }
+ let(:forbidden_tables) { %w(ci_builds) }
+ let(:forbidden_tables_list) { forbidden_tables.join(', ') }
+
context 'when in migration' do
before do
allow(cop).to receive(:in_migration?).and_return(true)
end
context 'when adding an index to a forbidden table' do
- it 'registers an offense when add_index is used' do
- expect_offense(<<~RUBY)
- def change
- add_index :ci_builds, :protected
- ^^^^^^^^^ Adding new index to ci_builds is forbidden, see https://gitlab.com/gitlab-org/gitlab/-/issues/332886
+ context 'when table_name is a symbol' do
+ it "registers an offense when add_index is used", :aggregate_failures do
+ forbidden_tables.each do |table_name|
+ expect_offense(<<~RUBY)
+ def change
+ add_index :#{table_name}, :protected
+ ^^^^^^^^^ Adding new index to #{forbidden_tables_list} is forbidden, see https://gitlab.com/gitlab-org/gitlab/-/issues/332886
+ end
+ RUBY
end
- RUBY
+ end
+
+ it "registers an offense when add_concurrent_index is used", :aggregate_failures do
+ forbidden_tables.each do |table_name|
+ expect_offense(<<~RUBY)
+ def change
+ add_concurrent_index :#{table_name}, :protected
+ ^^^^^^^^^^^^^^^^^^^^ Adding new index to #{forbidden_tables_list} is forbidden, see https://gitlab.com/gitlab-org/gitlab/-/issues/332886
+ end
+ RUBY
+ end
+ end
end
- it 'registers an offense when add_concurrent_index is used' do
- expect_offense(<<~RUBY)
- def change
- add_concurrent_index :ci_builds, :protected
- ^^^^^^^^^^^^^^^^^^^^ Adding new index to ci_builds is forbidden, see https://gitlab.com/gitlab-org/gitlab/-/issues/332886
+ context 'when table_name is a string' do
+ it "registers an offense when add_index is used", :aggregate_failures do
+ forbidden_tables.each do |table_name|
+ expect_offense(<<~RUBY)
+ def change
+ add_index "#{table_name}", :protected
+ ^^^^^^^^^ Adding new index to #{forbidden_tables_list} is forbidden, see https://gitlab.com/gitlab-org/gitlab/-/issues/332886
+ end
+ RUBY
end
- RUBY
+ end
+
+ it "registers an offense when add_concurrent_index is used", :aggregate_failures do
+ forbidden_tables.each do |table_name|
+ expect_offense(<<~RUBY)
+ def change
+ add_concurrent_index "#{table_name}", :protected
+ ^^^^^^^^^^^^^^^^^^^^ Adding new index to #{forbidden_tables_list} is forbidden, see https://gitlab.com/gitlab-org/gitlab/-/issues/332886
+ end
+ RUBY
+ end
+ end
+ end
+
+ context 'when table_name is a constant' do
+ it "registers an offense when add_concurrent_index is used", :aggregate_failures do
+ expect_offense(<<~RUBY)
+ INDEX_NAME = "index_name"
+ TABLE_NAME = :ci_builds
+ disable_ddl_transaction!
+
+ def change
+ add_concurrent_index TABLE_NAME, :protected
+ ^^^^^^^^^^^^^^^^^^^^ Adding new index to #{forbidden_tables_list} is forbidden, see https://gitlab.com/gitlab-org/gitlab/-/issues/332886
+ end
+ RUBY
+ end
end
end
@@ -39,6 +87,20 @@ RSpec.describe RuboCop::Cop::Migration::PreventIndexCreation do
end
RUBY
end
+
+ context 'when using a constant' do
+ it 'does not register an offense' do
+ expect_no_offenses(<<~RUBY)
+ disable_ddl_transaction!
+
+ TABLE_NAME = "not_forbidden"
+
+ def up
+ add_concurrent_index TABLE_NAME, :protected
+ end
+ RUBY
+ end
+ end
end
end
diff --git a/spec/rubocop/cop/migration/versioned_migration_class_spec.rb b/spec/rubocop/cop/migration/versioned_migration_class_spec.rb
new file mode 100644
index 00000000000..d9b0cd4546c
--- /dev/null
+++ b/spec/rubocop/cop/migration/versioned_migration_class_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require_relative '../../../../rubocop/cop/migration/versioned_migration_class'
+
+RSpec.describe RuboCop::Cop::Migration::VersionedMigrationClass do
+ subject(:cop) { described_class.new }
+
+ let(:migration) do
+ <<~SOURCE
+ class TestMigration < Gitlab::Database::Migration[1.0]
+ def up
+ execute 'select 1'
+ end
+
+ def down
+ execute 'select 1'
+ end
+ end
+ SOURCE
+ end
+
+ shared_examples 'a disabled cop' do
+ it 'does not register any offenses' do
+ expect_no_offenses(migration)
+ end
+ end
+
+ context 'outside of a migration' do
+ it_behaves_like 'a disabled cop'
+ end
+
+ context 'in migration' do
+ before do
+ allow(cop).to receive(:in_migration?).and_return(true)
+ end
+
+ context 'in an old migration' do
+ before do
+ allow(cop).to receive(:version).and_return(described_class::ENFORCED_SINCE - 5)
+ end
+
+ it_behaves_like 'a disabled cop'
+ end
+
+ context 'that is recent' do
+ before do
+ allow(cop).to receive(:version).and_return(described_class::ENFORCED_SINCE + 5)
+ end
+
+ it 'adds an offence if inheriting from ActiveRecord::Migration' do
+ expect_offense(<<~RUBY)
+ class MyMigration < ActiveRecord::Migration[6.1]
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't inherit from ActiveRecord::Migration but use Gitlab::Database::Migration[1.0] instead. See https://docs.gitlab.com/ee/development/migration_style_guide.html#migration-helpers-and-versioning.
+ end
+ RUBY
+ end
+
+ it 'adds an offence if including Gitlab::Database::MigrationHelpers directly' do
+ expect_offense(<<~RUBY)
+ class MyMigration < Gitlab::Database::Migration[1.0]
+ include Gitlab::Database::MigrationHelpers
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't include migration helper modules directly. Inherit from Gitlab::Database::Migration[1.0] instead. See https://docs.gitlab.com/ee/development/migration_style_guide.html#migration-helpers-and-versioning.
+ end
+ RUBY
+ end
+
+ it 'excludes ActiveRecord classes defined inside the migration' do
+ expect_no_offenses(<<~RUBY)
+ class TestMigration < Gitlab::Database::Migration[1.0]
+ class TestModel < ApplicationRecord
+ end
+
+ class AnotherTestModel < ActiveRecord::Base
+ end
+ end
+ RUBY
+ end
+ end
+ end
+end
diff --git a/spec/rubocop/cop/performance/active_record_subtransaction_methods_spec.rb b/spec/rubocop/cop/performance/active_record_subtransaction_methods_spec.rb
new file mode 100644
index 00000000000..df18121e2df
--- /dev/null
+++ b/spec/rubocop/cop/performance/active_record_subtransaction_methods_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'rspec-parameterized'
+
+require_relative '../../../../rubocop/cop/performance/active_record_subtransaction_methods'
+
+RSpec.describe RuboCop::Cop::Performance::ActiveRecordSubtransactionMethods do
+ subject(:cop) { described_class.new }
+
+ let(:message) { described_class::MSG }
+
+ shared_examples 'a method that uses a subtransaction' do |method_name|
+ it 'registers an offense' do
+ expect_offense(<<~RUBY, method_name: method_name, message: message)
+ Project.%{method_name}
+ ^{method_name} %{message}
+ RUBY
+ end
+ end
+
+ context 'when the method uses a subtransaction' do
+ where(:method) { described_class::DISALLOWED_METHODS.to_a }
+
+ with_them do
+ include_examples 'a method that uses a subtransaction', params[:method]
+ end
+ end
+end
diff --git a/spec/rubocop/cop/performance/active_record_subtransactions_spec.rb b/spec/rubocop/cop/performance/active_record_subtransactions_spec.rb
new file mode 100644
index 00000000000..0da2e30062a
--- /dev/null
+++ b/spec/rubocop/cop/performance/active_record_subtransactions_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require_relative '../../../../rubocop/cop/performance/active_record_subtransactions'
+
+RSpec.describe RuboCop::Cop::Performance::ActiveRecordSubtransactions do
+ subject(:cop) { described_class.new }
+
+ let(:message) { described_class::MSG }
+
+ context 'when calling #transaction with only requires_new: true' do
+ it 'registers an offense' do
+ expect_offense(<<~RUBY)
+ ApplicationRecord.transaction(requires_new: true) do
+ ^^^^^^^^^^^^^^^^^^ #{message}
+ Project.create!(name: 'MyProject')
+ end
+ RUBY
+ end
+ end
+
+ context 'when passing multiple arguments to #transaction, including requires_new: true' do
+ it 'registers an offense' do
+ expect_offense(<<~RUBY)
+ ApplicationRecord.transaction(isolation: :read_committed, requires_new: true) do
+ ^^^^^^^^^^^^^^^^^^ #{message}
+ Project.create!(name: 'MyProject')
+ end
+ RUBY
+ end
+ end
+
+ context 'when calling #transaction with requires_new: false' do
+ it 'does not register an offense' do
+ expect_no_offenses(<<~RUBY)
+ ApplicationRecord.transaction(requires_new: false) do
+ Project.create!(name: 'MyProject')
+ end
+ RUBY
+ end
+ end
+
+ context 'when calling #transaction with other options' do
+ it 'does not register an offense' do
+ expect_no_offenses(<<~RUBY)
+ ApplicationRecord.transaction(isolation: :read_committed) do
+ Project.create!(name: 'MyProject')
+ end
+ RUBY
+ end
+ end
+
+ context 'when calling #transaction with no arguments' do
+ it 'does not register an offense' do
+ expect_no_offenses(<<~RUBY)
+ ApplicationRecord.transaction do
+ Project.create!(name: 'MyProject')
+ end
+ RUBY
+ end
+ end
+end
diff --git a/spec/rubocop/cop/worker_data_consistency_spec.rb b/spec/rubocop/cop/sidekiq_load_balancing/worker_data_consistency_spec.rb
index 5fa42bf2b87..cf8d0d1b66f 100644
--- a/spec/rubocop/cop/worker_data_consistency_spec.rb
+++ b/spec/rubocop/cop/sidekiq_load_balancing/worker_data_consistency_spec.rb
@@ -1,9 +1,9 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require_relative '../../../rubocop/cop/worker_data_consistency'
+require_relative '../../../../rubocop/cop/sidekiq_load_balancing/worker_data_consistency'
-RSpec.describe RuboCop::Cop::WorkerDataConsistency do
+RSpec.describe RuboCop::Cop::SidekiqLoadBalancing::WorkerDataConsistency do
subject(:cop) { described_class.new }
before do
diff --git a/spec/rubocop/cop/sidekiq_load_balancing/worker_data_consistency_with_deduplication_spec.rb b/spec/rubocop/cop/sidekiq_load_balancing/worker_data_consistency_with_deduplication_spec.rb
new file mode 100644
index 00000000000..6e7212b1002
--- /dev/null
+++ b/spec/rubocop/cop/sidekiq_load_balancing/worker_data_consistency_with_deduplication_spec.rb
@@ -0,0 +1,166 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'rspec-parameterized'
+require_relative '../../../../rubocop/cop/sidekiq_load_balancing/worker_data_consistency_with_deduplication'
+
+RSpec.describe RuboCop::Cop::SidekiqLoadBalancing::WorkerDataConsistencyWithDeduplication do
+ using RSpec::Parameterized::TableSyntax
+
+ subject(:cop) { described_class.new }
+
+ before do
+ allow(cop)
+ .to receive(:in_worker?)
+ .and_return(true)
+ end
+
+ where(:data_consistency) { %i[delayed sticky] }
+
+ with_them do
+ let(:strategy) { described_class::DEFAULT_STRATEGY }
+ let(:corrected) do
+ <<~CORRECTED
+ class SomeWorker
+ include ApplicationWorker
+
+ data_consistency :#{data_consistency}
+
+ deduplicate #{strategy}, including_scheduled: true
+ idempotent!
+ end
+ CORRECTED
+ end
+
+ context 'when deduplication strategy is not explicitly set' do
+ it 'registers an offense and corrects using default strategy' do
+ expect_offense(<<~CODE)
+ class SomeWorker
+ include ApplicationWorker
+
+ data_consistency :#{data_consistency}
+
+ idempotent!
+ ^^^^^^^^^^^ Workers that declare either `:sticky` or `:delayed` data consistency [...]
+ end
+ CODE
+
+ expect_correction(corrected)
+ end
+
+ context 'when identation is different' do
+ let(:corrected) do
+ <<~CORRECTED
+ class SomeWorker
+ include ApplicationWorker
+
+ data_consistency :#{data_consistency}
+
+ deduplicate #{strategy}, including_scheduled: true
+ idempotent!
+ end
+ CORRECTED
+ end
+
+ it 'registers an offense and corrects with correct identation' do
+ expect_offense(<<~CODE)
+ class SomeWorker
+ include ApplicationWorker
+
+ data_consistency :#{data_consistency}
+
+ idempotent!
+ ^^^^^^^^^^^ Workers that declare either `:sticky` or `:delayed` data consistency [...]
+ end
+ CODE
+
+ expect_correction(corrected)
+ end
+ end
+ end
+
+ context 'when deduplication strategy does not include including_scheduling option' do
+ let(:strategy) { ':until_executed' }
+
+ it 'registers an offense and corrects' do
+ expect_offense(<<~CODE)
+ class SomeWorker
+ include ApplicationWorker
+
+ data_consistency :#{data_consistency}
+
+ deduplicate :until_executed
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Workers that declare either `:sticky` or `:delayed` data consistency [...]
+ idempotent!
+ end
+ CODE
+
+ expect_correction(corrected)
+ end
+ end
+
+ context 'when deduplication strategy has including_scheduling option disabled' do
+ let(:strategy) { ':until_executed' }
+
+ it 'registers an offense and corrects' do
+ expect_offense(<<~CODE)
+ class SomeWorker
+ include ApplicationWorker
+
+ data_consistency :#{data_consistency}
+
+ deduplicate :until_executed, including_scheduled: false
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Workers that declare either `:sticky` or `:delayed` data consistency [...]
+ idempotent!
+ end
+ CODE
+
+ expect_correction(corrected)
+ end
+ end
+
+ context "when deduplication strategy is :none" do
+ it 'does not register an offense' do
+ expect_no_offenses(<<~CODE)
+ class SomeWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ deduplicate :none
+ idempotent!
+ end
+ CODE
+ end
+ end
+
+ context "when deduplication strategy has including_scheduling option enabled" do
+ it 'does not register an offense' do
+ expect_no_offenses(<<~CODE)
+ class SomeWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ deduplicate :until_executing, including_scheduled: true
+ idempotent!
+ end
+ CODE
+ end
+ end
+ end
+
+ context "data_consistency: :always" do
+ it 'does not register an offense' do
+ expect_no_offenses(<<~CODE)
+ class SomeWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ idempotent!
+ end
+ CODE
+ end
+ end
+end
diff --git a/spec/serializers/group_child_entity_spec.rb b/spec/serializers/group_child_entity_spec.rb
index 7f330da44a7..e4844c25067 100644
--- a/spec/serializers/group_child_entity_spec.rb
+++ b/spec/serializers/group_child_entity_spec.rb
@@ -87,7 +87,7 @@ RSpec.describe GroupChildEntity do
expect(json[:children_count]).to eq(2)
end
- %w[children_count leave_path parent_id number_projects_with_delimiter number_users_with_delimiter project_count subgroup_count].each do |attribute|
+ %w[children_count leave_path parent_id number_users_with_delimiter project_count subgroup_count].each do |attribute|
it "includes #{attribute}" do
expect(json[attribute.to_sym]).to be_present
end
@@ -114,6 +114,40 @@ RSpec.describe GroupChildEntity do
it_behaves_like 'group child json'
end
+ describe 'for a private group' do
+ let(:object) do
+ create(:group, :private)
+ end
+
+ describe 'user is member of the group' do
+ before do
+ object.add_owner(user)
+ end
+
+ it 'includes the counts' do
+ expect(json.keys).to include(*%i(project_count subgroup_count))
+ end
+ end
+
+ describe 'user is not a member of the group' do
+ it 'does not include the counts' do
+ expect(json.keys).not_to include(*%i(project_count subgroup_count))
+ end
+ end
+
+ describe 'user is only a member of a project in the group' do
+ let(:project) { create(:project, namespace: object) }
+
+ before do
+ project.add_guest(user)
+ end
+
+ it 'does not include the counts' do
+ expect(json.keys).not_to include(*%i(project_count subgroup_count))
+ end
+ end
+ end
+
describe 'for a project with external authorization enabled' do
let(:object) do
create(:project, :with_avatar,
diff --git a/spec/serializers/issuable_sidebar_extras_entity_spec.rb b/spec/serializers/issuable_sidebar_extras_entity_spec.rb
index f49b9acfd5d..80c135cdc22 100644
--- a/spec/serializers/issuable_sidebar_extras_entity_spec.rb
+++ b/spec/serializers/issuable_sidebar_extras_entity_spec.rb
@@ -10,11 +10,7 @@ RSpec.describe IssuableSidebarExtrasEntity do
subject { described_class.new(resource, request: request).as_json }
- it 'have subscribe attributes' do
- expect(subject).to include(:participants,
- :project_emails_disabled,
- :subscribe_disabled_description,
- :subscribed,
- :assignees)
+ it 'have assignee attribute' do
+ expect(subject).to include(:assignees)
end
end
diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb
index bcad9eb6e23..587d167520f 100644
--- a/spec/serializers/pipeline_serializer_spec.rb
+++ b/spec/serializers/pipeline_serializer_spec.rb
@@ -202,7 +202,7 @@ RSpec.describe PipelineSerializer do
# Existing numbers are high and require performance optimization
# Ongoing issue:
# https://gitlab.com/gitlab-org/gitlab/-/issues/225156
- expected_queries = Gitlab.ee? ? 77 : 70
+ expected_queries = Gitlab.ee? ? 74 : 70
expect(recorded.count).to be_within(2).of(expected_queries)
expect(recorded.cached_count).to eq(0)
diff --git a/spec/services/application_settings/update_service_spec.rb b/spec/services/application_settings/update_service_spec.rb
index 56c1284927d..a1fd89bcad7 100644
--- a/spec/services/application_settings/update_service_spec.rb
+++ b/spec/services/application_settings/update_service_spec.rb
@@ -336,6 +336,31 @@ RSpec.describe ApplicationSettings::UpdateService do
end
end
+ context 'when general rate limits are passed' do
+ let(:params) do
+ {
+ throttle_authenticated_api_enabled: true,
+ throttle_authenticated_api_period_in_seconds: 10,
+ throttle_authenticated_api_requests_per_period: 20,
+ throttle_authenticated_web_enabled: true,
+ throttle_authenticated_web_period_in_seconds: 30,
+ throttle_authenticated_web_requests_per_period: 40,
+ throttle_unauthenticated_api_enabled: true,
+ throttle_unauthenticated_api_period_in_seconds: 50,
+ throttle_unauthenticated_api_requests_per_period: 60,
+ throttle_unauthenticated_enabled: true,
+ throttle_unauthenticated_period_in_seconds: 50,
+ throttle_unauthenticated_requests_per_period: 60
+ }
+ end
+
+ it 'updates general throttle settings' do
+ subject.execute
+
+ expect(application_settings.reload).to have_attributes(params)
+ end
+ end
+
context 'when package registry rate limits are passed' do
let(:params) do
{
@@ -362,6 +387,52 @@ RSpec.describe ApplicationSettings::UpdateService do
end
end
+ context 'when files API rate limits are passed' do
+ let(:params) do
+ {
+ throttle_unauthenticated_files_api_enabled: 1,
+ throttle_unauthenticated_files_api_period_in_seconds: 500,
+ throttle_unauthenticated_files_api_requests_per_period: 20,
+ throttle_authenticated_files_api_enabled: 1,
+ throttle_authenticated_files_api_period_in_seconds: 600,
+ throttle_authenticated_files_api_requests_per_period: 10
+ }
+ end
+
+ it 'updates files API throttle settings' do
+ subject.execute
+
+ application_settings.reload
+
+ expect(application_settings.throttle_unauthenticated_files_api_enabled).to be_truthy
+ expect(application_settings.throttle_unauthenticated_files_api_period_in_seconds).to eq(500)
+ expect(application_settings.throttle_unauthenticated_files_api_requests_per_period).to eq(20)
+ expect(application_settings.throttle_authenticated_files_api_enabled).to be_truthy
+ expect(application_settings.throttle_authenticated_files_api_period_in_seconds).to eq(600)
+ expect(application_settings.throttle_authenticated_files_api_requests_per_period).to eq(10)
+ end
+ end
+
+ context 'when git lfs rate limits are passed' do
+ let(:params) do
+ {
+ throttle_authenticated_git_lfs_enabled: 1,
+ throttle_authenticated_git_lfs_period_in_seconds: 600,
+ throttle_authenticated_git_lfs_requests_per_period: 10
+ }
+ end
+
+ it 'updates git lfs throttle settings' do
+ subject.execute
+
+ application_settings.reload
+
+ expect(application_settings.throttle_authenticated_git_lfs_enabled).to be_truthy
+ expect(application_settings.throttle_authenticated_git_lfs_period_in_seconds).to eq(600)
+ expect(application_settings.throttle_authenticated_git_lfs_requests_per_period).to eq(10)
+ end
+ end
+
context 'when issues_create_limit is passed' do
let(:params) do
{
diff --git a/spec/services/auth/container_registry_authentication_service_spec.rb b/spec/services/auth/container_registry_authentication_service_spec.rb
index b456f7a2745..46cc027fcb3 100644
--- a/spec/services/auth/container_registry_authentication_service_spec.rb
+++ b/spec/services/auth/container_registry_authentication_service_spec.rb
@@ -84,5 +84,36 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
it_behaves_like 'a modified token'
end
+
+ describe '#access_token' do
+ let(:token) { described_class.access_token(%w[push], [project.full_path]) }
+
+ subject { { token: token } }
+
+ it_behaves_like 'a modified token'
+ end
+ end
+
+ context 'when not in migration mode' do
+ include_context 'container registry auth service context'
+
+ let_it_be(:project) { create(:project) }
+
+ before do
+ stub_feature_flags(container_registry_migration_phase1: false)
+ end
+
+ shared_examples 'an unmodified token' do
+ it_behaves_like 'a valid token'
+ it { expect(payload['access']).not_to include(have_key('migration_eligible')) }
+ end
+
+ describe '#access_token' do
+ let(:token) { described_class.access_token(%w[push], [project.full_path]) }
+
+ subject { { token: token } }
+
+ it_behaves_like 'an unmodified token'
+ end
end
end
diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb
index bbdc178b234..d1f854f72bc 100644
--- a/spec/services/boards/issues/list_service_spec.rb
+++ b/spec/services/boards/issues/list_service_spec.rb
@@ -139,4 +139,51 @@ RSpec.describe Boards::Issues::ListService do
end
# rubocop: enable RSpec/MultipleMemoizedHelpers
end
+
+ describe '.initialize_relative_positions' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :empty_repo) }
+ let_it_be(:board) { create(:board, project: project) }
+ let_it_be(:backlog) { create(:backlog_list, board: board) }
+
+ let(:issue) { create(:issue, project: project, relative_position: nil) }
+
+ context "when 'Gitlab::Database::read_write?' is true" do
+ before do
+ allow(Gitlab::Database).to receive(:read_write?).and_return(true)
+ end
+
+ context 'user cannot move issues' do
+ it 'does not initialize the relative positions of issues' do
+ described_class.initialize_relative_positions(board, user, [issue])
+
+ expect(issue.relative_position).to eq nil
+ end
+ end
+
+ context 'user can move issues' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'initializes the relative positions of issues' do
+ described_class.initialize_relative_positions(board, user, [issue])
+
+ expect(issue.relative_position).not_to eq nil
+ end
+ end
+ end
+
+ context "when 'Gitlab::Database::read_write?' is false" do
+ before do
+ allow(Gitlab::Database).to receive(:read_write?).and_return(false)
+ end
+
+ it 'does not initialize the relative positions of issues' do
+ described_class.initialize_relative_positions(board, user, [issue])
+
+ expect(issue.relative_position).to eq nil
+ end
+ end
+ end
end
diff --git a/spec/services/ci/after_requeue_job_service_spec.rb b/spec/services/ci/after_requeue_job_service_spec.rb
index df5ddcafb37..2a5a971fdac 100644
--- a/spec/services/ci/after_requeue_job_service_spec.rb
+++ b/spec/services/ci/after_requeue_job_service_spec.rb
@@ -44,16 +44,6 @@ RSpec.describe Ci::AfterRequeueJobService do
it 'marks subsequent skipped jobs as processable' do
expect { execute_service }.to change { test4.reload.status }.from('skipped').to('created')
end
-
- context 'with ci_same_stage_job_needs FF disabled' do
- before do
- stub_feature_flags(ci_same_stage_job_needs: false)
- end
-
- it 'does nothing with the build' do
- expect { execute_service }.not_to change { test4.reload.status }
- end
- end
end
context 'when the pipeline is a downstream pipeline and the bridge is depended' do
diff --git a/spec/services/ci/archive_trace_service_spec.rb b/spec/services/ci/archive_trace_service_spec.rb
index 12804efc28c..071b5c3b2f9 100644
--- a/spec/services/ci/archive_trace_service_spec.rb
+++ b/spec/services/ci/archive_trace_service_spec.rb
@@ -12,6 +12,7 @@ RSpec.describe Ci::ArchiveTraceService, '#execute' do
expect { subject }.not_to raise_error
expect(job.reload.job_artifacts_trace).to be_exist
+ expect(job.trace_metadata.trace_artifact).to eq(job.job_artifacts_trace)
end
context 'when trace is already archived' do
@@ -27,7 +28,7 @@ RSpec.describe Ci::ArchiveTraceService, '#execute' do
context 'when live trace chunks still exist' do
before do
- create(:ci_build_trace_chunk, build: job)
+ create(:ci_build_trace_chunk, build: job, chunk_index: 0)
end
it 'removes the trace chunks' do
@@ -39,8 +40,14 @@ RSpec.describe Ci::ArchiveTraceService, '#execute' do
job.job_artifacts_trace.file.remove!
end
- it 'removes the trace artifact' do
- expect { subject }.to change { job.reload.job_artifacts_trace }.to(nil)
+ it 'removes the trace artifact and builds a new one' do
+ existing_trace = job.job_artifacts_trace
+ expect(existing_trace).to receive(:destroy!).and_call_original
+
+ subject
+
+ expect(job.reload.job_artifacts_trace).to be_present
+ expect(job.reload.job_artifacts_trace.file.file).to be_present
end
end
end
@@ -59,6 +66,54 @@ RSpec.describe Ci::ArchiveTraceService, '#execute' do
end
end
+ context 'when the job is out of archival attempts' do
+ before do
+ create(:ci_build_trace_metadata,
+ build: job,
+ archival_attempts: Ci::BuildTraceMetadata::MAX_ATTEMPTS + 1,
+ last_archival_attempt_at: 1.week.ago)
+ end
+
+ it 'skips archiving' do
+ expect(job.trace).not_to receive(:archive!)
+
+ subject
+ end
+
+ it 'leaves a warning message in sidekiq log' do
+ expect(Sidekiq.logger).to receive(:warn).with(
+ class: Ci::ArchiveTraceWorker.name,
+ message: 'The job is out of archival attempts.',
+ job_id: job.id)
+
+ subject
+ end
+ end
+
+ context 'when the archival process is backed off' do
+ before do
+ create(:ci_build_trace_metadata,
+ build: job,
+ archival_attempts: Ci::BuildTraceMetadata::MAX_ATTEMPTS - 1,
+ last_archival_attempt_at: 1.hour.ago)
+ end
+
+ it 'skips archiving' do
+ expect(job.trace).not_to receive(:archive!)
+
+ subject
+ end
+
+ it 'leaves a warning message in sidekiq log' do
+ expect(Sidekiq.logger).to receive(:warn).with(
+ class: Ci::ArchiveTraceWorker.name,
+ message: 'The job can not be archived right now.',
+ job_id: job.id)
+
+ subject
+ end
+ end
+
context 'when job failed to archive trace but did not raise an exception' do
before do
allow_next_instance_of(Gitlab::Ci::Trace) do |instance|
@@ -98,6 +153,7 @@ RSpec.describe Ci::ArchiveTraceService, '#execute' do
.and_call_original
expect { subject }.not_to raise_error
+ expect(job.trace_metadata.archival_attempts).to eq(1)
end
end
end
diff --git a/spec/services/ci/create_pipeline_service/parent_child_pipeline_spec.rb b/spec/services/ci/create_pipeline_service/parent_child_pipeline_spec.rb
index 6eb1315fff4..4326fa5533f 100644
--- a/spec/services/ci/create_pipeline_service/parent_child_pipeline_spec.rb
+++ b/spec/services/ci/create_pipeline_service/parent_child_pipeline_spec.rb
@@ -127,6 +127,32 @@ RSpec.describe Ci::CreatePipelineService, '#execute' do
end
end
end
+
+ context 'when resource group key includes a variable' do
+ let(:config) do
+ <<~YAML
+ instrumentation_test:
+ stage: test
+ resource_group: $CI_ENVIRONMENT_NAME
+ trigger:
+ include: path/to/child.yml
+ strategy: depend
+ YAML
+ end
+
+ it 'ignores the resource group keyword because it fails to expand the variable', :aggregate_failures do
+ pipeline = create_pipeline!
+ Ci::InitialPipelineProcessWorker.new.perform(pipeline.id)
+
+ test = pipeline.statuses.find_by(name: 'instrumentation_test')
+ expect(pipeline).to be_created_successfully
+ expect(pipeline.triggered_pipelines).not_to be_exist
+ expect(project.resource_groups.count).to eq(0)
+ expect(test).to be_a Ci::Bridge
+ expect(test).to be_pending
+ expect(test.resource_group).to be_nil
+ end
+ end
end
end
diff --git a/spec/services/ci/create_pipeline_service/tags_spec.rb b/spec/services/ci/create_pipeline_service/tags_spec.rb
new file mode 100644
index 00000000000..335d35010c8
--- /dev/null
+++ b/spec/services/ci/create_pipeline_service/tags_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Ci::CreatePipelineService do
+ describe 'tags:' do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { project.owner }
+
+ let(:ref) { 'refs/heads/master' }
+ let(:source) { :push }
+ let(:service) { described_class.new(project, user, { ref: ref }) }
+ let(:pipeline) { service.execute(source).payload }
+
+ before do
+ stub_ci_pipeline_yaml_file(config)
+ end
+
+ context 'with valid config' do
+ let(:config) { YAML.dump({ test: { script: 'ls', tags: %w[tag1 tag2] } }) }
+
+ it 'creates a pipeline', :aggregate_failures do
+ expect(pipeline).to be_created_successfully
+ expect(pipeline.builds.first.tag_list).to match_array(%w[tag1 tag2])
+ end
+ end
+
+ context 'with too many tags' do
+ let(:tags) { Array.new(50) {|i| "tag-#{i}" } }
+ let(:config) { YAML.dump({ test: { script: 'ls', tags: tags } }) }
+
+ it 'creates a pipeline without builds', :aggregate_failures do
+ expect(pipeline).not_to be_created_successfully
+ expect(pipeline.builds).to be_empty
+ expect(pipeline.yaml_errors).to eq("jobs:test:tags config must be less than the limit of #{Gitlab::Ci::Config::Entry::Tags::TAGS_LIMIT} tags")
+ end
+ end
+ end
+end
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index 2fdb0ed3c0d..78646665539 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe Ci::CreatePipelineService do
let(:ref_name) { 'refs/heads/master' }
before do
- stub_ci_pipeline_yaml_file(gitlab_ci_yaml)
+ stub_ci_pipeline_to_return_yaml_file
end
describe '#execute' do
@@ -991,6 +991,58 @@ RSpec.describe Ci::CreatePipelineService do
end
end
+ context 'when resource group is defined for review app deployment' do
+ before do
+ config = YAML.dump(
+ review_app: {
+ stage: 'test',
+ script: 'deploy',
+ environment: {
+ name: 'review/$CI_COMMIT_REF_SLUG',
+ on_stop: 'stop_review_app'
+ },
+ resource_group: '$CI_ENVIRONMENT_NAME'
+ },
+ stop_review_app: {
+ stage: 'test',
+ script: 'stop',
+ when: 'manual',
+ environment: {
+ name: 'review/$CI_COMMIT_REF_SLUG',
+ action: 'stop'
+ },
+ resource_group: '$CI_ENVIRONMENT_NAME'
+ }
+ )
+
+ stub_ci_pipeline_yaml_file(config)
+ end
+
+ it 'persists the association correctly' do
+ result = execute_service.payload
+ deploy_job = result.builds.find_by_name!(:review_app)
+ stop_job = result.builds.find_by_name!(:stop_review_app)
+
+ expect(result).to be_persisted
+ expect(deploy_job.resource_group.key).to eq('review/master')
+ expect(stop_job.resource_group.key).to eq('review/master')
+ expect(project.resource_groups.count).to eq(1)
+ end
+
+ it 'initializes scoped variables only once for each build' do
+ # Bypassing `stub_build` hack because it distrubs the expectations below.
+ allow_next_instances_of(Gitlab::Ci::Build::Context::Build, 2) do |build_context|
+ allow(build_context).to receive(:variables) { Gitlab::Ci::Variables::Collection.new }
+ end
+
+ expect_next_instances_of(::Ci::Build, 2) do |ci_build|
+ expect(ci_build).to receive(:scoped_variables).once.and_call_original
+ end
+
+ expect(execute_service.payload).to be_created_successfully
+ end
+ end
+
context 'with timeout' do
context 'when builds with custom timeouts are configured' do
before do
@@ -1248,16 +1300,47 @@ RSpec.describe Ci::CreatePipelineService do
end
context 'when pipeline variables are specified' do
- let(:variables_attributes) do
- [{ key: 'first', secret_value: 'world' },
- { key: 'second', secret_value: 'second_world' }]
+ subject(:pipeline) { execute_service(variables_attributes: variables_attributes).payload }
+
+ context 'with valid pipeline variables' do
+ let(:variables_attributes) do
+ [{ key: 'first', secret_value: 'world' },
+ { key: 'second', secret_value: 'second_world' }]
+ end
+
+ it 'creates a pipeline with specified variables' do
+ expect(pipeline.variables.map { |var| var.slice(:key, :secret_value) })
+ .to eq variables_attributes.map(&:with_indifferent_access)
+ end
end
- subject(:pipeline) { execute_service(variables_attributes: variables_attributes).payload }
+ context 'with duplicate pipeline variables' do
+ let(:variables_attributes) do
+ [{ key: 'hello', secret_value: 'world' },
+ { key: 'hello', secret_value: 'second_world' }]
+ end
- it 'creates a pipeline with specified variables' do
- expect(pipeline.variables.map { |var| var.slice(:key, :secret_value) })
- .to eq variables_attributes.map(&:with_indifferent_access)
+ it 'fails to create the pipeline' do
+ expect(pipeline).to be_failed
+ expect(pipeline.variables).to be_empty
+ expect(pipeline.errors[:base]).to eq(['Duplicate variable name: hello'])
+ end
+ end
+
+ context 'with more than one duplicate pipeline variable' do
+ let(:variables_attributes) do
+ [{ key: 'hello', secret_value: 'world' },
+ { key: 'hello', secret_value: 'second_world' },
+ { key: 'single', secret_value: 'variable' },
+ { key: 'other', secret_value: 'value' },
+ { key: 'other', secret_value: 'other value' }]
+ end
+
+ it 'fails to create the pipeline' do
+ expect(pipeline).to be_failed
+ expect(pipeline.variables).to be_empty
+ expect(pipeline.errors[:base]).to eq(['Duplicate variable names: hello, other'])
+ end
end
end
diff --git a/spec/services/ci/external_pull_requests/create_pipeline_service_spec.rb b/spec/services/ci/external_pull_requests/create_pipeline_service_spec.rb
index 2b310443b37..04d75630295 100644
--- a/spec/services/ci/external_pull_requests/create_pipeline_service_spec.rb
+++ b/spec/services/ci/external_pull_requests/create_pipeline_service_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe Ci::ExternalPullRequests::CreatePipelineService do
project.add_maintainer(user)
end
- subject(:response) { described_class.new(project, user).execute(pull_request) }
+ subject(:execute) { described_class.new(project, user).execute(pull_request) }
context 'when pull request is open' do
before do
@@ -21,26 +21,43 @@ RSpec.describe Ci::ExternalPullRequests::CreatePipelineService do
context 'when source sha is the head of the source branch' do
let(:source_branch) { project.repository.branches.last }
- let(:create_pipeline_service) { instance_double(Ci::CreatePipelineService) }
before do
pull_request.update!(source_branch: source_branch.name, source_sha: source_branch.target)
end
- it 'creates a pipeline for external pull request', :aggregate_failures do
- pipeline = response.payload
-
- expect(response).to be_success
- expect(pipeline).to be_valid
- expect(pipeline).to be_persisted
- expect(pipeline).to be_external_pull_request_event
- expect(pipeline).to eq(project.ci_pipelines.last)
- expect(pipeline.external_pull_request).to eq(pull_request)
- expect(pipeline.user).to eq(user)
- expect(pipeline.status).to eq('created')
- expect(pipeline.ref).to eq(pull_request.source_branch)
- expect(pipeline.sha).to eq(pull_request.source_sha)
- expect(pipeline.source_sha).to eq(pull_request.source_sha)
+ context 'when the FF ci_create_external_pr_pipeline_async is disabled' do
+ before do
+ stub_feature_flags(ci_create_external_pr_pipeline_async: false)
+ end
+
+ it 'creates a pipeline for external pull request', :aggregate_failures do
+ pipeline = execute.payload
+
+ expect(execute).to be_success
+ expect(pipeline).to be_valid
+ expect(pipeline).to be_persisted
+ expect(pipeline).to be_external_pull_request_event
+ expect(pipeline).to eq(project.ci_pipelines.last)
+ expect(pipeline.external_pull_request).to eq(pull_request)
+ expect(pipeline.user).to eq(user)
+ expect(pipeline.status).to eq('created')
+ expect(pipeline.ref).to eq(pull_request.source_branch)
+ expect(pipeline.sha).to eq(pull_request.source_sha)
+ expect(pipeline.source_sha).to eq(pull_request.source_sha)
+ end
+ end
+
+ it 'enqueues Ci::ExternalPullRequests::CreatePipelineWorker' do
+ expect { execute }
+ .to change { ::Ci::ExternalPullRequests::CreatePipelineWorker.jobs.count }
+ .by(1)
+
+ args = ::Ci::ExternalPullRequests::CreatePipelineWorker.jobs.last['args']
+
+ expect(args[0]).to eq(project.id)
+ expect(args[1]).to eq(user.id)
+ expect(args[2]).to eq(pull_request.id)
end
end
@@ -53,11 +70,12 @@ RSpec.describe Ci::ExternalPullRequests::CreatePipelineService do
end
it 'does nothing', :aggregate_failures do
- expect(Ci::CreatePipelineService).not_to receive(:new)
+ expect { execute }
+ .not_to change { ::Ci::ExternalPullRequests::CreatePipelineWorker.jobs.count }
- expect(response).to be_error
- expect(response.message).to eq('The source sha is not the head of the source branch')
- expect(response.payload).to be_nil
+ expect(execute).to be_error
+ expect(execute.message).to eq('The source sha is not the head of the source branch')
+ expect(execute.payload).to be_nil
end
end
end
@@ -68,11 +86,12 @@ RSpec.describe Ci::ExternalPullRequests::CreatePipelineService do
end
it 'does nothing', :aggregate_failures do
- expect(Ci::CreatePipelineService).not_to receive(:new)
+ expect { execute }
+ .not_to change { ::Ci::ExternalPullRequests::CreatePipelineWorker.jobs.count }
- expect(response).to be_error
- expect(response.message).to eq('The pull request is not opened')
- expect(response.payload).to be_nil
+ expect(execute).to be_error
+ expect(execute.message).to eq('The pull request is not opened')
+ expect(execute.payload).to be_nil
end
end
end
diff --git a/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb b/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb
index 04fa55068f2..7a91ad9dcc1 100644
--- a/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb
+++ b/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb
@@ -10,20 +10,16 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s
describe '.execute' do
subject { service.execute }
- let_it_be(:artifact, refind: true) do
- create(:ci_job_artifact, expire_at: 1.day.ago)
- end
-
- before(:all) do
- artifact.job.pipeline.unlocked!
- end
+ let_it_be(:locked_pipeline) { create(:ci_pipeline, :artifacts_locked) }
+ let_it_be(:pipeline) { create(:ci_pipeline, :unlocked) }
+ let_it_be(:locked_job) { create(:ci_build, :success, pipeline: locked_pipeline) }
+ let_it_be(:job) { create(:ci_build, :success, pipeline: pipeline) }
context 'when artifact is expired' do
+ let!(:artifact) { create(:ci_job_artifact, :expired, job: job) }
+
context 'with preloaded relationships' do
before do
- job = create(:ci_build, pipeline: artifact.job.pipeline)
- create(:ci_job_artifact, :archive, :expired, job: job)
-
stub_const("#{described_class}::LOOP_LIMIT", 1)
end
@@ -39,7 +35,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s
# COMMIT
# SELECT next expired ci_job_artifacts
- expect(log.count).to be_within(1).of(11)
+ expect(log.count).to be_within(1).of(10)
end
end
@@ -48,7 +44,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s
expect { subject }.to change { Ci::JobArtifact.count }.by(-1)
end
- context 'when the artifact does not a file attached to it' do
+ context 'when the artifact does not have a file attached to it' do
it 'does not create deleted objects' do
expect(artifact.exists?).to be_falsy # sanity check
@@ -57,10 +53,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s
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
+ let!(:artifact) { create(:ci_job_artifact, :expired, :zip, job: job) }
it 'creates a deleted object' do
expect { subject }.to change { Ci::DeletedObject.count }.by(1)
@@ -81,9 +74,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s
end
context 'when artifact is locked' do
- before do
- artifact.job.pipeline.artifacts_locked!
- end
+ let!(:artifact) { create(:ci_job_artifact, :expired, job: locked_job) }
it 'does not destroy job artifact' do
expect { subject }.not_to change { Ci::JobArtifact.count }
@@ -92,9 +83,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s
end
context 'when artifact is not expired' do
- before do
- artifact.update_column(:expire_at, 1.day.since)
- end
+ let!(:artifact) { create(:ci_job_artifact, job: job) }
it 'does not destroy expired job artifacts' do
expect { subject }.not_to change { Ci::JobArtifact.count }
@@ -102,9 +91,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s
end
context 'when artifact is permanent' do
- before do
- artifact.update_column(:expire_at, nil)
- end
+ let!(:artifact) { create(:ci_job_artifact, expire_at: nil, job: job) }
it 'does not destroy expired job artifacts' do
expect { subject }.not_to change { Ci::JobArtifact.count }
@@ -112,6 +99,8 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s
end
context 'when failed to destroy artifact' do
+ let!(:artifact) { create(:ci_job_artifact, :expired, job: job) }
+
before do
stub_const("#{described_class}::LOOP_LIMIT", 10)
end
@@ -146,58 +135,67 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s
end
context 'when exclusive lease has already been taken by the other instance' do
+ let!(:artifact) { create(:ci_job_artifact, :expired, job: job) }
+
before do
stub_exclusive_lease_taken(described_class::EXCLUSIVE_LOCK_KEY, timeout: described_class::LOCK_TIMEOUT)
end
it 'raises an error and does not start destroying' do
expect { subject }.to raise_error(Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError)
+ .and not_change { Ci::JobArtifact.count }.from(1)
end
end
- context 'when timeout happens' do
- let!(:second_artifact) { create(:ci_job_artifact, expire_at: 1.day.ago) }
+ context 'with a second artifact and batch size of 1' do
+ let(:second_job) { create(:ci_build, :success, pipeline: pipeline) }
+ let!(:second_artifact) { create(:ci_job_artifact, :archive, expire_at: 1.day.ago, job: second_job) }
+ let!(:artifact) { create(:ci_job_artifact, :expired, job: job) }
before do
- stub_const("#{described_class}::LOOP_TIMEOUT", 0.seconds)
stub_const("#{described_class}::BATCH_SIZE", 1)
-
- second_artifact.job.pipeline.unlocked!
end
- it 'destroys one artifact' do
- expect { subject }.to change { Ci::JobArtifact.count }.by(-1)
- end
-
- it 'reports the number of destroyed artifacts' do
- is_expected.to eq(1)
- end
- end
+ context 'when timeout happens' do
+ before do
+ stub_const("#{described_class}::LOOP_TIMEOUT", 0.seconds)
+ end
- context 'when loop reached loop limit' do
- before do
- stub_const("#{described_class}::LOOP_LIMIT", 1)
- stub_const("#{described_class}::BATCH_SIZE", 1)
+ it 'destroys one artifact' do
+ expect { subject }.to change { Ci::JobArtifact.count }.by(-1)
+ end
- second_artifact.job.pipeline.unlocked!
+ it 'reports the number of destroyed artifacts' do
+ is_expected.to eq(1)
+ end
end
- let!(:second_artifact) { create(:ci_job_artifact, expire_at: 1.day.ago) }
+ context 'when loop reached loop limit' do
+ before do
+ stub_const("#{described_class}::LOOP_LIMIT", 1)
+ end
- it 'destroys one artifact' do
- expect { subject }.to change { Ci::JobArtifact.count }.by(-1)
+ it 'destroys one artifact' do
+ expect { subject }.to change { Ci::JobArtifact.count }.by(-1)
+ end
+
+ it 'reports the number of destroyed artifacts' do
+ is_expected.to eq(1)
+ end
end
- it 'reports the number of destroyed artifacts' do
- is_expected.to eq(1)
+ context 'when the number of artifacts is greater than than batch size' do
+ it 'destroys all expired artifacts' do
+ expect { subject }.to change { Ci::JobArtifact.count }.by(-2)
+ end
+
+ it 'reports the number of destroyed artifacts' do
+ is_expected.to eq(2)
+ end
end
end
context 'when there are no artifacts' do
- before do
- artifact.destroy!
- end
-
it 'does not raise error' do
expect { subject }.not_to raise_error
end
@@ -207,42 +205,18 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s
end
end
- context 'when there are artifacts more than batch sizes' do
- before do
- stub_const("#{described_class}::BATCH_SIZE", 1)
-
- second_artifact.job.pipeline.unlocked!
- end
-
- let!(:second_artifact) { create(:ci_job_artifact, expire_at: 1.day.ago) }
-
- it 'destroys all expired artifacts' do
- expect { subject }.to change { Ci::JobArtifact.count }.by(-2)
- end
-
- it 'reports the number of destroyed artifacts' do
- is_expected.to eq(2)
- end
- end
-
context 'when some artifacts are locked' do
- before do
- pipeline = create(:ci_pipeline, locked: :artifacts_locked)
- job = create(:ci_build, pipeline: pipeline)
- create(:ci_job_artifact, expire_at: 1.day.ago, job: job)
- end
+ let!(:artifact) { create(:ci_job_artifact, :expired, job: job) }
+ let!(:locked_artifact) { create(:ci_job_artifact, :expired, job: locked_job) }
it 'destroys only unlocked artifacts' do
expect { subject }.to change { Ci::JobArtifact.count }.by(-1)
+ expect(locked_artifact).to be_persisted
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
+ let!(:artifact) { create(:ci_job_artifact, :expired, job: locked_job) }
it 'destroys no artifacts' do
expect { subject }.to change { Ci::JobArtifact.count }.by(0)
diff --git a/spec/services/ci/pipeline_processing/shared_processing_service.rb b/spec/services/ci/pipeline_processing/shared_processing_service.rb
index a4bc8e68b2d..8de9b308429 100644
--- a/spec/services/ci/pipeline_processing/shared_processing_service.rb
+++ b/spec/services/ci/pipeline_processing/shared_processing_service.rb
@@ -908,6 +908,39 @@ RSpec.shared_examples 'Pipeline Processing Service' do
end
end
+ context 'when a bridge job has invalid downstream project', :sidekiq_inline do
+ let(:config) do
+ <<-EOY
+ test:
+ stage: test
+ script: echo test
+
+ deploy:
+ stage: deploy
+ trigger:
+ project: invalid-project
+ EOY
+ end
+
+ let(:pipeline) do
+ Ci::CreatePipelineService.new(project, user, { ref: 'master' }).execute(:push).payload
+ end
+
+ before do
+ stub_ci_pipeline_yaml_file(config)
+ end
+
+ it 'creates a pipeline, then fails the bridge job' do
+ expect(all_builds_names).to contain_exactly('test', 'deploy')
+ expect(all_builds_statuses).to contain_exactly('pending', 'created')
+
+ succeed_pending
+
+ expect(all_builds_names).to contain_exactly('test', 'deploy')
+ expect(all_builds_statuses).to contain_exactly('success', 'failed')
+ end
+ end
+
private
def all_builds
diff --git a/spec/services/ci/pipeline_trigger_service_spec.rb b/spec/services/ci/pipeline_trigger_service_spec.rb
index 2f93b1ecd3c..29d12b0dd0e 100644
--- a/spec/services/ci/pipeline_trigger_service_spec.rb
+++ b/spec/services/ci/pipeline_trigger_service_spec.rb
@@ -103,6 +103,17 @@ RSpec.describe Ci::PipelineTriggerService do
end
end
+ context 'when params have duplicate variables' do
+ let(:params) { { token: trigger.token, ref: 'master', variables: variables } }
+ let(:variables) { { 'TRIGGER_PAYLOAD' => 'duplicate value' } }
+
+ it 'creates a failed pipeline without variables' do
+ expect { result }.to change { Ci::Pipeline.count }
+ expect(result).to be_error
+ expect(result.message[:base]).to eq(['Duplicate variable name: TRIGGER_PAYLOAD'])
+ end
+ end
+
it_behaves_like 'detecting an unprocessable pipeline trigger'
end
@@ -201,6 +212,17 @@ RSpec.describe Ci::PipelineTriggerService do
end
end
+ context 'when params have duplicate variables' do
+ let(:params) { { token: job.token, ref: 'master', variables: variables } }
+ let(:variables) { { 'TRIGGER_PAYLOAD' => 'duplicate value' } }
+
+ it 'creates a failed pipeline without variables' do
+ expect { result }.to change { Ci::Pipeline.count }
+ expect(result).to be_error
+ expect(result.message[:base]).to eq(['Duplicate variable name: TRIGGER_PAYLOAD'])
+ end
+ end
+
it_behaves_like 'detecting an unprocessable pipeline trigger'
end
diff --git a/spec/services/ci/pipelines/add_job_service_spec.rb b/spec/services/ci/pipelines/add_job_service_spec.rb
index bdf7e577fa8..3a77d26dd9e 100644
--- a/spec/services/ci/pipelines/add_job_service_spec.rb
+++ b/spec/services/ci/pipelines/add_job_service_spec.rb
@@ -59,18 +59,6 @@ RSpec.describe Ci::Pipelines::AddJobService do
end
end
- context 'when the FF ci_fix_commit_status_retried is disabled' do
- before do
- stub_feature_flags(ci_fix_commit_status_retried: false)
- end
-
- it 'does not call update_older_statuses_retried!' do
- expect(job).not_to receive(:update_older_statuses_retried!)
-
- execute
- end
- end
-
context 'exclusive lock' do
let(:lock_uuid) { 'test' }
let(:lock_key) { "ci:pipelines:#{pipeline.id}:add-job" }
diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb
index 2f37d0ea42d..73ff15ec393 100644
--- a/spec/services/ci/register_job_service_spec.rb
+++ b/spec/services/ci/register_job_service_spec.rb
@@ -40,12 +40,16 @@ module Ci
context 'runner follow tag list' do
it "picks build with the same tag" do
pending_job.update!(tag_list: ["linux"])
+ pending_job.reload
+ pending_job.create_queuing_entry!
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"])
+ pending_job.reload
+ pending_job.create_queuing_entry!
specific_runner.update!(tag_list: ["win32"])
expect(execute(specific_runner)).to be_falsey
end
@@ -56,6 +60,8 @@ module Ci
it "does not pick build with tag" do
pending_job.update!(tag_list: ["linux"])
+ pending_job.reload
+ pending_job.create_queuing_entry!
expect(execute(specific_runner)).to be_falsey
end
@@ -81,8 +87,30 @@ module Ci
end
context 'for specific runner' do
- it 'does not pick a build' do
- expect(execute(specific_runner)).to be_nil
+ context 'with FF disabled' do
+ before do
+ stub_feature_flags(
+ ci_pending_builds_project_runners_decoupling: false,
+ ci_queueing_builds_enabled_checks: false)
+ end
+
+ it 'does not pick a build' do
+ expect(execute(specific_runner)).to be_nil
+ end
+ end
+
+ context 'with FF enabled' do
+ before do
+ stub_feature_flags(
+ ci_pending_builds_project_runners_decoupling: true,
+ ci_queueing_builds_enabled_checks: true)
+ end
+
+ it 'does not pick a build' do
+ expect(execute(specific_runner)).to be_nil
+ expect(pending_job.reload).to be_failed
+ expect(pending_job.queuing_entry).to be_nil
+ end
end
end
end
@@ -219,6 +247,8 @@ module Ci
before do
project.update!(shared_runners_enabled: true, group_runners_enabled: true)
project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED)
+
+ pending_job.reload.create_queuing_entry!
end
context 'and uses shared runner' do
@@ -236,7 +266,29 @@ module Ci
context 'and uses project runner' do
let(:build) { execute(specific_runner) }
- it { expect(build).to be_nil }
+ context 'with FF disabled' do
+ before do
+ stub_feature_flags(
+ ci_pending_builds_project_runners_decoupling: false,
+ ci_queueing_builds_enabled_checks: false)
+ end
+
+ it { expect(build).to be_nil }
+ end
+
+ context 'with FF enabled' do
+ before do
+ stub_feature_flags(
+ ci_pending_builds_project_runners_decoupling: true,
+ ci_queueing_builds_enabled_checks: true)
+ end
+
+ it 'does not pick a build' do
+ expect(build).to be_nil
+ expect(pending_job.reload).to be_failed
+ expect(pending_job.queuing_entry).to be_nil
+ end
+ end
end
end
@@ -304,6 +356,8 @@ module Ci
context 'disallow group runners' do
before do
project.update!(group_runners_enabled: false)
+
+ pending_job.reload.create_queuing_entry!
end
context 'group runner' do
@@ -739,6 +793,30 @@ module Ci
include_examples 'handles runner assignment'
end
+
+ context 'with ci_queueing_denormalize_tags_information enabled' do
+ before do
+ stub_feature_flags(ci_queueing_denormalize_tags_information: true)
+ end
+
+ include_examples 'handles runner assignment'
+ end
+
+ context 'with ci_queueing_denormalize_tags_information disabled' do
+ before do
+ stub_feature_flags(ci_queueing_denormalize_tags_information: false)
+ end
+
+ include_examples 'handles runner assignment'
+ end
+
+ context 'with ci_queueing_denormalize_namespace_traversal_ids disabled' do
+ before do
+ stub_feature_flags(ci_queueing_denormalize_namespace_traversal_ids: false)
+ end
+
+ include_examples 'handles runner assignment'
+ end
end
context 'when not using pending builds table' do
diff --git a/spec/services/ci/stuck_builds/drop_service_spec.rb b/spec/services/ci/stuck_builds/drop_service_spec.rb
new file mode 100644
index 00000000000..8dfd1bc1b3d
--- /dev/null
+++ b/spec/services/ci/stuck_builds/drop_service_spec.rb
@@ -0,0 +1,284 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::StuckBuilds::DropService do
+ let!(:runner) { create :ci_runner }
+ let!(:job) { create :ci_build, runner: runner }
+ let(:created_at) { }
+ let(:updated_at) { }
+
+ subject(:service) { described_class.new }
+
+ before do
+ job_attributes = { status: status }
+ job_attributes[:created_at] = created_at if created_at
+ job_attributes[:updated_at] = updated_at if updated_at
+ job.update!(job_attributes)
+ end
+
+ shared_examples 'job is dropped' do
+ it 'changes status' do
+ expect(service).to receive(:drop).exactly(3).times.and_call_original
+ expect(service).to receive(:drop_stuck).exactly(:once).and_call_original
+
+ service.execute
+ job.reload
+
+ expect(job).to be_failed
+ expect(job).to be_stuck_or_timeout_failure
+ end
+
+ context 'when job have data integrity problem' do
+ it "does drop the job and logs the reason" do
+ job.update_columns(yaml_variables: '[{"key" => "value"}]')
+
+ expect(Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(anything, a_hash_including(build_id: job.id))
+ .once
+ .and_call_original
+
+ service.execute
+ job.reload
+
+ expect(job).to be_failed
+ expect(job).to be_data_integrity_failure
+ end
+ end
+ end
+
+ shared_examples 'job is unchanged' do
+ it 'does not change status' do
+ expect(service).to receive(:drop).exactly(3).times.and_call_original
+ expect(service).to receive(:drop_stuck).exactly(:once).and_call_original
+
+ service.execute
+ job.reload
+
+ expect(job.status).to eq(status)
+ end
+ end
+
+ context 'when job is pending' do
+ let(:status) { 'pending' }
+
+ context 'when job is not stuck' do
+ before do
+ allow_next_found_instance_of(Ci::Build) do |build|
+ allow(build).to receive(:stuck?).and_return(false)
+ end
+ end
+
+ context 'when job was updated_at more than 1 day ago' do
+ let(:updated_at) { 1.5.days.ago }
+
+ context 'when created_at is the same as updated_at' do
+ let(:created_at) { 1.5.days.ago }
+
+ it_behaves_like 'job is dropped'
+ end
+
+ context 'when created_at is before updated_at' do
+ let(:created_at) { 3.days.ago }
+
+ it_behaves_like 'job is dropped'
+ end
+
+ context 'when created_at is outside lookback window' do
+ let(:created_at) { described_class::BUILD_LOOKBACK - 1.day }
+
+ it_behaves_like 'job is unchanged'
+ end
+ end
+
+ context 'when job was updated less than 1 day ago' do
+ let(:updated_at) { 6.hours.ago }
+
+ context 'when created_at is the same as updated_at' do
+ let(:created_at) { 1.5.days.ago }
+
+ it_behaves_like 'job is unchanged'
+ end
+
+ context 'when created_at is before updated_at' do
+ let(:created_at) { 3.days.ago }
+
+ it_behaves_like 'job is unchanged'
+ end
+
+ context 'when created_at is outside lookback window' do
+ let(:created_at) { described_class::BUILD_LOOKBACK - 1.day }
+
+ it_behaves_like 'job is unchanged'
+ end
+ end
+
+ context 'when job was updated more than 1 hour ago' do
+ let(:updated_at) { 2.hours.ago }
+
+ context 'when created_at is the same as updated_at' do
+ let(:created_at) { 2.hours.ago }
+
+ it_behaves_like 'job is unchanged'
+ end
+
+ context 'when created_at is before updated_at' do
+ let(:created_at) { 3.days.ago }
+
+ it_behaves_like 'job is unchanged'
+ end
+
+ context 'when created_at is outside lookback window' do
+ let(:created_at) { described_class::BUILD_LOOKBACK - 1.day }
+
+ it_behaves_like 'job is unchanged'
+ end
+ end
+ end
+
+ context 'when job is stuck' do
+ before do
+ allow_next_found_instance_of(Ci::Build) do |build|
+ allow(build).to receive(:stuck?).and_return(true)
+ end
+ end
+
+ context 'when job was updated_at more than 1 hour ago' do
+ let(:updated_at) { 1.5.hours.ago }
+
+ context 'when created_at is the same as updated_at' do
+ let(:created_at) { 1.5.hours.ago }
+
+ it_behaves_like 'job is dropped'
+ end
+
+ context 'when created_at is before updated_at' do
+ let(:created_at) { 3.days.ago }
+
+ it_behaves_like 'job is dropped'
+ end
+
+ context 'when created_at is outside lookback window' do
+ let(:created_at) { described_class::BUILD_LOOKBACK - 1.day }
+
+ it_behaves_like 'job is unchanged'
+ end
+ end
+
+ context 'when job was updated in less than 1 hour ago' do
+ let(:updated_at) { 30.minutes.ago }
+
+ context 'when created_at is the same as updated_at' do
+ let(:created_at) { 30.minutes.ago }
+
+ it_behaves_like 'job is unchanged'
+ end
+
+ context 'when created_at is before updated_at' do
+ let(:created_at) { 2.days.ago }
+
+ it_behaves_like 'job is unchanged'
+ end
+
+ context 'when created_at is outside lookback window' do
+ let(:created_at) { described_class::BUILD_LOOKBACK - 1.day }
+
+ it_behaves_like 'job is unchanged'
+ end
+ end
+ end
+ end
+
+ context 'when job is running' do
+ let(:status) { 'running' }
+
+ context 'when job was updated_at more than an hour ago' do
+ let(:updated_at) { 2.hours.ago }
+
+ it_behaves_like 'job is dropped'
+ end
+
+ context 'when job was updated in less than 1 hour ago' do
+ let(:updated_at) { 30.minutes.ago }
+
+ it_behaves_like 'job is unchanged'
+ end
+ end
+
+ %w(success skipped failed canceled).each do |status|
+ context "when job is #{status}" do
+ let(:status) { status }
+ let(:updated_at) { 2.days.ago }
+
+ context 'when created_at is the same as updated_at' do
+ let(:created_at) { 2.days.ago }
+
+ it_behaves_like 'job is unchanged'
+ end
+
+ context 'when created_at is before updated_at' do
+ let(:created_at) { 3.days.ago }
+
+ it_behaves_like 'job is unchanged'
+ end
+
+ context 'when created_at is outside lookback window' do
+ let(:created_at) { described_class::BUILD_LOOKBACK - 1.day }
+
+ it_behaves_like 'job is unchanged'
+ end
+ end
+ end
+
+ context 'for deleted project' do
+ let(:status) { 'running' }
+ let(:updated_at) { 2.days.ago }
+
+ before do
+ job.project.update!(pending_delete: true)
+ end
+
+ it_behaves_like 'job is dropped'
+ end
+
+ describe 'drop stale scheduled builds' do
+ let(:status) { 'scheduled' }
+ let(:updated_at) { }
+
+ context 'when scheduled at 2 hours ago but it is not executed yet' do
+ let!(:job) { create(:ci_build, :scheduled, scheduled_at: 2.hours.ago) }
+
+ it 'drops the stale scheduled build' do
+ expect(Ci::Build.scheduled.count).to eq(1)
+ expect(job).to be_scheduled
+
+ service.execute
+ job.reload
+
+ expect(Ci::Build.scheduled.count).to eq(0)
+ expect(job).to be_failed
+ expect(job).to be_stale_schedule
+ end
+ end
+
+ context 'when scheduled at 30 minutes ago but it is not executed yet' do
+ let!(:job) { create(:ci_build, :scheduled, scheduled_at: 30.minutes.ago) }
+
+ it 'does not drop the stale scheduled build yet' do
+ expect(Ci::Build.scheduled.count).to eq(1)
+ expect(job).to be_scheduled
+
+ service.execute
+
+ expect(Ci::Build.scheduled.count).to eq(1)
+ expect(job).to be_scheduled
+ end
+ end
+
+ context 'when there are no stale scheduled builds' do
+ it 'does not drop the stale scheduled build yet' do
+ expect { service.execute }.not_to raise_error
+ end
+ end
+ end
+end
diff --git a/spec/services/ci/update_pending_build_service_spec.rb b/spec/services/ci/update_pending_build_service_spec.rb
new file mode 100644
index 00000000000..d842042de40
--- /dev/null
+++ b/spec/services/ci/update_pending_build_service_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::UpdatePendingBuildService do
+ describe '#execute' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, namespace: group) }
+ let_it_be(:pending_build_1) { create(:ci_pending_build, project: project, instance_runners_enabled: false) }
+ let_it_be(:pending_build_2) { create(:ci_pending_build, project: project, instance_runners_enabled: true) }
+ let_it_be(:update_params) { { instance_runners_enabled: true } }
+
+ subject(:service) { described_class.new(model, update_params).execute }
+
+ context 'validations' do
+ context 'when model is invalid' do
+ let(:model) { pending_build_1 }
+
+ it 'raises an error' do
+ expect { service }.to raise_error(described_class::InvalidModelError)
+ end
+ end
+
+ context 'when params is invalid' do
+ let(:model) { group }
+ let(:update_params) { { minutes_exceeded: true } }
+
+ it 'raises an error' do
+ expect { service }.to raise_error(described_class::InvalidParamsError)
+ end
+ end
+ end
+
+ context 'when model is a group with pending builds' do
+ let(:model) { group }
+
+ it 'updates all pending builds', :aggregate_failures do
+ service
+
+ expect(pending_build_1.reload.instance_runners_enabled).to be_truthy
+ expect(pending_build_2.reload.instance_runners_enabled).to be_truthy
+ end
+
+ context 'when ci_pending_builds_maintain_shared_runners_data is disabled' do
+ before do
+ stub_feature_flags(ci_pending_builds_maintain_shared_runners_data: false)
+ end
+
+ it 'does not update all pending builds', :aggregate_failures do
+ service
+
+ expect(pending_build_1.reload.instance_runners_enabled).to be_falsey
+ expect(pending_build_2.reload.instance_runners_enabled).to be_truthy
+ end
+ end
+ end
+
+ context 'when model is a project with pending builds' do
+ let(:model) { project }
+
+ it 'updates all pending builds', :aggregate_failures do
+ service
+
+ expect(pending_build_1.reload.instance_runners_enabled).to be_truthy
+ expect(pending_build_2.reload.instance_runners_enabled).to be_truthy
+ end
+
+ context 'when ci_pending_builds_maintain_shared_runners_data is disabled' do
+ before do
+ stub_feature_flags(ci_pending_builds_maintain_shared_runners_data: false)
+ end
+
+ it 'does not update all pending builds', :aggregate_failures do
+ service
+
+ expect(pending_build_1.reload.instance_runners_enabled).to be_falsey
+ expect(pending_build_2.reload.instance_runners_enabled).to be_truthy
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/clusters/agents/refresh_authorization_service_spec.rb b/spec/services/clusters/agents/refresh_authorization_service_spec.rb
new file mode 100644
index 00000000000..77ba81ea9c0
--- /dev/null
+++ b/spec/services/clusters/agents/refresh_authorization_service_spec.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::RefreshAuthorizationService do
+ describe '#execute' do
+ let_it_be(:root_ancestor) { create(:group) }
+
+ let_it_be(:removed_group) { create(:group, parent: root_ancestor) }
+ let_it_be(:modified_group) { create(:group, parent: root_ancestor) }
+ let_it_be(:added_group) { create(:group, parent: root_ancestor) }
+
+ let_it_be(:removed_project) { create(:project, namespace: root_ancestor) }
+ let_it_be(:modified_project) { create(:project, namespace: root_ancestor) }
+ let_it_be(:added_project) { create(:project, namespace: root_ancestor) }
+
+ let(:project) { create(:project, namespace: root_ancestor) }
+ let(:agent) { create(:cluster_agent, project: project) }
+
+ let(:config) do
+ {
+ ci_access: {
+ groups: [
+ { id: added_group.full_path, default_namespace: 'default' },
+ { id: modified_group.full_path, default_namespace: 'new-namespace' }
+ ],
+ projects: [
+ { id: added_project.full_path, default_namespace: 'default' },
+ { id: modified_project.full_path, default_namespace: 'new-namespace' }
+ ]
+ }
+ }.deep_stringify_keys
+ end
+
+ subject { described_class.new(agent, config: config).execute }
+
+ before do
+ default_config = { default_namespace: 'default' }
+
+ agent.group_authorizations.create!(group: removed_group, config: default_config)
+ agent.group_authorizations.create!(group: modified_group, config: default_config)
+
+ agent.project_authorizations.create!(project: removed_project, config: default_config)
+ agent.project_authorizations.create!(project: modified_project, config: default_config)
+ end
+
+ shared_examples 'removing authorization' do
+ context 'config contains no groups' do
+ let(:config) { {} }
+
+ it 'removes all authorizations' do
+ expect(subject).to be_truthy
+ expect(authorizations).to be_empty
+ end
+ end
+
+ context 'config contains groups outside of the configuration project hierarchy' do
+ let(:project) { create(:project, namespace: create(:group)) }
+
+ it 'removes all authorizations' do
+ expect(subject).to be_truthy
+ expect(authorizations).to be_empty
+ end
+ end
+
+ context 'configuration project does not belong to a group' do
+ let(:project) { create(:project) }
+
+ it 'removes all authorizations' do
+ expect(subject).to be_truthy
+ expect(authorizations).to be_empty
+ end
+ end
+ end
+
+ describe 'group authorization' do
+ it 'refreshes authorizations for the agent' do
+ expect(subject).to be_truthy
+ expect(agent.authorized_groups).to contain_exactly(added_group, modified_group)
+
+ added_authorization = agent.group_authorizations.find_by(group: added_group)
+ expect(added_authorization.config).to eq({ 'default_namespace' => 'default' })
+
+ modified_authorization = agent.group_authorizations.find_by(group: modified_group)
+ expect(modified_authorization.config).to eq({ 'default_namespace' => 'new-namespace' })
+ end
+
+ context 'config contains too many groups' do
+ before do
+ stub_const("#{described_class}::AUTHORIZED_ENTITY_LIMIT", 1)
+ end
+
+ it 'authorizes groups up to the limit' do
+ expect(subject).to be_truthy
+ expect(agent.authorized_groups).to contain_exactly(added_group)
+ end
+ end
+
+ include_examples 'removing authorization' do
+ let(:authorizations) { agent.authorized_groups }
+ end
+ end
+
+ describe 'project authorization' do
+ it 'refreshes authorizations for the agent' do
+ expect(subject).to be_truthy
+ expect(agent.authorized_projects).to contain_exactly(added_project, modified_project)
+
+ added_authorization = agent.project_authorizations.find_by(project: added_project)
+ expect(added_authorization.config).to eq({ 'default_namespace' => 'default' })
+
+ modified_authorization = agent.project_authorizations.find_by(project: modified_project)
+ expect(modified_authorization.config).to eq({ 'default_namespace' => 'new-namespace' })
+ end
+
+ context 'config contains too many projects' do
+ before do
+ stub_const("#{described_class}::AUTHORIZED_ENTITY_LIMIT", 1)
+ end
+
+ it 'authorizes projects up to the limit' do
+ expect(subject).to be_truthy
+ expect(agent.authorized_projects).to contain_exactly(added_project)
+ end
+ end
+
+ include_examples 'removing authorization' do
+ let(:authorizations) { agent.authorized_projects }
+ end
+ end
+ end
+end
diff --git a/spec/services/customer_relations/organizations/create_service_spec.rb b/spec/services/customer_relations/organizations/create_service_spec.rb
new file mode 100644
index 00000000000..b4764f6b97a
--- /dev/null
+++ b/spec/services/customer_relations/organizations/create_service_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe CustomerRelations::Organizations::CreateService do
+ describe '#execute' do
+ let_it_be(:user) { create(:user) }
+
+ let(:group) { create(:group) }
+ let(:params) { attributes_for(:organization, group: group) }
+
+ subject(:response) { described_class.new(group: group, current_user: user, params: params).execute }
+
+ it 'creates an organization' do
+ group.add_reporter(user)
+
+ expect(response).to be_success
+ end
+
+ it 'returns an error when user does not have permission' do
+ expect(response).to be_error
+ expect(response.message).to eq('You have insufficient permissions to create an organization for this group')
+ end
+
+ it 'returns an error when the organization is not persisted' do
+ group.add_reporter(user)
+ params[:name] = nil
+
+ expect(response).to be_error
+ expect(response.message).to eq(["Name can't be blank"])
+ end
+ end
+end
diff --git a/spec/services/customer_relations/organizations/update_service_spec.rb b/spec/services/customer_relations/organizations/update_service_spec.rb
new file mode 100644
index 00000000000..eb253540863
--- /dev/null
+++ b/spec/services/customer_relations/organizations/update_service_spec.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe CustomerRelations::Organizations::UpdateService do
+ let_it_be(:user) { create(:user) }
+
+ let(:organization) { create(:organization, name: 'Test', group: group) }
+
+ subject(:update) { described_class.new(group: group, current_user: user, params: params).execute(organization) }
+
+ describe '#execute' do
+ context 'when the user has no permission' do
+ let_it_be(:group) { create(:group) }
+
+ let(:params) { { name: 'GitLab' } }
+
+ it 'returns an error' do
+ response = update
+
+ expect(response).to be_error
+ expect(response.message).to eq('You have insufficient permissions to update an organization for this group')
+ end
+ end
+
+ context 'when user has permission' do
+ let_it_be(:group) { create(:group) }
+
+ before_all do
+ group.add_reporter(user)
+ end
+
+ context 'when name is changed' do
+ let(:params) { { name: 'GitLab' } }
+
+ it 'updates the organization' do
+ response = update
+
+ expect(response).to be_success
+ expect(response.payload.name).to eq('GitLab')
+ end
+ end
+
+ context 'when the organization is invalid' do
+ let(:params) { { name: nil } }
+
+ it 'returns an error' do
+ response = update
+
+ expect(response).to be_error
+ expect(response.message).to eq(["Name can't be blank"])
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/dependency_proxy/image_ttl_group_policies/update_service_spec.rb b/spec/services/dependency_proxy/image_ttl_group_policies/update_service_spec.rb
new file mode 100644
index 00000000000..ceac8985c8e
--- /dev/null
+++ b/spec/services/dependency_proxy/image_ttl_group_policies/update_service_spec.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::DependencyProxy::ImageTtlGroupPolicies::UpdateService do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be_with_reload(:group) { create(:group) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:params) { {} }
+
+ describe '#execute' do
+ subject { described_class.new(container: group, current_user: user, params: params).execute }
+
+ shared_examples 'returning a success' do
+ it 'returns a success' do
+ result = subject
+
+ expect(result.payload[:dependency_proxy_image_ttl_policy]).to be_present
+ expect(result).to be_success
+ end
+ end
+
+ shared_examples 'returning an error' do |message, http_status|
+ it 'returns an error' do
+ result = subject
+
+ expect(result).to have_attributes(
+ message: message,
+ status: :error,
+ http_status: http_status
+ )
+ end
+ end
+
+ shared_examples 'updating the dependency proxy image ttl policy' do
+ it_behaves_like 'updating the dependency proxy image ttl policy attributes',
+ from: { enabled: true, ttl: 90 },
+ to: { enabled: false, ttl: 2 }
+
+ it_behaves_like 'returning a success'
+
+ context 'with invalid params' do
+ let_it_be(:params) { { enabled: nil } }
+
+ it_behaves_like 'not creating the dependency proxy image ttl policy'
+
+ it "doesn't update" do
+ expect { subject }
+ .not_to change { ttl_policy.reload.enabled }
+ end
+
+ it_behaves_like 'returning an error', 'Enabled is not included in the list', 400
+ end
+ end
+
+ shared_examples 'denying access to dependency proxy image ttl policy' do
+ context 'with existing dependency proxy image ttl policy' do
+ it_behaves_like 'not creating the dependency proxy image ttl policy'
+
+ it_behaves_like 'returning an error', 'Access Denied', 403
+ end
+ end
+
+ before do
+ stub_config(dependency_proxy: { enabled: true })
+ end
+
+ context 'with existing dependency proxy image ttl policy' do
+ let_it_be(:ttl_policy) { create(:image_ttl_group_policy, group: group) }
+ let_it_be(:params) { { enabled: false, ttl: 2 } }
+
+ where(:user_role, :shared_examples_name) do
+ :maintainer | 'updating the dependency proxy image ttl policy'
+ :developer | 'updating the dependency proxy image ttl policy'
+ :reporter | 'denying access to dependency proxy image ttl policy'
+ :guest | 'denying access to dependency proxy image ttl policy'
+ :anonymous | 'denying access to dependency proxy image ttl policy'
+ end
+
+ with_them do
+ before do
+ group.send("add_#{user_role}", user) unless user_role == :anonymous
+ end
+
+ it_behaves_like params[:shared_examples_name]
+ end
+ end
+
+ context 'without existing dependency proxy image ttl policy' do
+ let_it_be(:ttl_policy) { group.dependency_proxy_image_ttl_policy }
+
+ where(:user_role, :shared_examples_name) do
+ :maintainer | 'creating the dependency proxy image ttl policy'
+ :developer | 'creating the dependency proxy image ttl policy'
+ :reporter | 'denying access to dependency proxy image ttl policy'
+ :guest | 'denying access to dependency proxy image ttl policy'
+ :anonymous | 'denying access to dependency proxy image ttl policy'
+ end
+
+ with_them do
+ before do
+ group.send("add_#{user_role}", user) unless user_role == :anonymous
+ end
+
+ it_behaves_like params[:shared_examples_name]
+ end
+
+ context 'when the policy is not found' do
+ before do
+ group.add_developer(user)
+ expect(group).to receive(:dependency_proxy_image_ttl_policy).and_return nil
+ end
+
+ it_behaves_like 'returning an error', 'Dependency proxy image TTL Policy not found', 404
+ end
+ end
+ end
+end
diff --git a/spec/services/design_management/delete_designs_service_spec.rb b/spec/services/design_management/delete_designs_service_spec.rb
index 341f71fa62c..bc7625d7c28 100644
--- a/spec/services/design_management/delete_designs_service_spec.rb
+++ b/spec/services/design_management/delete_designs_service_spec.rb
@@ -149,6 +149,12 @@ RSpec.describe DesignManagement::DeleteDesignsService do
expect { run_service }
.to change { designs.first.deleted? }.from(false).to(true)
end
+
+ it 'schedules deleting todos for that design' do
+ expect(TodosDestroyer::DestroyedDesignsWorker).to receive(:perform_async).with([designs.first.id])
+
+ run_service
+ end
end
context 'more than one design is passed' do
@@ -168,6 +174,12 @@ RSpec.describe DesignManagement::DeleteDesignsService do
.and change { Event.destroyed_action.for_design.count }.by(2)
end
+ it 'schedules deleting todos for that design' do
+ expect(TodosDestroyer::DestroyedDesignsWorker).to receive(:perform_async).with(designs.map(&:id))
+
+ run_service
+ end
+
it_behaves_like "a success"
context 'after executing the service' do
diff --git a/spec/services/draft_notes/publish_service_spec.rb b/spec/services/draft_notes/publish_service_spec.rb
index 4f761454516..51ef30c91c0 100644
--- a/spec/services/draft_notes/publish_service_spec.rb
+++ b/spec/services/draft_notes/publish_service_spec.rb
@@ -33,7 +33,8 @@ RSpec.describe DraftNotes::PublishService do
end
it 'does not skip notification', :sidekiq_might_not_need_inline do
- expect(Notes::CreateService).to receive(:new).with(project, user, drafts.first.publish_params).and_call_original
+ note_params = drafts.first.publish_params.merge(skip_keep_around_commits: false)
+ expect(Notes::CreateService).to receive(:new).with(project, user, note_params).and_call_original
expect_next_instance_of(NotificationService) do |notification_service|
expect(notification_service).to receive(:new_note)
end
@@ -127,12 +128,17 @@ RSpec.describe DraftNotes::PublishService do
publish
end
- context 'capturing diff notes positions' do
+ context 'capturing diff notes positions and keeping around commits' do
before do
# Need to execute this to ensure that we'll be able to test creation of
# DiffNotePosition records as that only happens when the `MergeRequest#merge_ref_head`
# is present. This service creates that for the specified merge request.
MergeRequests::MergeToRefService.new(project: project, current_user: user).execute(merge_request)
+
+ # Need to re-stub this and call original as we are stubbing
+ # `Gitlab::Git::KeepAround#execute` in spec_helper for performance reason.
+ # Enabling it here so we can test the Gitaly calls it makes.
+ allow(Gitlab::Git::KeepAround).to receive(:execute).and_call_original
end
it 'creates diff_note_positions for diff notes' do
@@ -143,11 +149,26 @@ RSpec.describe DraftNotes::PublishService do
expect(notes.last.diff_note_positions).to be_any
end
+ it 'keeps around the commits of each published note' do
+ publish
+
+ repository = project.repository
+ notes = merge_request.notes.order(id: :asc)
+
+ notes.first.shas.each do |sha|
+ expect(repository.ref_exists?("refs/keep-around/#{sha}")).to be_truthy
+ end
+
+ notes.last.shas.each do |sha|
+ expect(repository.ref_exists?("refs/keep-around/#{sha}")).to be_truthy
+ end
+ end
+
it 'does not requests a lot from Gitaly', :request_store do
# NOTE: This should be reduced as we work on reducing Gitaly calls.
# Gitaly requests shouldn't go above this threshold as much as possible
# as it may add more to the Gitaly N+1 issue we are experiencing.
- expect { publish }.to change { Gitlab::GitalyClient.get_request_count }.by(11)
+ expect { publish }.to change { Gitlab::GitalyClient.get_request_count }.by(21)
end
end
diff --git a/spec/services/environments/auto_stop_service_spec.rb b/spec/services/environments/auto_stop_service_spec.rb
index 93b1596586f..8dad59cbefd 100644
--- a/spec/services/environments/auto_stop_service_spec.rb
+++ b/spec/services/environments/auto_stop_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Environments::AutoStopService, :clean_gitlab_redis_shared_state do
+RSpec.describe Environments::AutoStopService, :clean_gitlab_redis_shared_state, :sidekiq_inline do
include CreateEnvironmentsHelpers
include ExclusiveLeaseHelpers
@@ -42,6 +42,15 @@ RSpec.describe Environments::AutoStopService, :clean_gitlab_redis_shared_state d
expect(Ci::Build.where(name: 'stop_review_app').map(&:status).uniq).to eq(['pending'])
end
+ it 'schedules stop processes in bulk' do
+ args = [[Environment.find_by_name('review/feature-1').id], [Environment.find_by_name('review/feature-2').id]]
+
+ expect(Environments::AutoStopWorker)
+ .to receive(:bulk_perform_async).with(args).once.and_call_original
+
+ subject
+ end
+
context 'when the other sidekiq worker has already been running' do
before do
stub_exclusive_lease_taken(described_class::EXCLUSIVE_LOCK_KEY)
diff --git a/spec/services/environments/stop_service_spec.rb b/spec/services/environments/stop_service_spec.rb
index 52be512612d..acc9869002f 100644
--- a/spec/services/environments/stop_service_spec.rb
+++ b/spec/services/environments/stop_service_spec.rb
@@ -237,60 +237,6 @@ RSpec.describe Environments::StopService do
end
end
- describe '.execute_in_batch' do
- subject { described_class.execute_in_batch(environments) }
-
- let_it_be(:project) { create(:project, :repository) }
- let_it_be(:user) { create(:user) }
-
- let(:environments) { Environment.available }
-
- before_all do
- project.add_developer(user)
- project.repository.add_branch(user, 'review/feature-1', 'master')
- project.repository.add_branch(user, 'review/feature-2', 'master')
- end
-
- before do
- create_review_app(user, project, 'review/feature-1')
- create_review_app(user, project, 'review/feature-2')
- end
-
- it 'stops environments' do
- expect { subject }
- .to change { project.environments.all.map(&:state).uniq }
- .from(['available']).to(['stopped'])
-
- expect(project.environments.all.map(&:auto_stop_at).uniq).to eq([nil])
- end
-
- it 'plays stop actions' do
- expect { subject }
- .to change { Ci::Build.where(name: 'stop_review_app').map(&:status).uniq }
- .from(['manual']).to(['pending'])
- end
-
- context 'when user does not have a permission to play the stop action' do
- before do
- project.team.truncate
- end
-
- it 'tracks the exception' do
- expect(Gitlab::ErrorTracking)
- .to receive(:track_exception)
- .with(Gitlab::Access::AccessDeniedError, anything)
- .twice
- .and_call_original
-
- subject
- end
-
- after do
- project.add_developer(user)
- end
- end
- end
-
def expect_environment_stopped_on(branch)
expect { service.execute_for_branch(branch) }
.to change { Environment.last.state }.from('available').to('stopped')
diff --git a/spec/services/error_tracking/collect_error_service_spec.rb b/spec/services/error_tracking/collect_error_service_spec.rb
index 14cd588f40b..ee9d0813e64 100644
--- a/spec/services/error_tracking/collect_error_service_spec.rb
+++ b/spec/services/error_tracking/collect_error_service_spec.rb
@@ -34,11 +34,35 @@ RSpec.describe ErrorTracking::CollectErrorService do
expect(error.platform).to eq 'ruby'
expect(error.last_seen_at).to eq '2021-07-08T12:59:16Z'
- expect(event.description).to eq 'ActionView::MissingTemplate'
+ expect(event.description).to start_with 'Missing template posts/error2'
expect(event.occurred_at).to eq '2021-07-08T12:59:16Z'
expect(event.level).to eq 'error'
expect(event.environment).to eq 'development'
expect(event.payload).to eq parsed_event
end
+
+ context 'unusual payload' do
+ let(:modified_event) { parsed_event }
+
+ context 'missing transaction' do
+ it 'builds actor from stacktrace' do
+ modified_event.delete('transaction')
+
+ event = described_class.new(project, nil, event: modified_event).execute
+
+ expect(event.error.actor).to eq 'find()'
+ end
+ end
+
+ context 'timestamp is numeric' do
+ it 'parses timestamp' do
+ modified_event['timestamp'] = '1631015580.50'
+
+ event = described_class.new(project, nil, event: modified_event).execute
+
+ expect(event.occurred_at).to eq '2021-09-07T11:53:00.5'
+ end
+ end
+ end
end
end
diff --git a/spec/services/feature_flags/create_service_spec.rb b/spec/services/feature_flags/create_service_spec.rb
index 4eb2b25fb64..5a517ce6a64 100644
--- a/spec/services/feature_flags/create_service_spec.rb
+++ b/spec/services/feature_flags/create_service_spec.rb
@@ -48,8 +48,9 @@ RSpec.describe FeatureFlags::CreateService do
{
name: 'feature_flag',
description: 'description',
- scopes_attributes: [{ environment_scope: '*', active: true },
- { environment_scope: 'production', active: false }]
+ version: 'new_version_flag',
+ strategies_attributes: [{ name: 'default', scopes_attributes: [{ environment_scope: '*' }], parameters: {} },
+ { name: 'default', parameters: {}, scopes_attributes: [{ environment_scope: 'production' }] }]
}
end
@@ -68,15 +69,10 @@ RSpec.describe FeatureFlags::CreateService do
end
it 'creates audit event' do
- expected_message = 'Created feature flag feature_flag '\
- 'with description "description". '\
- 'Created rule * and set it as active '\
- 'with strategies [{"name"=&gt;"default", "parameters"=&gt;{}}]. '\
- 'Created rule production and set it as inactive '\
- 'with strategies [{"name"=&gt;"default", "parameters"=&gt;{}}].'
-
expect { subject }.to change { AuditEvent.count }.by(1)
- expect(AuditEvent.last.details[:custom_message]).to eq(expected_message)
+ expect(AuditEvent.last.details[:custom_message]).to start_with('Created feature flag feature_flag with description "description".')
+ expect(AuditEvent.last.details[:custom_message]).to include('Created strategy "default" with scopes "*".')
+ expect(AuditEvent.last.details[:custom_message]).to include('Created strategy "default" with scopes "production".')
end
context 'when user is reporter' do
diff --git a/spec/services/git/base_hooks_service_spec.rb b/spec/services/git/base_hooks_service_spec.rb
index 539c294a2e7..a8d753ff124 100644
--- a/spec/services/git/base_hooks_service_spec.rb
+++ b/spec/services/git/base_hooks_service_spec.rb
@@ -19,9 +19,13 @@ RSpec.describe Git::BaseHooksService do
:push_hooks
end
- def commits
+ def limited_commits
[]
end
+
+ def commits_count
+ 0
+ end
end
end
diff --git a/spec/services/git/branch_hooks_service_spec.rb b/spec/services/git/branch_hooks_service_spec.rb
index a93f594b360..79c2cb1fca3 100644
--- a/spec/services/git/branch_hooks_service_spec.rb
+++ b/spec/services/git/branch_hooks_service_spec.rb
@@ -362,6 +362,9 @@ RSpec.describe Git::BranchHooksService, :clean_gitlab_redis_shared_state do
end
end
+ let(:commits_count) { service.send(:commits_count) }
+ let(:threshold_limit) { described_class::PROCESS_COMMIT_LIMIT + 1 }
+
let(:oldrev) { project.commit(commit_ids.first).parent_id }
let(:newrev) { commit_ids.last }
@@ -373,17 +376,31 @@ RSpec.describe Git::BranchHooksService, :clean_gitlab_redis_shared_state do
let(:oldrev) { Gitlab::Git::BLANK_SHA }
it 'processes a limited number of commit messages' do
+ expect(project.repository)
+ .to receive(:commits)
+ .with(newrev, limit: threshold_limit)
+ .and_call_original
+
expect(ProcessCommitWorker).to receive(:perform_async).twice
service.execute
+
+ expect(commits_count).to eq(project.repository.commit_count_for_ref(newrev))
end
end
context 'updating the default branch' do
it 'processes a limited number of commit messages' do
+ expect(project.repository)
+ .to receive(:commits_between)
+ .with(oldrev, newrev, limit: threshold_limit)
+ .and_call_original
+
expect(ProcessCommitWorker).to receive(:perform_async).twice
service.execute
+
+ expect(commits_count).to eq(project.repository.count_commits_between(oldrev, newrev))
end
end
@@ -391,9 +408,13 @@ RSpec.describe Git::BranchHooksService, :clean_gitlab_redis_shared_state do
let(:newrev) { Gitlab::Git::BLANK_SHA }
it 'does not process commit messages' do
+ expect(project.repository).not_to receive(:commits)
+ expect(project.repository).not_to receive(:commits_between)
expect(ProcessCommitWorker).not_to receive(:perform_async)
service.execute
+
+ expect(commits_count).to eq(0)
end
end
@@ -402,9 +423,16 @@ RSpec.describe Git::BranchHooksService, :clean_gitlab_redis_shared_state do
let(:oldrev) { Gitlab::Git::BLANK_SHA }
it 'processes a limited number of commit messages' do
+ expect(project.repository)
+ .to receive(:commits_between)
+ .with(project.default_branch, newrev, limit: threshold_limit)
+ .and_call_original
+
expect(ProcessCommitWorker).to receive(:perform_async).twice
service.execute
+
+ expect(commits_count).to eq(project.repository.count_commits_between(project.default_branch, branch))
end
end
@@ -412,9 +440,15 @@ RSpec.describe Git::BranchHooksService, :clean_gitlab_redis_shared_state do
let(:branch) { 'fix' }
it 'processes a limited number of commit messages' do
+ expect(project.repository)
+ .to receive(:commits_between)
+ .with(oldrev, newrev, limit: threshold_limit)
+ .and_call_original
+
expect(ProcessCommitWorker).to receive(:perform_async).twice
service.execute
+ expect(commits_count).to eq(project.repository.count_commits_between(oldrev, newrev))
end
end
@@ -423,9 +457,13 @@ RSpec.describe Git::BranchHooksService, :clean_gitlab_redis_shared_state do
let(:newrev) { Gitlab::Git::BLANK_SHA }
it 'does not process commit messages' do
+ expect(project.repository).not_to receive(:commits)
+ expect(project.repository).not_to receive(:commits_between)
expect(ProcessCommitWorker).not_to receive(:perform_async)
service.execute
+
+ expect(commits_count).to eq(0)
end
end
diff --git a/spec/services/groups/group_links/create_service_spec.rb b/spec/services/groups/group_links/create_service_spec.rb
index b1bb9a8de23..03dac14be54 100644
--- a/spec/services/groups/group_links/create_service_spec.rb
+++ b/spec/services/groups/group_links/create_service_spec.rb
@@ -6,18 +6,20 @@ RSpec.describe Groups::GroupLinks::CreateService, '#execute' do
let(:parent_group_user) { create(:user) }
let(:group_user) { create(:user) }
let(:child_group_user) { create(:user) }
+ let(:prevent_sharing) { false }
let_it_be(:group_parent) { create(:group, :private) }
let_it_be(:group) { create(:group, :private, parent: group_parent) }
let_it_be(:group_child) { create(:group, :private, parent: group) }
- let_it_be(:shared_group_parent, refind: true) { create(:group, :private) }
- let_it_be(:shared_group, refind: true) { create(:group, :private, parent: shared_group_parent) }
- let_it_be(:shared_group_child) { create(:group, :private, parent: shared_group) }
+ let(:ns_for_parent) { create(:namespace_settings, prevent_sharing_groups_outside_hierarchy: prevent_sharing) }
+ let(:shared_group_parent) { create(:group, :private, namespace_settings: ns_for_parent) }
+ let(:shared_group) { create(:group, :private, parent: shared_group_parent) }
+ let(:shared_group_child) { create(:group, :private, parent: shared_group) }
- let_it_be(:project_parent) { create(:project, group: shared_group_parent) }
- let_it_be(:project) { create(:project, group: shared_group) }
- let_it_be(:project_child) { create(:project, group: shared_group_child) }
+ let(:project_parent) { create(:project, group: shared_group_parent) }
+ let(:project) { create(:project, group: shared_group) }
+ let(:project_child) { create(:project, group: shared_group_child) }
let(:opts) do
{
@@ -129,9 +131,7 @@ RSpec.describe Groups::GroupLinks::CreateService, '#execute' do
end
context 'sharing outside the hierarchy is disabled' do
- before do
- shared_group_parent.namespace_settings.update!(prevent_sharing_groups_outside_hierarchy: true)
- end
+ let(:prevent_sharing) { true }
it 'prevents sharing with a group outside the hierarchy' do
result = subject.execute
diff --git a/spec/services/groups/open_issues_count_service_spec.rb b/spec/services/groups/open_issues_count_service_spec.rb
index fca09bfdebe..7dd8c2a59a0 100644
--- a/spec/services/groups/open_issues_count_service_spec.rb
+++ b/spec/services/groups/open_issues_count_service_spec.rb
@@ -3,12 +3,18 @@
require 'spec_helper'
RSpec.describe Groups::OpenIssuesCountService, :use_clean_rails_memory_store_caching do
- let_it_be(:group) { create(:group, :public)}
+ let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, namespace: group) }
+ let_it_be(:admin) { create(:user, :admin) }
let_it_be(:user) { create(:user) }
- let_it_be(:issue) { create(:issue, :opened, project: project) }
- let_it_be(:confidential) { create(:issue, :opened, confidential: true, project: project) }
- let_it_be(:closed) { create(:issue, :closed, project: project) }
+ let_it_be(:banned_user) { create(:user, :banned) }
+
+ before do
+ create(:issue, :opened, project: project)
+ create(:issue, :opened, confidential: true, project: project)
+ create(:issue, :opened, author: banned_user, project: project)
+ create(:issue, :closed, project: project)
+ end
subject { described_class.new(group, user) }
@@ -20,28 +26,42 @@ RSpec.describe Groups::OpenIssuesCountService, :use_clean_rails_memory_store_cac
it 'uses the IssuesFinder to scope issues' do
expect(IssuesFinder)
.to receive(:new)
- .with(user, group_id: group.id, state: 'opened', non_archived: true, include_subgroups: true, public_only: true)
+ .with(user, group_id: group.id, state: 'opened', non_archived: true, include_subgroups: true, public_only: true, include_hidden: false)
subject.count
end
end
describe '#count' do
- context 'when user is nil' do
- it 'does not include confidential issues in the issue count' do
- expect(described_class.new(group).count).to eq(1)
+ shared_examples 'counts public issues, does not count hidden or confidential' do
+ it 'counts only public issues' do
+ expect(subject.count).to eq(1)
+ end
+
+ it 'uses PUBLIC_COUNT_WITHOUT_HIDDEN_KEY cache key' do
+ expect(subject.cache_key).to include('group_open_public_issues_without_hidden_count')
end
end
+ context 'when user is nil' do
+ let(:user) { nil }
+
+ it_behaves_like 'counts public issues, does not count hidden or confidential'
+ end
+
context 'when user is provided' do
context 'when user can read confidential issues' do
before do
group.add_reporter(user)
end
- it 'returns the right count with confidential issues' do
+ it 'includes confidential issues and does not include hidden issues in count' do
expect(subject.count).to eq(2)
end
+
+ it 'uses TOTAL_COUNT_WITHOUT_HIDDEN_KEY cache key' do
+ expect(subject.cache_key).to include('group_open_issues_without_hidden_count')
+ end
end
context 'when user cannot read confidential issues' do
@@ -49,8 +69,24 @@ RSpec.describe Groups::OpenIssuesCountService, :use_clean_rails_memory_store_cac
group.add_guest(user)
end
- it 'does not include confidential issues' do
- expect(subject.count).to eq(1)
+ it_behaves_like 'counts public issues, does not count hidden or confidential'
+ end
+
+ context 'when user is an admin' do
+ let(:user) { admin }
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'includes confidential and hidden issues in count' do
+ expect(subject.count).to eq(3)
+ end
+
+ it 'uses TOTAL_COUNT_KEY cache key' do
+ expect(subject.cache_key).to include('group_open_issues_including_hidden_count')
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it_behaves_like 'counts public issues, does not count hidden or confidential'
end
end
@@ -61,11 +97,13 @@ RSpec.describe Groups::OpenIssuesCountService, :use_clean_rails_memory_store_cac
describe '#clear_all_cache_keys' do
it 'calls `Rails.cache.delete` with the correct keys' do
expect(Rails.cache).to receive(:delete)
- .with(['groups', 'open_issues_count_service', 1, group.id, described_class::PUBLIC_COUNT_KEY])
+ .with(['groups', 'open_issues_count_service', 1, group.id, described_class::PUBLIC_COUNT_WITHOUT_HIDDEN_KEY])
expect(Rails.cache).to receive(:delete)
.with(['groups', 'open_issues_count_service', 1, group.id, described_class::TOTAL_COUNT_KEY])
+ expect(Rails.cache).to receive(:delete)
+ .with(['groups', 'open_issues_count_service', 1, group.id, described_class::TOTAL_COUNT_WITHOUT_HIDDEN_KEY])
- subject.clear_all_cache_keys
+ described_class.new(group).clear_all_cache_keys
end
end
end
diff --git a/spec/services/groups/update_shared_runners_service_spec.rb b/spec/services/groups/update_shared_runners_service_spec.rb
index e941958eb8c..fe18277b5cd 100644
--- a/spec/services/groups/update_shared_runners_service_spec.rb
+++ b/spec/services/groups/update_shared_runners_service_spec.rb
@@ -55,6 +55,31 @@ RSpec.describe Groups::UpdateSharedRunnersService do
expect(subject[:status]).to eq(:success)
end
end
+
+ context 'when group has pending builds' do
+ let_it_be(:group) { create(:group, :shared_runners_disabled) }
+ let_it_be(:project) { create(:project, namespace: group, shared_runners_enabled: false) }
+ let_it_be(:pending_build_1) { create(:ci_pending_build, project: project, instance_runners_enabled: false) }
+ let_it_be(:pending_build_2) { create(:ci_pending_build, project: project, instance_runners_enabled: false) }
+
+ it 'updates pending builds for the group' do
+ subject
+
+ expect(pending_build_1.reload.instance_runners_enabled).to be_truthy
+ expect(pending_build_2.reload.instance_runners_enabled).to be_truthy
+ end
+
+ context 'when shared runners is not toggled' do
+ let(:params) { { shared_runners_setting: 'invalid_enabled' } }
+
+ it 'does not update pending builds for the group' do
+ subject
+
+ expect(pending_build_1.reload.instance_runners_enabled).to be_falsey
+ expect(pending_build_2.reload.instance_runners_enabled).to be_falsey
+ end
+ end
+ end
end
context 'disable shared Runners' do
@@ -67,6 +92,19 @@ RSpec.describe Groups::UpdateSharedRunnersService do
expect(subject[:status]).to eq(:success)
end
+
+ context 'when group has pending builds' do
+ let_it_be(:project) { create(:project, namespace: group) }
+ let_it_be(:pending_build_1) { create(:ci_pending_build, project: project, instance_runners_enabled: true) }
+ let_it_be(:pending_build_2) { create(:ci_pending_build, project: project, instance_runners_enabled: true) }
+
+ it 'updates pending builds for the group' do
+ subject
+
+ expect(pending_build_1.reload.instance_runners_enabled).to be_falsey
+ expect(pending_build_2.reload.instance_runners_enabled).to be_falsey
+ end
+ end
end
context 'allow descendants to override' do
diff --git a/spec/services/issue_rebalancing_service_spec.rb b/spec/services/issue_rebalancing_service_spec.rb
deleted file mode 100644
index 76ccb6d89ea..00000000000
--- a/spec/services/issue_rebalancing_service_spec.rb
+++ /dev/null
@@ -1,173 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe IssueRebalancingService do
- let_it_be(:project, reload: true) { create(:project) }
- let_it_be(:user) { project.creator }
- let_it_be(:start) { RelativePositioning::START_POSITION }
- let_it_be(:max_pos) { RelativePositioning::MAX_POSITION }
- let_it_be(:min_pos) { RelativePositioning::MIN_POSITION }
- let_it_be(:clump_size) { 300 }
-
- let_it_be(:unclumped, reload: true) do
- (1..clump_size).to_a.map do |i|
- create(:issue, project: project, author: user, relative_position: start + (1024 * i))
- end
- end
-
- let_it_be(:end_clump, reload: true) do
- (1..clump_size).to_a.map do |i|
- create(:issue, project: project, author: user, relative_position: max_pos - i)
- end
- end
-
- let_it_be(:start_clump, reload: true) do
- (1..clump_size).to_a.map do |i|
- create(:issue, project: project, author: user, relative_position: min_pos + i)
- end
- end
-
- before do
- stub_feature_flags(issue_rebalancing_with_retry: false)
- end
-
- def issues_in_position_order
- project.reload.issues.reorder(relative_position: :asc).to_a
- end
-
- shared_examples 'IssueRebalancingService shared examples' do
- it 'rebalances a set of issues with clumps at the end and start' do
- all_issues = start_clump + unclumped + end_clump.reverse
- service = described_class.new(Project.id_in([project.id]))
-
- expect { service.execute }.not_to change { issues_in_position_order.map(&:id) }
-
- all_issues.each(&:reset)
-
- gaps = all_issues.take(all_issues.count - 1).zip(all_issues.drop(1)).map do |a, b|
- b.relative_position - a.relative_position
- end
-
- expect(gaps).to all(be > RelativePositioning::MIN_GAP)
- expect(all_issues.first.relative_position).to be > (RelativePositioning::MIN_POSITION * 0.9999)
- expect(all_issues.last.relative_position).to be < (RelativePositioning::MAX_POSITION * 0.9999)
- end
-
- it 'is idempotent' do
- service = described_class.new(Project.id_in(project))
-
- expect do
- service.execute
- service.execute
- end.not_to change { issues_in_position_order.map(&:id) }
- end
-
- it 'does nothing if the feature flag is disabled' do
- stub_feature_flags(rebalance_issues: false)
- issue = project.issues.first
- issue.project
- issue.project.group
- old_pos = issue.relative_position
-
- service = described_class.new(Project.id_in(project))
-
- expect { service.execute }.not_to exceed_query_limit(0)
- expect(old_pos).to eq(issue.reload.relative_position)
- end
-
- it 'acts if the flag is enabled for the root namespace' do
- issue = create(:issue, project: project, author: user, relative_position: max_pos)
- stub_feature_flags(rebalance_issues: project.root_namespace)
-
- service = described_class.new(Project.id_in(project))
-
- expect { service.execute }.to change { issue.reload.relative_position }
- end
-
- it 'acts if the flag is enabled for the group' do
- issue = create(:issue, project: project, author: user, relative_position: max_pos)
- project.update!(group: create(:group))
- stub_feature_flags(rebalance_issues: issue.project.group)
-
- service = described_class.new(Project.id_in(project))
-
- expect { service.execute }.to change { issue.reload.relative_position }
- end
-
- it 'aborts if there are too many issues' do
- base = double(count: 10_001)
-
- allow(Issue).to receive(:in_projects).and_return(base)
-
- expect { described_class.new(Project.id_in(project)).execute }.to raise_error(described_class::TooManyIssues)
- end
- end
-
- shared_examples 'rebalancing is retried on statement timeout exceptions' do
- subject { described_class.new(Project.id_in(project)) }
-
- it 'retries update statement' do
- call_count = 0
- allow(subject).to receive(:run_update_query) do
- call_count += 1
- if call_count < 13
- raise(ActiveRecord::QueryCanceled)
- else
- call_count = 0 if call_count == 13 + 16 # 16 = 17 sub-batches - 1 call that succeeded as part of 5th batch
- true
- end
- end
-
- # call math:
- # batches start at 100 and are split in half after every 3 retries if ActiveRecord::StatementTimeout exception is raised.
- # We raise ActiveRecord::StatementTimeout exception for 13 calls:
- # 1. 100 => 3 calls
- # 2. 100/2=50 => 3 calls + 3 above = 6 calls, raise ActiveRecord::StatementTimeout
- # 3. 50/2=25 => 3 calls + 6 above = 9 calls, raise ActiveRecord::StatementTimeout
- # 4. 25/2=12 => 3 calls + 9 above = 12 calls, raise ActiveRecord::StatementTimeout
- # 5. 12/2=6 => 1 call + 12 above = 13 calls, run successfully
- #
- # so out of 100 elements we created batches of 6 items => 100/6 = 17 sub-batches of 6 or less elements
- #
- # project.issues.count: 900 issues, so 9 batches of 100 => 9 * (13+16) = 261
- expect(subject).to receive(:update_positions).exactly(261).times.and_call_original
-
- subject.execute
- end
- end
-
- context 'when issue_rebalancing_optimization feature flag is on' do
- before do
- stub_feature_flags(issue_rebalancing_optimization: true)
- end
-
- it_behaves_like 'IssueRebalancingService shared examples'
-
- context 'when issue_rebalancing_with_retry feature flag is on' do
- before do
- stub_feature_flags(issue_rebalancing_with_retry: true)
- end
-
- it_behaves_like 'IssueRebalancingService shared examples'
- it_behaves_like 'rebalancing is retried on statement timeout exceptions'
- end
- end
-
- context 'when issue_rebalancing_optimization feature flag is off' do
- before do
- stub_feature_flags(issue_rebalancing_optimization: false)
- end
-
- it_behaves_like 'IssueRebalancingService shared examples'
-
- context 'when issue_rebalancing_with_retry feature flag is on' do
- before do
- stub_feature_flags(issue_rebalancing_with_retry: true)
- end
-
- it_behaves_like 'IssueRebalancingService shared examples'
- it_behaves_like 'rebalancing is retried on statement timeout exceptions'
- end
- end
-end
diff --git a/spec/services/issues/build_service_spec.rb b/spec/services/issues/build_service_spec.rb
index 3f506ec58b0..b96dd981e0f 100644
--- a/spec/services/issues/build_service_spec.rb
+++ b/spec/services/issues/build_service_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Issues::BuildService do
+ using RSpec::Parameterized::TableSyntax
+
let_it_be(:project) { create(:project, :repository) }
let_it_be(:developer) { create(:user) }
let_it_be(:guest) { create(:user) }
@@ -144,6 +146,8 @@ RSpec.describe Issues::BuildService do
issue = build_issue(milestone_id: milestone.id)
expect(issue.milestone).to eq(milestone)
+ expect(issue.issue_type).to eq('issue')
+ expect(issue.work_item_type.base_type).to eq('issue')
end
it 'sets milestone to nil if it is not available for the project' do
@@ -152,6 +156,15 @@ RSpec.describe Issues::BuildService do
expect(issue.milestone).to be_nil
end
+
+ context 'when issue_type is incident' do
+ it 'sets the correct issue type' do
+ issue = build_issue(issue_type: 'incident')
+
+ expect(issue.issue_type).to eq('incident')
+ expect(issue.work_item_type.base_type).to eq('incident')
+ end
+ end
end
context 'as guest' do
@@ -165,28 +178,37 @@ RSpec.describe Issues::BuildService do
end
context 'setting issue type' do
- it 'defaults to issue if issue_type not given' do
- issue = build_issue
+ shared_examples 'builds an issue' do
+ specify do
+ issue = build_issue(issue_type: issue_type)
- expect(issue).to be_issue
+ expect(issue.issue_type).to eq(resulting_issue_type)
+ expect(issue.work_item_type_id).to eq(work_item_type_id)
+ end
end
- it 'sets issue' do
- issue = build_issue(issue_type: 'issue')
+ it 'cannot set invalid issue type' do
+ issue = build_issue(issue_type: 'project')
expect(issue).to be_issue
end
- it 'sets incident' do
- issue = build_issue(issue_type: 'incident')
-
- expect(issue).to be_incident
- end
-
- it 'cannot set invalid type' do
- issue = build_issue(issue_type: 'invalid type')
-
- expect(issue).to be_issue
+ context 'with a corresponding WorkItem::Type' do
+ let_it_be(:type_issue_id) { WorkItem::Type.default_issue_type.id }
+ let_it_be(:type_incident_id) { WorkItem::Type.default_by_type(:incident).id }
+
+ where(:issue_type, :work_item_type_id, :resulting_issue_type) do
+ nil | ref(:type_issue_id) | 'issue'
+ 'issue' | ref(:type_issue_id) | 'issue'
+ 'incident' | ref(:type_incident_id) | 'incident'
+ 'test_case' | ref(:type_issue_id) | 'issue' # update once support for test_case is enabled
+ 'requirement' | ref(:type_issue_id) | 'issue' # update once support for requirement is enabled
+ 'invalid' | ref(:type_issue_id) | 'issue'
+ end
+
+ with_them do
+ it_behaves_like 'builds an issue'
+ end
end
end
end
diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb
index b1d4877e138..14e6b44f7b0 100644
--- a/spec/services/issues/close_service_spec.rb
+++ b/spec/services/issues/close_service_spec.rb
@@ -58,8 +58,11 @@ RSpec.describe Issues::CloseService do
end
it 'refreshes the number of open issues', :use_clean_rails_memory_store_caching do
- expect { service.execute(issue) }
- .to change { project.open_issues_count }.from(1).to(0)
+ expect do
+ service.execute(issue)
+
+ BatchLoader::Executor.clear_current
+ end.to change { project.open_issues_count }.from(1).to(0)
end
it 'invalidates counter cache for assignees' do
@@ -222,7 +225,7 @@ RSpec.describe Issues::CloseService do
it 'verifies the number of queries' do
recorded = ActiveRecord::QueryRecorder.new { close_issue }
- expected_queries = 25
+ expected_queries = 27
expect(recorded.count).to be <= expected_queries
expect(recorded.cached_count).to eq(0)
diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb
index 0e2b3b957a5..3988069d83a 100644
--- a/spec/services/issues/create_service_spec.rb
+++ b/spec/services/issues/create_service_spec.rb
@@ -43,10 +43,11 @@ RSpec.describe Issues::CreateService do
expect(issue).to be_persisted
expect(issue.title).to eq('Awesome issue')
- expect(issue.assignees).to eq [assignee]
- expect(issue.labels).to match_array labels
- expect(issue.milestone).to eq milestone
- expect(issue.due_date).to eq Date.tomorrow
+ expect(issue.assignees).to eq([assignee])
+ expect(issue.labels).to match_array(labels)
+ expect(issue.milestone).to eq(milestone)
+ expect(issue.due_date).to eq(Date.tomorrow)
+ expect(issue.work_item_type.base_type).to eq('issue')
end
context 'when skip_system_notes is true' do
@@ -91,7 +92,11 @@ RSpec.describe Issues::CreateService do
end
it 'refreshes the number of open issues', :use_clean_rails_memory_store_caching do
- expect { issue }.to change { project.open_issues_count }.from(0).to(1)
+ expect do
+ issue
+
+ BatchLoader::Executor.clear_current
+ end.to change { project.open_issues_count }.from(0).to(1)
end
context 'when current user cannot set issue metadata in the project' do
diff --git a/spec/services/issues/relative_position_rebalancing_service_spec.rb b/spec/services/issues/relative_position_rebalancing_service_spec.rb
new file mode 100644
index 00000000000..d5d81770817
--- /dev/null
+++ b/spec/services/issues/relative_position_rebalancing_service_spec.rb
@@ -0,0 +1,166 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Issues::RelativePositionRebalancingService, :clean_gitlab_redis_shared_state do
+ let_it_be(:project, reload: true) { create(:project) }
+ let_it_be(:user) { project.creator }
+ let_it_be(:start) { RelativePositioning::START_POSITION }
+ let_it_be(:max_pos) { RelativePositioning::MAX_POSITION }
+ let_it_be(:min_pos) { RelativePositioning::MIN_POSITION }
+ let_it_be(:clump_size) { 300 }
+
+ let_it_be(:unclumped, reload: true) do
+ (1..clump_size).to_a.map do |i|
+ create(:issue, project: project, author: user, relative_position: start + (1024 * i))
+ end
+ end
+
+ let_it_be(:end_clump, reload: true) do
+ (1..clump_size).to_a.map do |i|
+ create(:issue, project: project, author: user, relative_position: max_pos - i)
+ end
+ end
+
+ let_it_be(:start_clump, reload: true) do
+ (1..clump_size).to_a.map do |i|
+ create(:issue, project: project, author: user, relative_position: min_pos + i)
+ end
+ end
+
+ before do
+ stub_feature_flags(issue_rebalancing_with_retry: false)
+ end
+
+ def issues_in_position_order
+ project.reload.issues.reorder(relative_position: :asc).to_a
+ end
+
+ subject(:service) { described_class.new(Project.id_in(project)) }
+
+ context 'execute' do
+ it 're-balances a set of issues with clumps at the end and start' do
+ all_issues = start_clump + unclumped + end_clump.reverse
+
+ expect { service.execute }.not_to change { issues_in_position_order.map(&:id) }
+
+ all_issues.each(&:reset)
+
+ gaps = all_issues.take(all_issues.count - 1).zip(all_issues.drop(1)).map do |a, b|
+ b.relative_position - a.relative_position
+ end
+
+ expect(gaps).to all(be > RelativePositioning::MIN_GAP)
+ expect(all_issues.first.relative_position).to be > (RelativePositioning::MIN_POSITION * 0.9999)
+ expect(all_issues.last.relative_position).to be < (RelativePositioning::MAX_POSITION * 0.9999)
+ expect(project.root_namespace.issue_repositioning_disabled?).to be false
+ end
+
+ it 'is idempotent' do
+ expect do
+ service.execute
+ service.execute
+ end.not_to change { issues_in_position_order.map(&:id) }
+ end
+
+ it 'does nothing if the feature flag is disabled' do
+ stub_feature_flags(rebalance_issues: false)
+ issue = project.issues.first
+ issue.project
+ issue.project.group
+ old_pos = issue.relative_position
+
+ # fetching root namespace in the initializer triggers 2 queries:
+ # for fetching a random project from collection and fetching the root namespace.
+ expect { service.execute }.not_to exceed_query_limit(2)
+ expect(old_pos).to eq(issue.reload.relative_position)
+ end
+
+ it 'acts if the flag is enabled for the root namespace' do
+ issue = create(:issue, project: project, author: user, relative_position: max_pos)
+ stub_feature_flags(rebalance_issues: project.root_namespace)
+
+ expect { service.execute }.to change { issue.reload.relative_position }
+ end
+
+ it 'acts if the flag is enabled for the group' do
+ issue = create(:issue, project: project, author: user, relative_position: max_pos)
+ project.update!(group: create(:group))
+ stub_feature_flags(rebalance_issues: issue.project.group)
+
+ expect { service.execute }.to change { issue.reload.relative_position }
+ end
+
+ it 'aborts if there are too many rebalances running' do
+ caching = service.send(:caching)
+ allow(caching).to receive(:rebalance_in_progress?).and_return(false)
+ allow(caching).to receive(:concurrent_running_rebalances_count).and_return(10)
+ allow(service).to receive(:caching).and_return(caching)
+
+ expect { service.execute }.to raise_error(Issues::RelativePositionRebalancingService::TooManyConcurrentRebalances)
+ expect(project.root_namespace.issue_repositioning_disabled?).to be false
+ end
+
+ it 'resumes a started rebalance even if there are already too many rebalances running' do
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.sadd("gitlab:issues-position-rebalances:running_rebalances", "#{::Gitlab::Issues::Rebalancing::State::PROJECT}/#{project.id}")
+ redis.sadd("gitlab:issues-position-rebalances:running_rebalances", "1/100")
+ end
+
+ caching = service.send(:caching)
+ allow(caching).to receive(:concurrent_running_rebalances_count).and_return(10)
+ allow(service).to receive(:caching).and_return(caching)
+
+ expect { service.execute }.not_to raise_error(Issues::RelativePositionRebalancingService::TooManyConcurrentRebalances)
+ end
+
+ context 're-balancing is retried on statement timeout exceptions' do
+ subject { service }
+
+ it 'retries update statement' do
+ call_count = 0
+ allow(subject).to receive(:run_update_query) do
+ call_count += 1
+ if call_count < 13
+ raise(ActiveRecord::QueryCanceled)
+ else
+ call_count = 0 if call_count == 13 + 16 # 16 = 17 sub-batches - 1 call that succeeded as part of 5th batch
+ true
+ end
+ end
+
+ # call math:
+ # batches start at 100 and are split in half after every 3 retries if ActiveRecord::StatementTimeout exception is raised.
+ # We raise ActiveRecord::StatementTimeout exception for 13 calls:
+ # 1. 100 => 3 calls
+ # 2. 100/2=50 => 3 calls + 3 above = 6 calls, raise ActiveRecord::StatementTimeout
+ # 3. 50/2=25 => 3 calls + 6 above = 9 calls, raise ActiveRecord::StatementTimeout
+ # 4. 25/2=12 => 3 calls + 9 above = 12 calls, raise ActiveRecord::StatementTimeout
+ # 5. 12/2=6 => 1 call + 12 above = 13 calls, run successfully
+ #
+ # so out of 100 elements we created batches of 6 items => 100/6 = 17 sub-batches of 6 or less elements
+ #
+ # project.issues.count: 900 issues, so 9 batches of 100 => 9 * (13+16) = 261
+ expect(subject).to receive(:update_positions).exactly(261).times.and_call_original
+
+ subject.execute
+ end
+ end
+
+ context 'when resuming a stopped rebalance' do
+ before do
+ service.send(:preload_issue_ids)
+ expect(service.send(:caching).get_cached_issue_ids(0, 300)).not_to be_empty
+ # simulate we already rebalanced half the issues
+ index = clump_size * 3 / 2 + 1
+ service.send(:caching).cache_current_index(index)
+ end
+
+ it 'rebalances the other half of issues' do
+ expect(subject).to receive(:update_positions_with_retry).exactly(5).and_call_original
+
+ subject.execute
+ end
+ end
+ end
+end
diff --git a/spec/services/issues/reopen_service_spec.rb b/spec/services/issues/reopen_service_spec.rb
index d58c27289c2..86190c4e475 100644
--- a/spec/services/issues/reopen_service_spec.rb
+++ b/spec/services/issues/reopen_service_spec.rb
@@ -39,8 +39,11 @@ RSpec.describe Issues::ReopenService do
it 'refreshes the number of opened issues' do
service = described_class.new(project: project, current_user: user)
- expect { service.execute(issue) }
- .to change { project.open_issues_count }.from(0).to(1)
+ expect do
+ service.execute(issue)
+
+ BatchLoader::Executor.clear_current
+ end.to change { project.open_issues_count }.from(0).to(1)
end
it 'deletes milestone issue counters cache' do
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index 29ac7df88eb..331cf291f21 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -146,8 +146,11 @@ RSpec.describe Issues::UpdateService, :mailer do
it 'refreshes the number of open issues when the issue is made confidential', :use_clean_rails_memory_store_caching do
issue # make sure the issue is created first so our counts are correct.
- expect { update_issue(confidential: true) }
- .to change { project.open_issues_count }.from(1).to(0)
+ expect do
+ update_issue(confidential: true)
+
+ BatchLoader::Executor.clear_current
+ end.to change { project.open_issues_count }.from(1).to(0)
end
it 'enqueues ConfidentialIssueWorker when an issue is made confidential' do
@@ -189,6 +192,14 @@ RSpec.describe Issues::UpdateService, :mailer do
expect(issue.labels.pluck(:title)).to eq(['incident'])
end
+ it 'creates system note about issue type' do
+ update_issue(issue_type: 'incident')
+
+ note = find_note('changed issue type to incident')
+
+ expect(note).not_to eq(nil)
+ end
+
context 'for an issue with multiple labels' do
let(:issue) { create(:incident, project: project, labels: [label_1]) }
@@ -217,15 +228,19 @@ RSpec.describe Issues::UpdateService, :mailer do
context 'from incident to issue' do
let(:issue) { create(:incident, project: project) }
+ it 'changed from an incident to an issue type' do
+ expect { update_issue(issue_type: 'issue') }
+ .to change(issue, :issue_type).from('incident').to('issue')
+ .and(change { issue.work_item_type.base_type }.from('incident').to('issue'))
+ end
+
context 'for an incident with multiple labels' do
let(:issue) { create(:incident, project: project, labels: [label_1, label_2]) }
- before do
- update_issue(issue_type: 'issue')
- end
-
it 'removes an `incident` label if one exists on the incident' do
- expect(issue.labels).to eq([label_2])
+ expect { update_issue(issue_type: 'issue') }.to change(issue, :label_ids)
+ .from(containing_exactly(label_1.id, label_2.id))
+ .to([label_2.id])
end
end
@@ -233,12 +248,10 @@ RSpec.describe Issues::UpdateService, :mailer do
let(:issue) { create(:incident, project: project, labels: [label_1, label_2]) }
let(:params) { { label_ids: [label_1.id, label_2.id], remove_label_ids: [] } }
- before do
- update_issue(issue_type: 'issue')
- end
-
it 'adds an incident label id to remove_label_ids for it to be removed' do
- expect(issue.label_ids).to contain_exactly(label_2.id)
+ expect { update_issue(issue_type: 'issue') }.to change(issue, :label_ids)
+ .from(containing_exactly(label_1.id, label_2.id))
+ .to([label_2.id])
end
end
end
diff --git a/spec/services/members/groups/bulk_creator_service_spec.rb b/spec/services/members/groups/bulk_creator_service_spec.rb
new file mode 100644
index 00000000000..0623ae00080
--- /dev/null
+++ b/spec/services/members/groups/bulk_creator_service_spec.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Members::Groups::BulkCreatorService do
+ it_behaves_like 'bulk member creation' do
+ let_it_be(:source, reload: true) { create(:group, :public) }
+ let_it_be(:member_type) { GroupMember }
+ end
+end
diff --git a/spec/services/members/mailgun/process_webhook_service_spec.rb b/spec/services/members/mailgun/process_webhook_service_spec.rb
new file mode 100644
index 00000000000..d6a21183395
--- /dev/null
+++ b/spec/services/members/mailgun/process_webhook_service_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Members::Mailgun::ProcessWebhookService do
+ describe '#execute', :aggregate_failures do
+ let_it_be(:member) { create(:project_member, :invited) }
+
+ let(:raw_invite_token) { member.raw_invite_token }
+ let(:payload) { { 'user-variables' => { ::Members::Mailgun::INVITE_EMAIL_TOKEN_KEY => raw_invite_token } } }
+
+ subject(:service) { described_class.new(payload).execute }
+
+ it 'marks the member invite email success as false' do
+ expect(Gitlab::AppLogger).to receive(:info).with(/^UPDATED MEMBER INVITE_EMAIL_SUCCESS/).and_call_original
+
+ expect { service }.to change { member.reload.invite_email_success }.from(true).to(false)
+ end
+
+ context 'when member can not be found' do
+ let(:raw_invite_token) { '_foobar_' }
+
+ it 'does not change member status' do
+ expect(Gitlab::AppLogger).not_to receive(:info).with(/^UPDATED MEMBER INVITE_EMAIL_SUCCESS/)
+
+ expect { service }.not_to change { member.reload.invite_email_success }
+ end
+ end
+
+ context 'when invite token is not found in payload' do
+ let(:payload) { {} }
+
+ it 'does not change member status and logs an error' do
+ expect(Gitlab::AppLogger).not_to receive(:info).with(/^UPDATED MEMBER INVITE_EMAIL_SUCCESS/)
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
+ an_instance_of(described_class::ProcessWebhookServiceError))
+
+ expect { service }.not_to change { member.reload.invite_email_success }
+ end
+ end
+ end
+end
diff --git a/spec/services/members/projects/bulk_creator_service_spec.rb b/spec/services/members/projects/bulk_creator_service_spec.rb
new file mode 100644
index 00000000000..7acb7d79fe7
--- /dev/null
+++ b/spec/services/members/projects/bulk_creator_service_spec.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Members::Projects::BulkCreatorService do
+ it_behaves_like 'bulk member creation' do
+ let_it_be(:source, reload: true) { create(:project, :public) }
+ let_it_be(:member_type) { ProjectMember }
+ end
+end
diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb
index b3af4d67896..e3f33304aab 100644
--- a/spec/services/merge_requests/merge_service_spec.rb
+++ b/spec/services/merge_requests/merge_service_spec.rb
@@ -402,21 +402,6 @@ RSpec.describe MergeRequests::MergeService do
expect(Gitlab::AppLogger).to have_received(:error).with(a_string_matching(error_message))
end
- it 'logs and saves error if there is a squash in progress' do
- error_message = 'another squash is already in progress'
-
- allow_any_instance_of(MergeRequest).to receive(:squash_in_progress?).and_return(true)
- merge_request.update!(squash: true)
-
- service.execute(merge_request)
-
- expect(merge_request).to be_open
- expect(merge_request.merge_commit_sha).to be_nil
- expect(merge_request.squash_commit_sha).to be_nil
- expect(merge_request.merge_error).to include(error_message)
- expect(Gitlab::AppLogger).to have_received(:error).with(a_string_matching(error_message))
- end
-
it 'logs and saves error if there is an PreReceiveError exception' do
error_message = 'error message'
diff --git a/spec/services/merge_requests/merge_to_ref_service_spec.rb b/spec/services/merge_requests/merge_to_ref_service_spec.rb
index 8fc12c6c2b1..0a781aee704 100644
--- a/spec/services/merge_requests/merge_to_ref_service_spec.rb
+++ b/spec/services/merge_requests/merge_to_ref_service_spec.rb
@@ -37,34 +37,26 @@ RSpec.describe MergeRequests::MergeToRefService do
expect(ref_head.id).to eq(result[:commit_id])
end
- context 'cache_merge_to_ref_calls flag enabled', :use_clean_rails_memory_store_caching do
+ context 'cache_merge_to_ref_calls parameter', :use_clean_rails_memory_store_caching do
before do
- stub_feature_flags(cache_merge_to_ref_calls: true)
-
# warm the cache
#
- service.execute(merge_request)
- end
-
- it 'caches the response', :request_store do
- expect { 3.times { service.execute(merge_request) } }
- .not_to change(Gitlab::GitalyClient, :get_request_count)
+ service.execute(merge_request, true)
end
- end
-
- context 'cache_merge_to_ref_calls flag disabled', :use_clean_rails_memory_store_caching do
- before do
- stub_feature_flags(cache_merge_to_ref_calls: false)
- # warm the cache
- #
- service.execute(merge_request)
+ context 'when true' do
+ it 'caches the response', :request_store do
+ expect { 3.times { service.execute(merge_request, true) } }
+ .not_to change(Gitlab::GitalyClient, :get_request_count)
+ end
end
- it 'does not cache the response', :request_store do
- expect(Gitlab::GitalyClient).to receive(:call).at_least(3).times.and_call_original
+ context 'when false' do
+ it 'does not cache the response', :request_store do
+ expect(Gitlab::GitalyClient).to receive(:call).at_least(3).times.and_call_original
- 3.times { service.execute(merge_request) }
+ 3.times { service.execute(merge_request, false) }
+ end
end
end
end
diff --git a/spec/services/merge_requests/mergeability_check_service_spec.rb b/spec/services/merge_requests/mergeability_check_service_spec.rb
index 65599b7e046..4f7be0f5965 100644
--- a/spec/services/merge_requests/mergeability_check_service_spec.rb
+++ b/spec/services/merge_requests/mergeability_check_service_spec.rb
@@ -132,6 +132,15 @@ RSpec.describe MergeRequests::MergeabilityCheckService, :clean_gitlab_redis_shar
it_behaves_like 'mergeable merge request'
+ it 'calls MergeToRefService with cache parameter' do
+ service = instance_double(MergeRequests::MergeToRefService)
+
+ expect(MergeRequests::MergeToRefService).to receive(:new).once { service }
+ expect(service).to receive(:execute).once.with(merge_request, true).and_return(success: true)
+
+ described_class.new(merge_request).execute(recheck: true)
+ end
+
context 'when concurrent calls' do
it 'waits first lock and returns "cached" result in subsequent calls' do
threads = execute_within_threads(amount: 3)
diff --git a/spec/services/merge_requests/squash_service_spec.rb b/spec/services/merge_requests/squash_service_spec.rb
index 149748cdabc..09f83624e05 100644
--- a/spec/services/merge_requests/squash_service_spec.rb
+++ b/spec/services/merge_requests/squash_service_spec.rb
@@ -194,23 +194,6 @@ RSpec.describe MergeRequests::SquashService do
expect(service.execute).to match(status: :error, message: a_string_including('squash'))
end
end
-
- context 'with an error in squash in progress check' do
- before do
- allow(repository).to receive(:squash_in_progress?)
- .and_raise(Gitlab::Git::Repository::GitError, error)
- end
-
- it 'logs the stage and output' do
- expect(service).to receive(:log_error).with(exception: an_instance_of(Gitlab::Git::Repository::GitError), message: 'Failed to check squash in progress')
-
- service.execute
- end
-
- it 'returns an error' do
- expect(service.execute).to match(status: :error, message: 'An error occurred while checking whether another squash is in progress.')
- end
- end
end
context 'when any other exception is thrown' do
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index 3c4d7d50002..a03f1f17b39 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -2623,7 +2623,7 @@ RSpec.describe NotificationService, :mailer do
let_it_be(:user) { create(:user) }
it 'sends the user an email' do
- notification.user_deactivated(user.name, user.notification_email)
+ notification.user_deactivated(user.name, user.notification_email_or_default)
should_only_email(user)
end
diff --git a/spec/services/packages/composer/version_parser_service_spec.rb b/spec/services/packages/composer/version_parser_service_spec.rb
index 1a2f653c042..69253ff934e 100644
--- a/spec/services/packages/composer/version_parser_service_spec.rb
+++ b/spec/services/packages/composer/version_parser_service_spec.rb
@@ -12,6 +12,7 @@ RSpec.describe Packages::Composer::VersionParserService do
where(:tagname, :branchname, :expected_version) do
nil | 'master' | 'dev-master'
nil | 'my-feature' | 'dev-my-feature'
+ nil | '12-feature' | 'dev-12-feature'
nil | 'v1' | '1.x-dev'
nil | 'v1.x' | '1.x-dev'
nil | 'v1.7.x' | '1.7.x-dev'
diff --git a/spec/services/packages/generic/create_package_file_service_spec.rb b/spec/services/packages/generic/create_package_file_service_spec.rb
index 1c9eb53cfc7..9d6784b7721 100644
--- a/spec/services/packages/generic/create_package_file_service_spec.rb
+++ b/spec/services/packages/generic/create_package_file_service_spec.rb
@@ -105,6 +105,37 @@ RSpec.describe Packages::Generic::CreatePackageFileService do
it { expect { execute_service }.to change { project.package_files.count }.by(1) }
end
end
+
+ context 'with multiple files for the same package and the same pipeline' do
+ let(:file_2_params) { params.merge(file_name: 'myfile.tar.gz.2', file: file2) }
+ let(:file_3_params) { params.merge(file_name: 'myfile.tar.gz.3', file: file3) }
+
+ let(:temp_file2) { Tempfile.new("test2") }
+ let(:temp_file3) { Tempfile.new("test3") }
+
+ let(:file2) { UploadedFile.new(temp_file2.path, sha256: sha256) }
+ let(:file3) { UploadedFile.new(temp_file3.path, sha256: sha256) }
+
+ before do
+ FileUtils.touch(temp_file2)
+ FileUtils.touch(temp_file3)
+ expect(::Packages::Generic::FindOrCreatePackageService).to receive(:new).with(project, user, package_params).and_return(package_service).twice
+ expect(package_service).to receive(:execute).and_return(package).twice
+ end
+
+ after do
+ FileUtils.rm_f(temp_file2)
+ FileUtils.rm_f(temp_file3)
+ end
+
+ it 'creates the build info only once' do
+ expect do
+ described_class.new(project, user, params).execute
+ described_class.new(project, user, file_2_params).execute
+ described_class.new(project, user, file_3_params).execute
+ end.to change { package.build_infos.count }.by(1)
+ end
+ end
end
end
end
diff --git a/spec/services/packages/maven/find_or_create_package_service_spec.rb b/spec/services/packages/maven/find_or_create_package_service_spec.rb
index d8b48af0121..59f5677f526 100644
--- a/spec/services/packages/maven/find_or_create_package_service_spec.rb
+++ b/spec/services/packages/maven/find_or_create_package_service_spec.rb
@@ -98,6 +98,19 @@ RSpec.describe Packages::Maven::FindOrCreatePackageService do
it 'creates a build_info' do
expect { subject }.to change { Packages::BuildInfo.count }.by(1)
end
+
+ context 'with multiple files for the same package and the same pipeline' do
+ let(:file_2_params) { params.merge(file_name: 'test2.jar') }
+ let(:file_3_params) { params.merge(file_name: 'test3.jar') }
+
+ it 'creates a single build info' do
+ expect do
+ described_class.new(project, user, params).execute
+ described_class.new(project, user, file_2_params).execute
+ described_class.new(project, user, file_3_params).execute
+ end.to change { ::Packages::BuildInfo.count }.by(1)
+ end
+ end
end
context 'when package duplicates are not allowed' do
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 66ff6a8d03f..d682ee12ed5 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, :processing, :with_symbol_package) }
+ let!(:package) { create(:nuget_package, :processing, :with_symbol_package) }
let(:package_file) { package.package_files.first }
let(:service) { described_class.new(package_file) }
let(:package_name) { 'DummyProject.DummyPackage' }
@@ -63,234 +63,213 @@ RSpec.describe Packages::Nuget::UpdatePackageFromMetadataService, :clean_gitlab_
end
end
- shared_examples 'handling all conditions' do
- context 'with no existing package' do
- let(:package_id) { package.id }
+ context 'with no existing package' do
+ let(:package_id) { package.id }
+
+ it 'updates package and package file', :aggregate_failures do
+ expect { subject }
+ .to not_change { ::Packages::Package.count }
+ .and change { Packages::Dependency.count }.by(1)
+ .and change { Packages::DependencyLink.count }.by(1)
+ .and change { ::Packages::Nuget::Metadatum.count }.by(0)
+
+ 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
+ end
- it 'updates package and package file', :aggregate_failures do
- expect { subject }
- .to not_change { ::Packages::Package.count }
- .and change { Packages::Dependency.count }.by(1)
- .and change { Packages::DependencyLink.count }.by(1)
- .and change { ::Packages::Nuget::Metadatum.count }.by(0)
+ it_behaves_like 'taking the lease'
- 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
- end
+ it_behaves_like 'not updating the package if the lease is taken'
+ end
- it_behaves_like 'taking the lease'
+ context 'with existing package' do
+ let!(:existing_package) { create(:nuget_package, project: package.project, name: package_name, version: package_version) }
+ let(:package_id) { existing_package.id }
- it_behaves_like 'not updating the package if the lease is taken'
- end
+ it 'link existing package and updates package file', :aggregate_failures do
+ expect(service).to receive(:try_obtain_lease).and_call_original
- context 'with existing package' do
- let!(:existing_package) { create(:nuget_package, project: package.project, name: package_name, version: package_version) }
- let(:package_id) { existing_package.id }
+ expect { subject }
+ .to change { ::Packages::Package.count }.by(-1)
+ .and change { Packages::Dependency.count }.by(0)
+ .and change { Packages::DependencyLink.count }.by(0)
+ .and change { Packages::Nuget::DependencyLinkMetadatum.count }.by(0)
+ .and change { ::Packages::Nuget::Metadatum.count }.by(0)
+ expect(package_file.reload.file_name).to eq(package_file_name)
+ expect(package_file.package).to eq(existing_package)
+ end
- it 'link existing package and updates package file', :aggregate_failures do
- expect(service).to receive(:try_obtain_lease).and_call_original
+ it_behaves_like 'taking the lease'
- expect { subject }
- .to change { ::Packages::Package.count }.by(-1)
- .and change { Packages::Dependency.count }.by(0)
- .and change { Packages::DependencyLink.count }.by(0)
- .and change { Packages::Nuget::DependencyLinkMetadatum.count }.by(0)
- .and change { ::Packages::Nuget::Metadatum.count }.by(0)
- expect(package_file.reload.file_name).to eq(package_file_name)
- expect(package_file.package).to eq(existing_package)
- end
+ it_behaves_like 'not updating the package if the lease is taken'
+ end
- it_behaves_like 'taking the lease'
+ context 'with a nuspec file with metadata' do
+ let(:nuspec_filepath) { 'packages/nuget/with_metadata.nuspec' }
+ let(:expected_tags) { %w(foo bar test tag1 tag2 tag3 tag4 tag5) }
- it_behaves_like 'not updating the package if the lease is taken'
+ before do
+ allow_next_instance_of(Packages::Nuget::MetadataExtractionService) do |service|
+ allow(service)
+ .to receive(:nuspec_file_content).and_return(fixture_file(nuspec_filepath))
+ end
end
- context 'with a nuspec file with metadata' do
- let(:nuspec_filepath) { 'packages/nuget/with_metadata.nuspec' }
- let(:expected_tags) { %w(foo bar test tag1 tag2 tag3 tag4 tag5) }
+ it 'creates tags' do
+ expect(service).to receive(:try_obtain_lease).and_call_original
+ expect { subject }.to change { ::Packages::Tag.count }.by(8)
+ expect(package.reload.tags.map(&:name)).to contain_exactly(*expected_tags)
+ end
- before do
- allow_next_instance_of(Packages::Nuget::MetadataExtractionService) do |service|
- allow(service)
- .to receive(:nuspec_file_content).and_return(fixture_file(nuspec_filepath))
- end
- end
+ context 'with existing package and tags' do
+ let!(:existing_package) { create(:nuget_package, project: package.project, name: 'DummyProject.WithMetadata', version: '1.2.3') }
+ let!(:tag1) { create(:packages_tag, package: existing_package, name: 'tag1') }
+ let!(:tag2) { create(:packages_tag, package: existing_package, name: 'tag2') }
+ let!(:tag3) { create(:packages_tag, package: existing_package, name: 'tag_not_in_metadata') }
- it 'creates tags' do
+ it 'creates tags and deletes those not in metadata' do
expect(service).to receive(:try_obtain_lease).and_call_original
- expect { subject }.to change { ::Packages::Tag.count }.by(8)
- expect(package.reload.tags.map(&:name)).to contain_exactly(*expected_tags)
+ expect { subject }.to change { ::Packages::Tag.count }.by(5)
+ expect(existing_package.tags.map(&:name)).to contain_exactly(*expected_tags)
end
+ end
- context 'with existing package and tags' do
- let!(:existing_package) { create(:nuget_package, project: package.project, name: 'DummyProject.WithMetadata', version: '1.2.3') }
- let!(:tag1) { create(:packages_tag, package: existing_package, name: 'tag1') }
- let!(:tag2) { create(:packages_tag, package: existing_package, name: 'tag2') }
- let!(:tag3) { create(:packages_tag, package: existing_package, name: 'tag_not_in_metadata') }
-
- it 'creates tags and deletes those not in metadata' do
- expect(service).to receive(:try_obtain_lease).and_call_original
- expect { subject }.to change { ::Packages::Tag.count }.by(5)
- expect(existing_package.tags.map(&:name)).to contain_exactly(*expected_tags)
- end
- end
-
- it 'creates nuget metadatum', :aggregate_failures do
- expect { subject }
- .to not_change { ::Packages::Package.count }
- .and change { ::Packages::Nuget::Metadatum.count }.by(1)
-
- metadatum = package_file.reload.package.nuget_metadatum
- expect(metadatum.license_url).to eq('https://opensource.org/licenses/MIT')
- expect(metadatum.project_url).to eq('https://gitlab.com/gitlab-org/gitlab')
- expect(metadatum.icon_url).to eq('https://opensource.org/files/osi_keyhole_300X300_90ppi_0.png')
- end
+ it 'creates nuget metadatum', :aggregate_failures do
+ expect { subject }
+ .to not_change { ::Packages::Package.count }
+ .and change { ::Packages::Nuget::Metadatum.count }.by(1)
- context 'with too long url' do
- let_it_be(:too_long_url) { "http://localhost/#{'bananas' * 50}" }
+ metadatum = package_file.reload.package.nuget_metadatum
+ expect(metadatum.license_url).to eq('https://opensource.org/licenses/MIT')
+ expect(metadatum.project_url).to eq('https://gitlab.com/gitlab-org/gitlab')
+ expect(metadatum.icon_url).to eq('https://opensource.org/files/osi_keyhole_300X300_90ppi_0.png')
+ end
- let(:metadata) { { package_name: package_name, package_version: package_version, license_url: too_long_url } }
+ context 'with too long url' do
+ let_it_be(:too_long_url) { "http://localhost/#{'bananas' * 50}" }
- before do
- allow(service).to receive(:metadata).and_return(metadata)
- end
+ let(:metadata) { { package_name: package_name, package_version: package_version, license_url: too_long_url } }
- it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError
+ before do
+ allow(service).to receive(:metadata).and_return(metadata)
end
+
+ it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError
end
+ end
- context 'with nuspec file with dependencies' do
- let(:nuspec_filepath) { 'packages/nuget/with_dependencies.nuspec' }
- let(:package_name) { 'Test.Package' }
- let(:package_version) { '3.5.2' }
- let(:package_file_name) { 'test.package.3.5.2.nupkg' }
+ context 'with nuspec file with dependencies' do
+ let(:nuspec_filepath) { 'packages/nuget/with_dependencies.nuspec' }
+ let(:package_name) { 'Test.Package' }
+ let(:package_version) { '3.5.2' }
+ let(:package_file_name) { 'test.package.3.5.2.nupkg' }
- before do
- allow_next_instance_of(Packages::Nuget::MetadataExtractionService) do |service|
- allow(service)
- .to receive(:nuspec_file_content).and_return(fixture_file(nuspec_filepath))
- end
+ before do
+ allow_next_instance_of(Packages::Nuget::MetadataExtractionService) do |service|
+ allow(service)
+ .to receive(:nuspec_file_content).and_return(fixture_file(nuspec_filepath))
end
+ end
- it 'updates package and package file', :aggregate_failures do
- expect { subject }
- .to not_change { ::Packages::Package.count }
- .and change { Packages::Dependency.count }.by(4)
- .and change { Packages::DependencyLink.count }.by(4)
- .and change { Packages::Nuget::DependencyLinkMetadatum.count }.by(2)
-
- 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
- end
+ it 'updates package and package file', :aggregate_failures do
+ expect { subject }
+ .to not_change { ::Packages::Package.count }
+ .and change { Packages::Dependency.count }.by(4)
+ .and change { Packages::DependencyLink.count }.by(4)
+ .and change { Packages::Nuget::DependencyLinkMetadatum.count }.by(2)
+
+ 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
end
+ end
- context 'with package file not containing a nuspec file' do
- before do
- allow_next_instance_of(Zip::File) do |file|
- allow(file).to receive(:glob).and_return([])
- end
+ context 'with package file not containing a nuspec file' do
+ before do
+ allow_next_instance_of(Zip::File) do |file|
+ allow(file).to receive(:glob).and_return([])
end
-
- it_behaves_like 'raising an', ::Packages::Nuget::MetadataExtractionService::ExtractionError
end
- context 'with a symbol package' do
- let(:package_file) { package.package_files.last }
- let(:package_file_name) { 'dummyproject.dummypackage.1.0.0.snupkg' }
+ it_behaves_like 'raising an', ::Packages::Nuget::MetadataExtractionService::ExtractionError
+ end
- context 'with no existing package' do
- let(:package_id) { package.id }
+ context 'with a symbol package' do
+ let(:package_file) { package.package_files.last }
+ let(:package_file_name) { 'dummyproject.dummypackage.1.0.0.snupkg' }
- it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError
- end
-
- context 'with existing package' do
- let!(:existing_package) { create(:nuget_package, project: package.project, name: package_name, version: package_version) }
- let(:package_id) { existing_package.id }
+ context 'with no existing package' do
+ let(:package_id) { package.id }
- it 'link existing package and updates package file', :aggregate_failures do
- expect(service).to receive(:try_obtain_lease).and_call_original
- expect(::Packages::Nuget::SyncMetadatumService).not_to receive(:new)
- expect(::Packages::UpdateTagsService).not_to receive(:new)
+ it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError
+ end
- expect { subject }
- .to change { ::Packages::Package.count }.by(-1)
- .and change { Packages::Dependency.count }.by(0)
- .and change { Packages::DependencyLink.count }.by(0)
- .and change { Packages::Nuget::DependencyLinkMetadatum.count }.by(0)
- .and change { ::Packages::Nuget::Metadatum.count }.by(0)
- expect(package_file.reload.file_name).to eq(package_file_name)
- expect(package_file.package).to eq(existing_package)
- end
+ context 'with existing package' do
+ let!(:existing_package) { create(:nuget_package, project: package.project, name: package_name, version: package_version) }
+ let(:package_id) { existing_package.id }
- it_behaves_like 'taking the lease'
+ it 'link existing package and updates package file', :aggregate_failures do
+ expect(service).to receive(:try_obtain_lease).and_call_original
+ expect(::Packages::Nuget::SyncMetadatumService).not_to receive(:new)
+ expect(::Packages::UpdateTagsService).not_to receive(:new)
- it_behaves_like 'not updating the package if the lease is taken'
+ expect { subject }
+ .to change { ::Packages::Package.count }.by(-1)
+ .and change { Packages::Dependency.count }.by(0)
+ .and change { Packages::DependencyLink.count }.by(0)
+ .and change { Packages::Nuget::DependencyLinkMetadatum.count }.by(0)
+ .and change { ::Packages::Nuget::Metadatum.count }.by(0)
+ expect(package_file.reload.file_name).to eq(package_file_name)
+ expect(package_file.package).to eq(existing_package)
end
- end
-
- context 'with an invalid package name' do
- invalid_names = [
- '',
- 'My/package',
- '../../../my_package',
- '%2e%2e%2fmy_package'
- ]
- invalid_names.each do |invalid_name|
- before do
- allow(service).to receive(:package_name).and_return(invalid_name)
- end
+ it_behaves_like 'taking the lease'
- it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError
- end
+ it_behaves_like 'not updating the package if the lease is taken'
end
+ end
- context 'with an invalid package version' do
- invalid_versions = [
- '',
- '555',
- '1.2',
- '1./2.3',
- '../../../../../1.2.3',
- '%2e%2e%2f1.2.3'
- ]
-
- invalid_versions.each do |invalid_version|
- before do
- allow(service).to receive(:package_version).and_return(invalid_version)
- end
-
- it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError
+ context 'with an invalid package name' do
+ invalid_names = [
+ '',
+ 'My/package',
+ '../../../my_package',
+ '%2e%2e%2fmy_package'
+ ]
+
+ invalid_names.each do |invalid_name|
+ before do
+ allow(service).to receive(:package_name).and_return(invalid_name)
end
- end
- end
- context 'with packages_nuget_new_package_file_updater enabled' do
- before do
- expect(service).not_to receive(:legacy_execute)
+ it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError
end
-
- it_behaves_like 'handling all conditions'
end
- context 'with packages_nuget_new_package_file_updater disabled' do
- before do
- stub_feature_flags(packages_nuget_new_package_file_updater: false)
- expect(::Packages::UpdatePackageFileService)
- .not_to receive(:new).with(package_file, instance_of(Hash)).and_call_original
- expect(service).not_to receive(:new_execute)
- end
+ context 'with an invalid package version' do
+ invalid_versions = [
+ '',
+ '555',
+ '1.2',
+ '1./2.3',
+ '../../../../../1.2.3',
+ '%2e%2e%2f1.2.3'
+ ]
+
+ invalid_versions.each do |invalid_version|
+ before do
+ allow(service).to receive(:package_version).and_return(invalid_version)
+ end
- it_behaves_like 'handling all conditions'
+ it_behaves_like 'raising an', ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError
+ end
end
end
end
diff --git a/spec/services/pages/delete_service_spec.rb b/spec/services/pages/delete_service_spec.rb
index 295abe15bf0..e02e8e72e0b 100644
--- a/spec/services/pages/delete_service_spec.rb
+++ b/spec/services/pages/delete_service_spec.rb
@@ -12,27 +12,6 @@ RSpec.describe Pages::DeleteService do
project.mark_pages_as_deployed
end
- it 'deletes published pages', :sidekiq_inline do
- expect_next_instance_of(Gitlab::PagesTransfer) do |pages_transfer|
- expect(pages_transfer).to receive(:rename_project).and_return true
- end
-
- expect(PagesWorker).to receive(:perform_in).with(5.minutes, :remove, project.namespace.full_path, anything)
-
- service.execute
- end
-
- it "doesn't remove anything from the legacy storage if local_store is disabled", :sidekiq_inline do
- allow(Settings.pages.local_store).to receive(:enabled).and_return(false)
-
- expect(project.pages_deployed?).to be(true)
- expect(PagesWorker).not_to receive(:perform_in)
-
- service.execute
-
- expect(project.pages_deployed?).to be(false)
- end
-
it 'marks pages as not deployed' do
expect do
service.execute
diff --git a/spec/services/pages/legacy_storage_lease_spec.rb b/spec/services/pages/legacy_storage_lease_spec.rb
deleted file mode 100644
index 092dce093ff..00000000000
--- a/spec/services/pages/legacy_storage_lease_spec.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ::Pages::LegacyStorageLease do
- let(:project) { create(:project) }
-
- let(:implementation) do
- Class.new do
- include ::Pages::LegacyStorageLease
-
- attr_reader :project
-
- def initialize(project)
- @project = project
- end
-
- def execute
- try_obtain_lease do
- execute_unsafe
- end
- end
-
- def execute_unsafe
- true
- end
- end
- end
-
- let(:service) { implementation.new(project) }
-
- it 'allows method to be executed' do
- expect(service).to receive(:execute_unsafe).and_call_original
-
- expect(service.execute).to eq(true)
- end
-
- context 'when another service holds the lease for the same project' do
- around do |example|
- implementation.new(project).try_obtain_lease do
- example.run
- end
- end
-
- it 'does not run guarded method' do
- expect(service).not_to receive(:execute_unsafe)
-
- expect(service.execute).to eq(nil)
- end
- end
-
- context 'when another service holds the lease for the different project' do
- around do |example|
- implementation.new(create(:project)).try_obtain_lease do
- example.run
- end
- end
-
- it 'allows method to be executed' do
- expect(service).to receive(:execute_unsafe).and_call_original
-
- expect(service.execute).to eq(true)
- end
- end
-end
diff --git a/spec/services/pages/migrate_legacy_storage_to_deployment_service_spec.rb b/spec/services/pages/migrate_legacy_storage_to_deployment_service_spec.rb
index 25f571a73d1..177467aac85 100644
--- a/spec/services/pages/migrate_legacy_storage_to_deployment_service_spec.rb
+++ b/spec/services/pages/migrate_legacy_storage_to_deployment_service_spec.rb
@@ -114,13 +114,5 @@ RSpec.describe Pages::MigrateLegacyStorageToDeploymentService do
described_class.new(project).execute
end.not_to change { project.pages_metadatum.reload.pages_deployment_id }.from(old_deployment.id)
end
-
- it 'raises exception if exclusive lease is taken' do
- described_class.new(project).try_obtain_lease do
- expect do
- described_class.new(project).execute
- end.to raise_error(described_class::ExclusiveLeaseTakenError)
- end
- end
end
end
diff --git a/spec/services/projects/batch_open_issues_count_service_spec.rb b/spec/services/projects/batch_open_issues_count_service_spec.rb
index 82d50604309..17bd5f7a37b 100644
--- a/spec/services/projects/batch_open_issues_count_service_spec.rb
+++ b/spec/services/projects/batch_open_issues_count_service_spec.rb
@@ -5,52 +5,49 @@ require 'spec_helper'
RSpec.describe Projects::BatchOpenIssuesCountService do
let!(:project_1) { create(:project) }
let!(:project_2) { create(:project) }
+ let!(:banned_user) { create(:user, :banned) }
let(:subject) { described_class.new([project_1, project_2]) }
- describe '#refresh_cache', :use_clean_rails_memory_store_caching do
+ describe '#refresh_cache_and_retrieve_data', :use_clean_rails_memory_store_caching do
before do
create(:issue, project: project_1)
create(:issue, project: project_1, confidential: true)
-
+ create(:issue, project: project_1, author: banned_user)
create(:issue, project: project_2)
create(:issue, project: project_2, confidential: true)
+ create(:issue, project: project_2, author: banned_user)
end
- context 'when cache is clean' do
+ context 'when cache is clean', :aggregate_failures do
it 'refreshes cache keys correctly' do
- subject.refresh_cache
+ expect(get_cache_key(project_1)).to eq(nil)
+ expect(get_cache_key(project_2)).to eq(nil)
- # It does not update total issues cache
- expect(Rails.cache.read(get_cache_key(subject, project_1))).to eq(nil)
- expect(Rails.cache.read(get_cache_key(subject, project_2))).to eq(nil)
+ subject.count_service.new(project_1).refresh_cache
+ subject.count_service.new(project_2).refresh_cache
- expect(Rails.cache.read(get_cache_key(subject, project_1, true))).to eq(1)
- expect(Rails.cache.read(get_cache_key(subject, project_1, true))).to eq(1)
- end
- end
-
- context 'when issues count is already cached' do
- before do
- create(:issue, project: project_2)
- subject.refresh_cache
- end
+ expect(get_cache_key(project_1)).to eq(1)
+ expect(get_cache_key(project_2)).to eq(1)
- it 'does update cache again' do
- expect(Rails.cache).not_to receive(:write)
+ expect(get_cache_key(project_1, true)).to eq(2)
+ expect(get_cache_key(project_2, true)).to eq(2)
- subject.refresh_cache
+ expect(get_cache_key(project_1, true, true)).to eq(3)
+ expect(get_cache_key(project_2, true, true)).to eq(3)
end
end
end
- def get_cache_key(subject, project, public_key = false)
+ def get_cache_key(project, with_confidential = false, with_hidden = false)
service = subject.count_service.new(project)
- if public_key
- service.cache_key(service.class::PUBLIC_COUNT_KEY)
+ if with_confidential && with_hidden
+ Rails.cache.read(service.cache_key(service.class::TOTAL_COUNT_KEY))
+ elsif with_confidential
+ Rails.cache.read(service.cache_key(service.class::TOTAL_COUNT_WITHOUT_HIDDEN_KEY))
else
- service.cache_key(service.class::TOTAL_COUNT_KEY)
+ Rails.cache.read(service.cache_key(service.class::PUBLIC_COUNT_WITHOUT_HIDDEN_KEY))
end
end
end
diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb
index c3928563125..e15d9341fd1 100644
--- a/spec/services/projects/create_service_spec.rb
+++ b/spec/services/projects/create_service_spec.rb
@@ -86,7 +86,7 @@ RSpec.describe Projects::CreateService, '#execute' do
subject(:project) { create_project(user, opts) }
context "with 'topics' parameter" do
- let(:opts) { { topics: 'topics' } }
+ let(:opts) { { name: 'topic-project', topics: 'topics' } }
it 'keeps them as specified' do
expect(project.topic_list).to eq(%w[topics])
@@ -94,7 +94,7 @@ RSpec.describe Projects::CreateService, '#execute' do
end
context "with 'topic_list' parameter" do
- let(:opts) { { topic_list: 'topic_list' } }
+ let(:opts) { { name: 'topic-project', topic_list: 'topic_list' } }
it 'keeps them as specified' do
expect(project.topic_list).to eq(%w[topic_list])
@@ -102,7 +102,7 @@ RSpec.describe Projects::CreateService, '#execute' do
end
context "with 'tag_list' parameter (deprecated)" do
- let(:opts) { { tag_list: 'tag_list' } }
+ let(:opts) { { name: 'topic-project', tag_list: 'tag_list' } }
it 'keeps them as specified' do
expect(project.topic_list).to eq(%w[tag_list])
@@ -346,6 +346,12 @@ RSpec.describe Projects::CreateService, '#execute' do
expect(imported_project.import_data.data).to eq(import_data[:data])
expect(imported_project.import_url).to eq('http://import-url')
end
+
+ it 'tracks for the combined_registration experiment', :experiment do
+ expect(experiment(:combined_registration)).to track(:import_project).on_next_instance
+
+ imported_project
+ end
end
context 'builds_enabled global setting' do
@@ -601,6 +607,18 @@ RSpec.describe Projects::CreateService, '#execute' do
MARKDOWN
end
end
+
+ context 'and readme_template is specified' do
+ before do
+ opts[:readme_template] = "# GitLab\nThis is customized template."
+ end
+
+ it_behaves_like 'creates README.md'
+
+ it 'creates README.md with specified template' do
+ expect(project.repository.readme.data).to include('This is customized template.')
+ end
+ end
end
end
diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb
index d710e4a777f..3f58fa46806 100644
--- a/spec/services/projects/fork_service_spec.rb
+++ b/spec/services/projects/fork_service_spec.rb
@@ -28,7 +28,8 @@ RSpec.describe Projects::ForkService do
namespace: @from_namespace,
star_count: 107,
avatar: avatar,
- description: 'wow such project')
+ description: 'wow such project',
+ external_authorization_classification_label: 'classification-label')
@to_user = create(:user)
@to_namespace = @to_user.namespace
@from_project.add_user(@to_user, :developer)
@@ -66,6 +67,7 @@ RSpec.describe Projects::ForkService do
it { expect(to_project.description).to eq(@from_project.description) }
it { expect(to_project.avatar.file).to be_exists }
it { expect(to_project.ci_config_path).to eq(@from_project.ci_config_path) }
+ it { expect(to_project.external_authorization_classification_label).to eq(@from_project.external_authorization_classification_label) }
# This test is here because we had a bug where the from-project lost its
# avatar after being forked.
diff --git a/spec/services/projects/group_links/destroy_service_spec.rb b/spec/services/projects/group_links/destroy_service_spec.rb
index d65afb68bfe..5d07fd52230 100644
--- a/spec/services/projects/group_links/destroy_service_spec.rb
+++ b/spec/services/projects/group_links/destroy_service_spec.rb
@@ -20,54 +20,28 @@ RSpec.describe Projects::GroupLinks::DestroyService, '#execute' do
group.add_maintainer(user)
end
- context 'when the feature flag `use_specialized_worker_for_project_auth_recalculation` is enabled' do
- before do
- stub_feature_flags(use_specialized_worker_for_project_auth_recalculation: true)
- end
-
- it 'calls AuthorizedProjectUpdate::ProjectRecalculateWorker to update project authorizations' do
- expect(AuthorizedProjectUpdate::ProjectRecalculateWorker)
- .to receive(:perform_async).with(group_link.project.id)
-
- subject.execute(group_link)
- end
-
- it 'calls AuthorizedProjectUpdate::UserRefreshFromReplicaWorker with a delay to update project authorizations' do
- expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to(
- receive(:bulk_perform_in)
- .with(1.hour,
- [[user.id]],
- batch_delay: 30.seconds, batch_size: 100)
- )
-
- subject.execute(group_link)
- end
+ it 'calls AuthorizedProjectUpdate::ProjectRecalculateWorker to update project authorizations' do
+ expect(AuthorizedProjectUpdate::ProjectRecalculateWorker)
+ .to receive(:perform_async).with(group_link.project.id)
- it 'updates project authorizations of users who had access to the project via the group share', :sidekiq_inline do
- expect { subject.execute(group_link) }.to(
- change { Ability.allowed?(user, :read_project, project) }
- .from(true).to(false))
- end
+ subject.execute(group_link)
end
- context 'when the feature flag `use_specialized_worker_for_project_auth_recalculation` is disabled' do
- before do
- stub_feature_flags(use_specialized_worker_for_project_auth_recalculation: false)
- end
-
- it 'calls UserProjectAccessChangedService to update project authorizations' do
- expect_next_instance_of(UserProjectAccessChangedService, [user.id]) do |service|
- expect(service).to receive(:execute)
- end
+ it 'calls AuthorizedProjectUpdate::UserRefreshFromReplicaWorker with a delay to update project authorizations' do
+ expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to(
+ receive(:bulk_perform_in)
+ .with(1.hour,
+ [[user.id]],
+ batch_delay: 30.seconds, batch_size: 100)
+ )
- subject.execute(group_link)
- end
+ subject.execute(group_link)
+ end
- it 'updates project authorizations of users who had access to the project via the group share' do
- expect { subject.execute(group_link) }.to(
- change { Ability.allowed?(user, :read_project, project) }
- .from(true).to(false))
- end
+ it 'updates project authorizations of users who had access to the project via the group share', :sidekiq_inline do
+ expect { subject.execute(group_link) }.to(
+ change { Ability.allowed?(user, :read_project, project) }
+ .from(true).to(false))
end
end
diff --git a/spec/services/projects/open_issues_count_service_spec.rb b/spec/services/projects/open_issues_count_service_spec.rb
index c739fea5ecf..8710d0c0267 100644
--- a/spec/services/projects/open_issues_count_service_spec.rb
+++ b/spec/services/projects/open_issues_count_service_spec.rb
@@ -4,89 +4,102 @@ require 'spec_helper'
RSpec.describe Projects::OpenIssuesCountService, :use_clean_rails_memory_store_caching do
let(:project) { create(:project) }
+ let(:user) { create(:user) }
+ let(:banned_user) { create(:user, :banned) }
- subject { described_class.new(project) }
+ subject { described_class.new(project, user) }
it_behaves_like 'a counter caching service'
+ before do
+ create(:issue, :opened, project: project)
+ create(:issue, :opened, confidential: true, project: project)
+ create(:issue, :opened, author: banned_user, project: project)
+ create(:issue, :closed, project: project)
+
+ described_class.new(project).refresh_cache
+ end
+
describe '#count' do
- context 'when user is nil' do
- it 'does not include confidential issues in the issue count' do
- create(:issue, :opened, project: project)
- create(:issue, :opened, confidential: true, project: project)
+ shared_examples 'counts public issues, does not count hidden or confidential' do
+ it 'counts only public issues' do
+ expect(subject.count).to eq(1)
+ end
- expect(described_class.new(project).count).to eq(1)
+ it 'uses PUBLIC_COUNT_WITHOUT_HIDDEN_KEY cache key' do
+ expect(subject.cache_key).to include('project_open_public_issues_without_hidden_count')
end
end
- context 'when user is provided' do
- let(:user) { create(:user) }
+ context 'when user is nil' do
+ let(:user) { nil }
+
+ it_behaves_like 'counts public issues, does not count hidden or confidential'
+ end
+ context 'when user is provided' do
context 'when user can read confidential issues' do
before do
project.add_reporter(user)
end
- it 'returns the right count with confidential issues' do
- create(:issue, :opened, project: project)
- create(:issue, :opened, confidential: true, project: project)
-
- expect(described_class.new(project, user).count).to eq(2)
+ it 'includes confidential issues and does not include hidden issues in count' do
+ expect(subject.count).to eq(2)
end
- it 'uses total_open_issues_count cache key' do
- expect(described_class.new(project, user).cache_key_name).to eq('total_open_issues_count')
+ it 'uses TOTAL_COUNT_WITHOUT_HIDDEN_KEY cache key' do
+ expect(subject.cache_key).to include('project_open_issues_without_hidden_count')
end
end
- context 'when user cannot read confidential issues' do
+ context 'when user cannot read confidential or hidden issues' do
before do
project.add_guest(user)
end
- it 'does not include confidential issues' do
- create(:issue, :opened, project: project)
- create(:issue, :opened, confidential: true, project: project)
+ it_behaves_like 'counts public issues, does not count hidden or confidential'
+ end
+
+ context 'when user is an admin' do
+ let_it_be(:user) { create(:user, :admin) }
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'includes confidential and hidden issues in count' do
+ expect(subject.count).to eq(3)
+ end
- expect(described_class.new(project, user).count).to eq(1)
+ it 'uses TOTAL_COUNT_KEY cache key' do
+ expect(subject.cache_key).to include('project_open_issues_including_hidden_count')
+ end
end
- it 'uses public_open_issues_count cache key' do
- expect(described_class.new(project, user).cache_key_name).to eq('public_open_issues_count')
+ context 'when admin mode is disabled' do
+ it_behaves_like 'counts public issues, does not count hidden or confidential'
end
end
end
+ end
- describe '#refresh_cache' do
- before do
- create(:issue, :opened, project: project)
- create(:issue, :opened, project: project)
- create(:issue, :opened, confidential: true, project: project)
- end
-
- context 'when cache is empty' do
- it 'refreshes cache keys correctly' do
- subject.refresh_cache
-
- expect(Rails.cache.read(subject.cache_key(described_class::PUBLIC_COUNT_KEY))).to eq(2)
- expect(Rails.cache.read(subject.cache_key(described_class::TOTAL_COUNT_KEY))).to eq(3)
- end
+ describe '#refresh_cache', :aggregate_failures do
+ context 'when cache is empty' do
+ it 'refreshes cache keys correctly' do
+ expect(Rails.cache.read(described_class.new(project).cache_key(described_class::PUBLIC_COUNT_WITHOUT_HIDDEN_KEY))).to eq(1)
+ expect(Rails.cache.read(described_class.new(project).cache_key(described_class::TOTAL_COUNT_WITHOUT_HIDDEN_KEY))).to eq(2)
+ expect(Rails.cache.read(described_class.new(project).cache_key(described_class::TOTAL_COUNT_KEY))).to eq(3)
end
+ end
- context 'when cache is outdated' do
- before do
- subject.refresh_cache
- end
-
- it 'refreshes cache keys correctly' do
- create(:issue, :opened, project: project)
- create(:issue, :opened, confidential: true, project: project)
+ context 'when cache is outdated' do
+ it 'refreshes cache keys correctly' do
+ create(:issue, :opened, project: project)
+ create(:issue, :opened, confidential: true, project: project)
+ create(:issue, :opened, author: banned_user, project: project)
- subject.refresh_cache
+ described_class.new(project).refresh_cache
- expect(Rails.cache.read(subject.cache_key(described_class::PUBLIC_COUNT_KEY))).to eq(3)
- expect(Rails.cache.read(subject.cache_key(described_class::TOTAL_COUNT_KEY))).to eq(5)
- end
+ expect(Rails.cache.read(described_class.new(project).cache_key(described_class::PUBLIC_COUNT_WITHOUT_HIDDEN_KEY))).to eq(2)
+ expect(Rails.cache.read(described_class.new(project).cache_key(described_class::TOTAL_COUNT_WITHOUT_HIDDEN_KEY))).to eq(4)
+ expect(Rails.cache.read(described_class.new(project).cache_key(described_class::TOTAL_COUNT_KEY))).to eq(6)
end
end
end
diff --git a/spec/services/projects/operations/update_service_spec.rb b/spec/services/projects/operations/update_service_spec.rb
index 1d9d5f6e938..a71fafb2121 100644
--- a/spec/services/projects/operations/update_service_spec.rb
+++ b/spec/services/projects/operations/update_service_spec.rb
@@ -153,6 +153,7 @@ RSpec.describe Projects::Operations::UpdateService do
{
error_tracking_setting_attributes: {
enabled: false,
+ integrated: true,
api_host: 'http://gitlab.com/',
token: 'token',
project: {
@@ -174,6 +175,7 @@ RSpec.describe Projects::Operations::UpdateService do
project.reload
expect(project.error_tracking_setting).not_to be_enabled
+ expect(project.error_tracking_setting.integrated).to be_truthy
expect(project.error_tracking_setting.api_url).to eq(
'http://gitlab.com/api/0/projects/org/project/'
)
@@ -206,6 +208,7 @@ RSpec.describe Projects::Operations::UpdateService do
{
error_tracking_setting_attributes: {
enabled: true,
+ integrated: true,
api_host: 'http://gitlab.com/',
token: 'token',
project: {
@@ -222,6 +225,7 @@ RSpec.describe Projects::Operations::UpdateService do
expect(result[:status]).to eq(:success)
expect(project.error_tracking_setting).to be_enabled
+ expect(project.error_tracking_setting.integrated).to be_truthy
expect(project.error_tracking_setting.api_url).to eq(
'http://gitlab.com/api/0/projects/org/project/'
)
diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb
index b71677a5e8f..d96573e26af 100644
--- a/spec/services/projects/transfer_service_spec.rb
+++ b/spec/services/projects/transfer_service_spec.rb
@@ -292,10 +292,37 @@ RSpec.describe Projects::TransferService do
end
end
- context 'target namespace allows developers to create projects' do
+ context 'target namespace matches current namespace' do
+ let(:group) { user.namespace }
+
+ it 'does not allow project transfer' do
+ transfer_result = execute_transfer
+
+ expect(transfer_result).to eq false
+ expect(project.namespace).to eq(user.namespace)
+ expect(project.errors[:new_namespace]).to include('Project is already in this namespace.')
+ end
+ end
+
+ context 'when user does not own the project' do
+ let(:project) { create(:project, :repository, :legacy_storage) }
+
+ before do
+ project.add_developer(user)
+ end
+
+ it 'does not allow project transfer to the target namespace' do
+ transfer_result = execute_transfer
+
+ expect(transfer_result).to eq false
+ expect(project.errors[:new_namespace]).to include("You don't have permission to transfer this project.")
+ end
+ end
+
+ context 'when user can create projects in the target namespace' do
let(:group) { create(:group, project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS) }
- context 'the user is a member of the target namespace with developer permissions' do
+ context 'but has only developer permissions in the target namespace' do
before do
group.add_developer(user)
end
@@ -305,7 +332,7 @@ RSpec.describe Projects::TransferService do
expect(transfer_result).to eq false
expect(project.namespace).to eq(user.namespace)
- expect(project.errors[:new_namespace]).to include('Transfer failed, please contact an admin.')
+ expect(project.errors[:new_namespace]).to include("You don't have permission to transfer projects into that namespace.")
end
end
end
diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb
index 0f21736eda0..6d0b75e0c95 100644
--- a/spec/services/projects/update_pages_service_spec.rb
+++ b/spec/services/projects/update_pages_service_spec.rb
@@ -18,17 +18,6 @@ RSpec.describe Projects::UpdatePagesService do
subject { described_class.new(project, build) }
- before do
- stub_feature_flags(skip_pages_deploy_to_legacy_storage: false)
- project.legacy_remove_pages
- end
-
- context '::TMP_EXTRACT_PATH' do
- subject { described_class::TMP_EXTRACT_PATH }
-
- it { is_expected.not_to match(Gitlab::PathRegex.namespace_format_regex) }
- end
-
context 'for new artifacts' do
context "for a valid job" do
let!(:artifacts_archive) { create(:ci_job_artifact, :correct_checksum, file: file, job: build) }
@@ -52,36 +41,6 @@ RSpec.describe Projects::UpdatePagesService do
expect(project.pages_metadatum).to be_deployed
expect(project.pages_metadatum.artifacts_archive).to eq(artifacts_archive)
expect(project.pages_deployed?).to be_truthy
-
- # Check that all expected files are extracted
- %w[index.html zero .hidden/file].each do |filename|
- expect(File.exist?(File.join(project.pages_path, 'public', filename))).to be_truthy
- end
- end
-
- it 'creates a temporary directory with the project and build ID' do
- expect(Dir).to receive(:mktmpdir).with("project-#{project.id}-build-#{build.id}-", anything).and_call_original
-
- subject.execute
- end
-
- it "doesn't deploy to legacy storage if it's disabled" do
- allow(Settings.pages.local_store).to receive(:enabled).and_return(false)
-
- expect(execute).to eq(:success)
- expect(project.pages_deployed?).to be_truthy
-
- expect(File.exist?(File.join(project.pages_path, 'public', 'index.html'))).to eq(false)
- end
-
- it "doesn't deploy to legacy storage if skip_pages_deploy_to_legacy_storage is enabled" do
- allow(Settings.pages.local_store).to receive(:enabled).and_return(true)
- stub_feature_flags(skip_pages_deploy_to_legacy_storage: true)
-
- expect(execute).to eq(:success)
- expect(project.pages_deployed?).to be_truthy
-
- expect(File.exist?(File.join(project.pages_path, 'public', 'index.html'))).to eq(false)
end
it 'creates pages_deployment and saves it in the metadata' do
@@ -99,16 +58,6 @@ RSpec.describe Projects::UpdatePagesService do
expect(deployment.ci_build_id).to eq(build.id)
end
- it 'fails if another deployment is in progress' do
- subject.try_obtain_lease do
- expect do
- execute
- end.to raise_error("Failed to deploy pages - other deployment is in progress")
-
- expect(GenericCommitStatus.last.description).to eq("Failed to deploy pages - other deployment is in progress")
- end
- end
-
it 'does not fail if pages_metadata is absent' do
project.pages_metadatum.destroy!
project.reload
@@ -156,47 +105,10 @@ RSpec.describe Projects::UpdatePagesService do
expect(GenericCommitStatus.last.description).to eq("pages site contains 3 file entries, while limit is set to 2")
end
- it 'removes pages after destroy' do
- expect(PagesWorker).to receive(:perform_in)
- expect(project.pages_deployed?).to be_falsey
- expect(Dir.exist?(File.join(project.pages_path))).to be_falsey
-
- expect(execute).to eq(:success)
-
- expect(project.pages_metadatum).to be_deployed
- expect(project.pages_deployed?).to be_truthy
- expect(Dir.exist?(File.join(project.pages_path))).to be_truthy
-
- project.destroy!
-
- expect(Dir.exist?(File.join(project.pages_path))).to be_falsey
- expect(ProjectPagesMetadatum.find_by_project_id(project)).to be_nil
- end
-
- context 'when using empty file' do
- let(:file) { empty_file }
-
- it 'fails to extract' do
- expect { execute }
- .to raise_error(Projects::UpdatePagesService::FailedToExtractError)
- end
- end
-
- context 'when using pages with non-writeable public' do
- let(:file) { fixture_file_upload("spec/fixtures/pages_non_writeable.zip") }
-
- context 'when using RubyZip' do
- it 'succeeds to extract' do
- expect(execute).to eq(:success)
- expect(project.pages_metadatum).to be_deployed
- end
- end
- end
-
context 'when timeout happens by DNS error' do
before do
allow_next_instance_of(described_class) do |instance|
- allow(instance).to receive(:extract_zip_archive!).and_raise(SocketError)
+ allow(instance).to receive(:create_pages_deployment).and_raise(SocketError)
end
end
@@ -209,24 +121,6 @@ RSpec.describe Projects::UpdatePagesService do
end
end
- context 'when failed to extract zip artifacts' do
- before do
- expect_next_instance_of(described_class) do |instance|
- expect(instance).to receive(:extract_zip_archive!)
- .and_raise(Projects::UpdatePagesService::FailedToExtractError)
- end
- end
-
- it 'raises an error' do
- expect { execute }
- .to raise_error(Projects::UpdatePagesService::FailedToExtractError)
-
- build.reload
- expect(deploy_status).to be_failed
- expect(project.pages_metadatum).not_to be_deployed
- end
- end
-
context 'when missing artifacts metadata' do
before do
expect(build).to receive(:artifacts_metadata?).and_return(false)
@@ -338,12 +232,6 @@ RSpec.describe Projects::UpdatePagesService do
end
end
- it 'fails to remove project pages when no pages is deployed' do
- expect(PagesWorker).not_to receive(:perform_in)
- expect(project.pages_deployed?).to be_falsey
- project.destroy!
- end
-
it 'fails if no artifacts' do
expect(execute).not_to eq(:success)
end
@@ -384,38 +272,6 @@ RSpec.describe Projects::UpdatePagesService do
end
end
- context 'when file size is spoofed' do
- let(:metadata) { spy('metadata') }
-
- include_context 'pages zip with spoofed size'
-
- before do
- file = fixture_file_upload(fake_zip_path, 'pages.zip')
- metafile = fixture_file_upload('spec/fixtures/pages.zip.meta')
-
- create(:ci_job_artifact, :archive, file: file, job: build)
- create(:ci_job_artifact, :metadata, file: metafile, job: build)
-
- allow(build).to receive(:artifacts_metadata_entry).with('public/', recursive: true)
- .and_return(metadata)
- allow(metadata).to receive(:total_size).and_return(100)
-
- # to pass entries count check
- root_metadata = double('root metadata')
- allow(build).to receive(:artifacts_metadata_entry).with('', recursive: true)
- .and_return(root_metadata)
- allow(root_metadata).to receive_message_chain(:entries, :count).and_return(10)
- end
-
- it 'raises an error' do
- expect do
- subject.execute
- end.to raise_error(Projects::UpdatePagesService::FailedToExtractError,
- 'Entry public/index.html should be 1B but is larger when inflated')
- expect(deploy_status).to be_script_failure
- end
- end
-
context 'when retrying the job' do
let!(:older_deploy_job) do
create(:generic_commit_status, :failed, pipeline: pipeline,
@@ -435,18 +291,6 @@ RSpec.describe Projects::UpdatePagesService do
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
diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb
index c74a8295d0a..115f3098185 100644
--- a/spec/services/projects/update_service_spec.rb
+++ b/spec/services/projects/update_service_spec.rb
@@ -441,6 +441,30 @@ RSpec.describe Projects::UpdateService do
end
end
+ context 'when updating #shared_runners', :https_pages_enabled do
+ let!(:pending_build) { create(:ci_pending_build, project: project, instance_runners_enabled: true) }
+
+ subject(:call_service) do
+ update_project(project, admin, shared_runners_enabled: shared_runners_enabled)
+ end
+
+ context 'when shared runners is toggled' do
+ let(:shared_runners_enabled) { false }
+
+ it 'updates ci pending builds' do
+ expect { call_service }.to change { pending_build.reload.instance_runners_enabled }.to(false)
+ end
+ end
+
+ context 'when shared runners is not toggled' do
+ let(:shared_runners_enabled) { true }
+
+ it 'updates ci pending builds' do
+ expect { call_service }.to not_change { pending_build.reload.instance_runners_enabled }
+ end
+ end
+ end
+
context 'with external authorization enabled' do
before do
enable_external_authorization_service_check
diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb
index a1b726071d6..02997096021 100644
--- a/spec/services/quick_actions/interpret_service_spec.rb
+++ b/spec/services/quick_actions/interpret_service_spec.rb
@@ -624,6 +624,18 @@ RSpec.describe QuickActions::InterpretService do
end
end
+ shared_examples 'approve command unavailable' do
+ it 'is not part of the available commands' do
+ expect(service.available_commands(issuable)).not_to include(a_hash_including(name: :approve))
+ end
+ end
+
+ shared_examples 'unapprove command unavailable' do
+ it 'is not part of the available commands' do
+ expect(service.available_commands(issuable)).not_to include(a_hash_including(name: :unapprove))
+ end
+ end
+
shared_examples 'shrug command' do
it 'appends ¯\_(ツ)_/¯ to the comment' do
new_content, _, _ = service.execute(content, issuable)
@@ -2135,6 +2147,66 @@ RSpec.describe QuickActions::InterpretService do
end
end
end
+
+ context 'approve command' do
+ let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:content) { '/approve' }
+
+ it 'approves the current merge request' do
+ service.execute(content, merge_request)
+
+ expect(merge_request.approved_by_users).to eq([developer])
+ end
+
+ context "when the user can't approve" do
+ before do
+ project.team.truncate
+ project.add_guest(developer)
+ end
+
+ it 'does not approve the MR' do
+ service.execute(content, merge_request)
+
+ expect(merge_request.approved_by_users).to be_empty
+ end
+ end
+
+ it_behaves_like 'approve command unavailable' do
+ let(:issuable) { issue }
+ end
+ end
+
+ context 'unapprove command' do
+ let!(:merge_request) { create(:merge_request, source_project: project) }
+ let(:content) { '/unapprove' }
+
+ before do
+ service.execute('/approve', merge_request)
+ end
+
+ it 'unapproves the current merge request' do
+ service.execute(content, merge_request)
+
+ expect(merge_request.approved_by_users).to be_empty
+ end
+
+ context "when the user can't unapprove" do
+ before do
+ project.team.truncate
+ project.add_guest(developer)
+ end
+
+ it 'does not unapprove the MR' do
+ service.execute(content, merge_request)
+
+ expect(merge_request.approved_by_users).to eq([developer])
+ end
+
+ it_behaves_like 'unapprove command unavailable' do
+ let(:issuable) { issue }
+ 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 02d60f076ca..b547ae17317 100644
--- a/spec/services/repositories/changelog_service_spec.rb
+++ b/spec/services/repositories/changelog_service_spec.rb
@@ -76,7 +76,7 @@ RSpec.describe Repositories::ChangelogService do
recorder = ActiveRecord::QueryRecorder.new { service.execute }
changelog = project.repository.blob_at('master', 'CHANGELOG.md')&.data
- expect(recorder.count).to eq(11)
+ expect(recorder.count).to eq(9)
expect(changelog).to include('Title 1', 'Title 2')
end
diff --git a/spec/services/service_ping/submit_service_ping_service_spec.rb b/spec/services/service_ping/submit_service_ping_service_spec.rb
index 05df4e49014..c2fe565938a 100644
--- a/spec/services/service_ping/submit_service_ping_service_spec.rb
+++ b/spec/services/service_ping/submit_service_ping_service_spec.rb
@@ -300,8 +300,32 @@ RSpec.describe ServicePing::SubmitService do
end
end
- def stub_response(body:, status: 201)
- stub_full_request(subject.send(:url), method: :post)
+ describe '#url' do
+ let(:url) { subject.url.to_s }
+
+ context 'when Rails.env is production' do
+ before do
+ stub_rails_env('production')
+ end
+
+ it 'points to the production Version app' do
+ expect(url).to eq("#{described_class::PRODUCTION_BASE_URL}/#{described_class::USAGE_DATA_PATH}")
+ end
+ end
+
+ context 'when Rails.env is not production' do
+ before do
+ stub_rails_env('development')
+ end
+
+ it 'points to the staging Version app' do
+ expect(url).to eq("#{described_class::STAGING_BASE_URL}/#{described_class::USAGE_DATA_PATH}")
+ end
+ end
+ end
+
+ def stub_response(url: subject.url, body:, status: 201)
+ stub_full_request(url, method: :post)
.to_return(
headers: { 'Content-Type' => 'application/json' },
body: body.to_json,
diff --git a/spec/services/suggestions/apply_service_spec.rb b/spec/services/suggestions/apply_service_spec.rb
index 9cf794cde7e..dc330a5546f 100644
--- a/spec/services/suggestions/apply_service_spec.rb
+++ b/spec/services/suggestions/apply_service_spec.rb
@@ -70,7 +70,7 @@ RSpec.describe Suggestions::ApplyService do
author = suggestions.first.note.author
expect(user.commit_email).not_to eq(user.email)
- expect(commit.author_email).to eq(author.commit_email)
+ expect(commit.author_email).to eq(author.commit_email_or_default)
expect(commit.committer_email).to eq(user.commit_email)
expect(commit.author_name).to eq(author.name)
expect(commit.committer_name).to eq(user.name)
@@ -79,7 +79,7 @@ RSpec.describe Suggestions::ApplyService do
it 'tracks apply suggestion event' do
expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
.to receive(:track_apply_suggestion_action)
- .with(user: user)
+ .with(user: user, suggestions: suggestions)
apply(suggestions)
end
@@ -333,9 +333,9 @@ RSpec.describe Suggestions::ApplyService do
end
it 'created commit by same author and committer' do
- expect(user.commit_email).to eq(author.commit_email)
+ expect(user.commit_email).to eq(author.commit_email_or_default)
expect(author).to eq(user)
- expect(commit.author_email).to eq(author.commit_email)
+ expect(commit.author_email).to eq(author.commit_email_or_default)
expect(commit.committer_email).to eq(user.commit_email)
expect(commit.author_name).to eq(author.name)
expect(commit.committer_name).to eq(user.name)
@@ -350,7 +350,7 @@ RSpec.describe Suggestions::ApplyService do
it 'created commit has authors info and commiters info' do
expect(user.commit_email).not_to eq(user.email)
expect(author).not_to eq(user)
- expect(commit.author_email).to eq(author.commit_email)
+ expect(commit.author_email).to eq(author.commit_email_or_default)
expect(commit.committer_email).to eq(user.commit_email)
expect(commit.author_name).to eq(author.name)
expect(commit.committer_name).to eq(user.name)
@@ -359,7 +359,7 @@ RSpec.describe Suggestions::ApplyService do
end
context 'multiple suggestions' do
- let(:author_emails) { suggestions.map {|s| s.note.author.commit_email } }
+ let(:author_emails) { suggestions.map {|s| s.note.author.commit_email_or_default } }
let(:first_author) { suggestion.note.author }
let(:commit) { project.repository.commit }
@@ -369,8 +369,8 @@ RSpec.describe Suggestions::ApplyService do
end
it 'uses first authors information' do
- expect(author_emails).to include(first_author.commit_email).exactly(3)
- expect(commit.author_email).to eq(first_author.commit_email)
+ expect(author_emails).to include(first_author.commit_email_or_default).exactly(3)
+ expect(commit.author_email).to eq(first_author.commit_email_or_default)
end
end
diff --git a/spec/services/suggestions/create_service_spec.rb b/spec/services/suggestions/create_service_spec.rb
index 5148d6756fc..a4e62431128 100644
--- a/spec/services/suggestions/create_service_spec.rb
+++ b/spec/services/suggestions/create_service_spec.rb
@@ -159,7 +159,7 @@ RSpec.describe Suggestions::CreateService do
it 'tracks add suggestion event' do
expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
.to receive(:track_add_suggestion_action)
- .with(user: note.author)
+ .with(note: note)
subject.execute
end
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index 5aff5149dcf..1a421999ffb 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -793,4 +793,16 @@ RSpec.describe SystemNoteService do
described_class.log_resolving_alert(alert, monitoring_tool)
end
end
+
+ describe '.change_issue_type' do
+ let(:incident) { build(:incident) }
+
+ it 'calls IssuableService' do
+ expect_next_instance_of(::SystemNotes::IssuablesService) do |service|
+ expect(service).to receive(:change_issue_type)
+ end
+
+ described_class.change_issue_type(incident, author)
+ end
+ end
end
diff --git a/spec/services/system_notes/issuables_service_spec.rb b/spec/services/system_notes/issuables_service_spec.rb
index 1ea3c241d27..71a28a89cd8 100644
--- a/spec/services/system_notes/issuables_service_spec.rb
+++ b/spec/services/system_notes/issuables_service_spec.rb
@@ -773,4 +773,16 @@ RSpec.describe ::SystemNotes::IssuablesService do
expect(event.state).to eq('closed')
end
end
+
+ describe '#change_issue_type' do
+ let(:noteable) { create(:incident, project: project) }
+
+ subject { service.change_issue_type }
+
+ it_behaves_like 'a system note' do
+ let(:action) { 'issue_type' }
+ end
+
+ it { expect(subject.note).to eq "changed issue type to incident" }
+ end
end
diff --git a/spec/services/todos/destroy/design_service_spec.rb b/spec/services/todos/destroy/design_service_spec.rb
new file mode 100644
index 00000000000..61a6718dc9d
--- /dev/null
+++ b/spec/services/todos/destroy/design_service_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Todos::Destroy::DesignService do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:user_2) { create(:user) }
+ let_it_be(:design) { create(:design) }
+ let_it_be(:design_2) { create(:design) }
+ let_it_be(:design_3) { create(:design) }
+
+ let_it_be(:create_action) { create(:design_action, design: design)}
+ let_it_be(:create_action_2) { create(:design_action, design: design_2)}
+
+ describe '#execute' do
+ before do
+ create(:todo, user: user, target: design)
+ create(:todo, user: user_2, target: design)
+ create(:todo, user: user, target: design_2)
+ create(:todo, user: user, target: design_3)
+ end
+
+ subject { described_class.new([design.id, design_2.id, design_3.id]).execute }
+
+ context 'when the design has been archived' do
+ let_it_be(:archive_action) { create(:design_action, design: design, event: :deletion)}
+ let_it_be(:archive_action_2) { create(:design_action, design: design_3, event: :deletion)}
+
+ it 'removes todos for that design' do
+ expect { subject }.to change { Todo.count }.from(4).to(1)
+ end
+ end
+
+ context 'when no design has been archived' do
+ it 'does not remove any todos' do
+ expect { subject }.not_to change { Todo.count }.from(4)
+ end
+ end
+ end
+end
diff --git a/spec/services/users/ban_service_spec.rb b/spec/services/users/ban_service_spec.rb
index 6f49ee08782..79f3cbeb46d 100644
--- a/spec/services/users/ban_service_spec.rb
+++ b/spec/services/users/ban_service_spec.rb
@@ -50,7 +50,7 @@ RSpec.describe Users::BanService do
response = ban_user
expect(response[:status]).to eq(:error)
- expect(response[:message]).to match(/State cannot transition/)
+ expect(response[:message]).to match('You cannot ban blocked users.')
end
it_behaves_like 'does not modify the BannedUser record or user state'
diff --git a/spec/services/users/dismiss_group_callout_service_spec.rb b/spec/services/users/dismiss_group_callout_service_spec.rb
new file mode 100644
index 00000000000..d74602a7606
--- /dev/null
+++ b/spec/services/users/dismiss_group_callout_service_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Users::DismissGroupCalloutService do
+ describe '#execute' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+
+ let(:params) { { feature_name: feature_name, group_id: group.id } }
+ let(:feature_name) { Users::GroupCallout.feature_names.each_key.first }
+
+ subject(:execute) do
+ described_class.new(
+ container: nil, current_user: user, params: params
+ ).execute
+ end
+
+ it_behaves_like 'dismissing user callout', Users::GroupCallout
+
+ it 'sets the group_id' do
+ expect(execute.group_id).to eq(group.id)
+ end
+ end
+end
diff --git a/spec/services/users/dismiss_user_callout_service_spec.rb b/spec/services/users/dismiss_user_callout_service_spec.rb
index 22f84a939f7..6bf9961eb74 100644
--- a/spec/services/users/dismiss_user_callout_service_spec.rb
+++ b/spec/services/users/dismiss_user_callout_service_spec.rb
@@ -3,25 +3,18 @@
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 }
+ let_it_be(:user) { create(:user) }
- it 'returns a user callout' do
- expect(execute).to be_an_instance_of(UserCallout)
- end
+ let(:params) { { feature_name: feature_name } }
+ let(:feature_name) { UserCallout.feature_names.each_key.first }
- it 'sets the dismisse_at attribute to current time' do
- freeze_time do
- expect(execute).to have_attributes(dismissed_at: Time.current)
- end
+ subject(:execute) do
+ described_class.new(
+ container: nil, current_user: user, params: params
+ ).execute
end
+
+ it_behaves_like 'dismissing user callout', UserCallout
end
end
diff --git a/spec/services/users/migrate_to_ghost_user_service_spec.rb b/spec/services/users/migrate_to_ghost_user_service_spec.rb
index c9c8f9a74d3..c36889f20ec 100644
--- a/spec/services/users/migrate_to_ghost_user_service_spec.rb
+++ b/spec/services/users/migrate_to_ghost_user_service_spec.rb
@@ -92,23 +92,5 @@ RSpec.describe Users::MigrateToGhostUserService do
let(:created_record) { create(:review, author: user) }
end
end
-
- context "when record migration fails with a rollback exception" do
- before do
- expect_any_instance_of(ActiveRecord::Associations::CollectionProxy)
- .to receive(:update_all).and_raise(ActiveRecord::Rollback)
- end
-
- context "for records that were already migrated" do
- let!(:issue) { create(:issue, project: project, author: user) }
- let!(:merge_request) { create(:merge_request, source_project: project, author: user, target_branch: "first") }
-
- it "reverses the migration" do
- service.execute
-
- expect(issue.reload.author).to eq(user)
- end
- end
- end
end
end
diff --git a/spec/services/users/reject_service_spec.rb b/spec/services/users/reject_service_spec.rb
index b0094a7c47e..5a243e876ac 100644
--- a/spec/services/users/reject_service_spec.rb
+++ b/spec/services/users/reject_service_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe Users::RejectService do
it 'returns error result' do
expect(subject[:status]).to eq(:error)
expect(subject[:message])
- .to match(/This user does not have a pending request/)
+ .to match(/User does not have a pending request/)
end
end
end
@@ -44,7 +44,7 @@ RSpec.describe Users::RejectService do
it 'emails the user on rejection' do
expect_next_instance_of(NotificationService) do |notification|
- allow(notification).to receive(:user_admin_rejection).with(user.name, user.notification_email)
+ allow(notification).to receive(:user_admin_rejection).with(user.name, user.notification_email_or_default)
end
subject
diff --git a/spec/services/users/unban_service_spec.rb b/spec/services/users/unban_service_spec.rb
index b2b3140ccb3..d536baafdcc 100644
--- a/spec/services/users/unban_service_spec.rb
+++ b/spec/services/users/unban_service_spec.rb
@@ -50,7 +50,7 @@ RSpec.describe Users::UnbanService do
response = unban_user
expect(response[:status]).to eq(:error)
- expect(response[:message]).to match(/State cannot transition/)
+ expect(response[:message]).to match('You cannot unban active users.')
end
it_behaves_like 'does not modify the BannedUser record or user state'
diff --git a/spec/services/wiki_pages/event_create_service_spec.rb b/spec/services/wiki_pages/event_create_service_spec.rb
index 6bc6a678189..8476f872e98 100644
--- a/spec/services/wiki_pages/event_create_service_spec.rb
+++ b/spec/services/wiki_pages/event_create_service_spec.rb
@@ -34,10 +34,6 @@ RSpec.describe WikiPages::EventCreateService do
it 'does not create an event' do
expect { response }.not_to change(Event, :count)
end
-
- it 'does not create a metadata record' do
- expect { response }.not_to change(WikiPage::Meta, :count)
- end
end
it 'returns a successful response' do
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index b95b7fad5a0..aa791d1d2e7 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -160,11 +160,11 @@ RSpec.configure do |config|
config.include GitlabRoutingHelper
config.include StubExperiments
config.include StubGitlabCalls
- config.include StubGitlabData
config.include NextFoundInstanceOf
config.include NextInstanceOf
config.include TestEnv
config.include FileReadHelpers
+ config.include Database::MultipleDatabases
config.include Devise::Test::ControllerHelpers, type: :controller
config.include Devise::Test::ControllerHelpers, type: :view
config.include Devise::Test::IntegrationHelpers, type: :feature
@@ -221,6 +221,8 @@ RSpec.configure do |config|
# Enable all features by default for testing
# Reset any changes in after hook.
stub_all_feature_flags
+
+ TestEnv.seed_db
end
config.after(:all) do
@@ -260,6 +262,9 @@ RSpec.configure do |config|
# tests, until we introduce it in user settings
stub_feature_flags(forti_token_cloud: false)
+ # Disable for now whilst we add more states
+ stub_feature_flags(restructured_mr_widget: false)
+
# These feature flag are by default disabled and used in disaster recovery mode
stub_feature_flags(ci_queueing_disaster_recovery_disable_fair_scheduling: false)
stub_feature_flags(ci_queueing_disaster_recovery_disable_quota: false)
@@ -301,6 +306,15 @@ RSpec.configure do |config|
# For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321
stub_feature_flags(block_issue_repositioning: false)
+ # These are ops feature flags that are disabled by default
+ stub_feature_flags(disable_anonymous_search: false)
+ stub_feature_flags(disable_anonymous_project_search: false)
+
+ # Disable the refactored top nav search until there is functionality
+ # Can be removed once all existing functionality has been replicated
+ # For more information check https://gitlab.com/gitlab-org/gitlab/-/issues/339348
+ stub_feature_flags(new_header_search: false)
+
allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged)
else
unstub_all_feature_flags
diff --git a/spec/support/database/ci_tables.rb b/spec/support/database/ci_tables.rb
deleted file mode 100644
index 99fc7ac2501..00000000000
--- a/spec/support/database/ci_tables.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-# This module stores the CI-related database tables which are
-# going to be moved to a separate database.
-module Database
- module CiTables
- def self.include?(name)
- ci_tables.include?(name)
- end
-
- def self.ci_tables
- @@ci_tables ||= Set.new.tap do |tables| # rubocop:disable Style/ClassVars
- tables.merge(Ci::ApplicationRecord.descendants.map(&:table_name).compact)
-
- # It was decided that taggings/tags are best placed with CI
- # https://gitlab.com/gitlab-org/gitlab/-/issues/333413
- tables.add('taggings')
- tables.add('tags')
- end
- end
- end
-end
diff --git a/spec/support/database/cross-join-allowlist.yml b/spec/support/database/cross-join-allowlist.yml
new file mode 100644
index 00000000000..2b4cfc6773a
--- /dev/null
+++ b/spec/support/database/cross-join-allowlist.yml
@@ -0,0 +1,196 @@
+- "./ee/spec/controllers/operations_controller_spec.rb"
+- "./ee/spec/controllers/projects/issues_controller_spec.rb"
+- "./ee/spec/controllers/projects/security/vulnerabilities_controller_spec.rb"
+- "./ee/spec/features/ci/ci_minutes_spec.rb"
+- "./ee/spec/features/merge_request/user_merges_immediately_spec.rb"
+- "./ee/spec/features/merge_request/user_sees_merge_widget_spec.rb"
+- "./ee/spec/features/merge_trains/two_merge_requests_on_train_spec.rb"
+- "./ee/spec/features/merge_trains/user_adds_merge_request_to_merge_train_spec.rb"
+- "./ee/spec/features/merge_trains/user_adds_to_merge_train_when_pipeline_succeeds_spec.rb"
+- "./ee/spec/features/projects/pipelines/pipeline_spec.rb"
+- "./ee/spec/features/projects/settings/auto_rollback_spec.rb"
+- "./ee/spec/features/projects/settings/pipeline_subscriptions_spec.rb"
+- "./ee/spec/features/projects/settings/protected_environments_spec.rb"
+- "./ee/spec/finders/ee/namespaces/projects_finder_spec.rb"
+- "./ee/spec/finders/group_projects_finder_spec.rb"
+- "./ee/spec/finders/security/findings_finder_spec.rb"
+- "./ee/spec/graphql/ee/resolvers/namespace_projects_resolver_spec.rb"
+- "./ee/spec/lib/analytics/devops_adoption/snapshot_calculator_spec.rb"
+- "./ee/spec/lib/ee/gitlab/background_migration/migrate_approver_to_approval_rules_spec.rb"
+- "./ee/spec/lib/ee/gitlab/background_migration/migrate_security_scans_spec.rb"
+- "./ee/spec/lib/ee/gitlab/background_migration/populate_latest_pipeline_ids_spec.rb"
+- "./ee/spec/lib/ee/gitlab/background_migration/populate_resolved_on_default_branch_column_spec.rb"
+- "./ee/spec/lib/ee/gitlab/background_migration/populate_uuids_for_security_findings_spec.rb"
+- "./ee/spec/lib/ee/gitlab/background_migration/populate_vulnerability_feedback_pipeline_id_spec.rb"
+- "./ee/spec/lib/ee/gitlab/usage_data_spec.rb"
+- "./ee/spec/migrations/schedule_populate_resolved_on_default_branch_column_spec.rb"
+- "./ee/spec/models/ci/build_spec.rb"
+- "./ee/spec/models/ci/minutes/project_monthly_usage_spec.rb"
+- "./ee/spec/models/ci/pipeline_spec.rb"
+- "./ee/spec/models/ee/vulnerability_spec.rb"
+- "./ee/spec/models/merge_request_spec.rb"
+- "./ee/spec/models/project_spec.rb"
+- "./ee/spec/models/security/finding_spec.rb"
+- "./ee/spec/models/security/scan_spec.rb"
+- "./ee/spec/presenters/ci/pipeline_presenter_spec.rb"
+- "./ee/spec/requests/api/ci/minutes_spec.rb"
+- "./ee/spec/requests/api/graphql/ci/minutes/usage_spec.rb"
+- "./ee/spec/requests/api/graphql/mutations/environments/canary_ingress/update_spec.rb"
+- "./ee/spec/requests/api/graphql/mutations/vulnerabilities/create_external_issue_link_spec.rb"
+- "./ee/spec/requests/api/graphql/project/pipeline/security_report_summary_spec.rb"
+- "./ee/spec/requests/api/graphql/vulnerabilities/location_spec.rb"
+- "./ee/spec/requests/api/groups_spec.rb"
+- "./ee/spec/requests/api/namespaces_spec.rb"
+- "./ee/spec/requests/api/vulnerability_findings_spec.rb"
+- "./ee/spec/serializers/dashboard_environment_entity_spec.rb"
+- "./ee/spec/serializers/dashboard_environments_serializer_spec.rb"
+- "./ee/spec/services/auto_merge/add_to_merge_train_when_pipeline_succeeds_service_spec.rb"
+- "./ee/spec/services/ci/create_pipeline_service/runnable_builds_spec.rb"
+- "./ee/spec/services/ci/minutes/additional_packs/change_namespace_service_spec.rb"
+- "./ee/spec/services/ci/minutes/additional_packs/create_service_spec.rb"
+- "./ee/spec/services/ci/minutes/refresh_cached_data_service_spec.rb"
+- "./ee/spec/services/ci/process_pipeline_service_spec.rb"
+- "./ee/spec/services/ci/trigger_downstream_subscription_service_spec.rb"
+- "./ee/spec/services/clear_namespace_shared_runners_minutes_service_spec.rb"
+- "./ee/spec/services/deployments/auto_rollback_service_spec.rb"
+- "./ee/spec/services/ee/ci/job_artifacts/destroy_all_expired_service_spec.rb"
+- "./ee/spec/services/ee/ci/job_artifacts/destroy_batch_service_spec.rb"
+- "./ee/spec/services/ee/issues/build_from_vulnerability_service_spec.rb"
+- "./ee/spec/services/ee/merge_requests/create_pipeline_service_spec.rb"
+- "./ee/spec/services/ee/merge_requests/refresh_service_spec.rb"
+- "./ee/spec/services/security/report_summary_service_spec.rb"
+- "./ee/spec/services/security/vulnerability_counting_service_spec.rb"
+- "./ee/spec/workers/scan_security_report_secrets_worker_spec.rb"
+- "./ee/spec/workers/security/store_scans_worker_spec.rb"
+- "./spec/controllers/admin/runners_controller_spec.rb"
+- "./spec/controllers/groups/runners_controller_spec.rb"
+- "./spec/controllers/groups/settings/ci_cd_controller_spec.rb"
+- "./spec/controllers/projects/logs_controller_spec.rb"
+- "./spec/controllers/projects/merge_requests_controller_spec.rb"
+- "./spec/controllers/projects/runners_controller_spec.rb"
+- "./spec/controllers/projects/serverless/functions_controller_spec.rb"
+- "./spec/controllers/projects/settings/ci_cd_controller_spec.rb"
+- "./spec/features/admin/admin_runners_spec.rb"
+- "./spec/features/groups/settings/ci_cd_spec.rb"
+- "./spec/features/ide/user_opens_merge_request_spec.rb"
+- "./spec/features/merge_request/user_merges_immediately_spec.rb"
+- "./spec/features/merge_request/user_merges_only_if_pipeline_succeeds_spec.rb"
+- "./spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb"
+- "./spec/features/merge_request/user_resolves_wip_mr_spec.rb"
+- "./spec/features/merge_request/user_sees_deployment_widget_spec.rb"
+- "./spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb"
+- "./spec/features/merge_request/user_sees_merge_widget_spec.rb"
+- "./spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb"
+- "./spec/features/merge_request/user_sees_pipelines_from_forked_project_spec.rb"
+- "./spec/features/merge_request/user_sees_pipelines_spec.rb"
+- "./spec/features/project_group_variables_spec.rb"
+- "./spec/features/project_variables_spec.rb"
+- "./spec/features/projects/badges/list_spec.rb"
+- "./spec/features/projects/environments_pod_logs_spec.rb"
+- "./spec/features/projects/infrastructure_registry_spec.rb"
+- "./spec/features/projects/jobs_spec.rb"
+- "./spec/features/projects/package_files_spec.rb"
+- "./spec/features/projects/pipelines/pipeline_spec.rb"
+- "./spec/features/projects/pipelines/pipelines_spec.rb"
+- "./spec/features/projects/serverless/functions_spec.rb"
+- "./spec/features/projects/settings/pipelines_settings_spec.rb"
+- "./spec/features/runners_spec.rb"
+- "./spec/features/security/project/internal_access_spec.rb"
+- "./spec/features/security/project/private_access_spec.rb"
+- "./spec/features/security/project/public_access_spec.rb"
+- "./spec/features/triggers_spec.rb"
+- "./spec/finders/ci/pipelines_finder_spec.rb"
+- "./spec/finders/ci/pipelines_for_merge_request_finder_spec.rb"
+- "./spec/finders/ci/runners_finder_spec.rb"
+- "./spec/finders/clusters/knative_services_finder_spec.rb"
+- "./spec/finders/projects/serverless/functions_finder_spec.rb"
+- "./spec/frontend/fixtures/runner.rb"
+- "./spec/graphql/mutations/ci/runner/delete_spec.rb"
+- "./spec/graphql/resolvers/ci/group_runners_resolver_spec.rb"
+- "./spec/graphql/resolvers/ci/job_token_scope_resolver_spec.rb"
+- "./spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb"
+- "./spec/graphql/types/ci/job_token_scope_type_spec.rb"
+- "./spec/helpers/packages_helper_spec.rb"
+- "./spec/lib/api/entities/package_spec.rb"
+- "./spec/lib/gitlab/background_migration/migrate_legacy_artifacts_spec.rb"
+- "./spec/lib/gitlab/prometheus/query_variables_spec.rb"
+- "./spec/mailers/emails/pipelines_spec.rb"
+- "./spec/migrations/cleanup_legacy_artifact_migration_spec.rb"
+- "./spec/migrations/migrate_protected_attribute_to_pending_builds_spec.rb"
+- "./spec/migrations/re_schedule_latest_pipeline_id_population_with_all_security_related_artifact_types_spec.rb"
+- "./spec/migrations/schedule_migrate_security_scans_spec.rb"
+- "./spec/models/ci/build_spec.rb"
+- "./spec/models/ci/job_artifact_spec.rb"
+- "./spec/models/ci/job_token/scope_spec.rb"
+- "./spec/models/ci/pipeline_spec.rb"
+- "./spec/models/ci/runner_spec.rb"
+- "./spec/models/clusters/applications/runner_spec.rb"
+- "./spec/models/deployment_spec.rb"
+- "./spec/models/environment_spec.rb"
+- "./spec/models/merge_request_spec.rb"
+- "./spec/models/project_spec.rb"
+- "./spec/models/user_spec.rb"
+- "./spec/presenters/ci/build_runner_presenter_spec.rb"
+- "./spec/presenters/ci/pipeline_presenter_spec.rb"
+- "./spec/presenters/packages/detail/package_presenter_spec.rb"
+- "./spec/requests/api/ci/pipelines_spec.rb"
+- "./spec/requests/api/ci/runner/jobs_request_post_spec.rb"
+- "./spec/requests/api/ci/runner/runners_post_spec.rb"
+- "./spec/requests/api/ci/runners_spec.rb"
+- "./spec/requests/api/commit_statuses_spec.rb"
+- "./spec/requests/api/graphql/group_query_spec.rb"
+- "./spec/requests/api/graphql/merge_request/merge_request_spec.rb"
+- "./spec/requests/api/graphql/mutations/ci/job_token_scope/add_project_spec.rb"
+- "./spec/requests/api/graphql/mutations/ci/job_token_scope/remove_project_spec.rb"
+- "./spec/requests/api/graphql/mutations/environments/canary_ingress/update_spec.rb"
+- "./spec/requests/api/graphql/mutations/merge_requests/create_spec.rb"
+- "./spec/requests/api/graphql/packages/composer_spec.rb"
+- "./spec/requests/api/graphql/packages/conan_spec.rb"
+- "./spec/requests/api/graphql/packages/maven_spec.rb"
+- "./spec/requests/api/graphql/packages/nuget_spec.rb"
+- "./spec/requests/api/graphql/packages/package_spec.rb"
+- "./spec/requests/api/graphql/packages/pypi_spec.rb"
+- "./spec/requests/api/graphql/project/merge_request/pipelines_spec.rb"
+- "./spec/requests/api/graphql/project/merge_request_spec.rb"
+- "./spec/requests/api/graphql/project/merge_requests_spec.rb"
+- "./spec/requests/api/graphql/project/pipeline_spec.rb"
+- "./spec/requests/api/merge_requests_spec.rb"
+- "./spec/requests/api/package_files_spec.rb"
+- "./spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb"
+- "./spec/services/ci/create_pipeline_service/cross_project_pipeline_spec.rb"
+- "./spec/services/ci/create_pipeline_service/needs_spec.rb"
+- "./spec/services/ci/create_pipeline_service_spec.rb"
+- "./spec/services/ci/destroy_pipeline_service_spec.rb"
+- "./spec/services/ci/expire_pipeline_cache_service_spec.rb"
+- "./spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb"
+- "./spec/services/ci/job_artifacts/destroy_associations_service_spec.rb"
+- "./spec/services/ci/job_artifacts/destroy_batch_service_spec.rb"
+- "./spec/services/ci/pipeline_processing/shared_processing_service.rb"
+- "./spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb"
+- "./spec/services/ci/register_job_service_spec.rb"
+- "./spec/services/clusters/applications/prometheus_config_service_spec.rb"
+- "./spec/services/deployments/older_deployments_drop_service_spec.rb"
+- "./spec/services/environments/auto_stop_service_spec.rb"
+- "./spec/services/environments/stop_service_spec.rb"
+- "./spec/services/merge_requests/add_todo_when_build_fails_service_spec.rb"
+- "./spec/services/merge_requests/create_service_spec.rb"
+- "./spec/services/merge_requests/post_merge_service_spec.rb"
+- "./spec/services/merge_requests/refresh_service_spec.rb"
+- "./spec/support/prometheus/additional_metrics_shared_examples.rb"
+- "./spec/support/shared_examples/ci/pipeline_email_shared_examples.rb"
+- "./spec/support/shared_examples/features/packages_shared_examples.rb"
+- "./spec/support/shared_examples/features/search_settings_shared_examples.rb"
+- "./spec/support/shared_examples/features/variable_list_shared_examples.rb"
+- "./spec/support/shared_examples/models/concerns/limitable_shared_examples.rb"
+- "./spec/support/shared_examples/quick_actions/merge_request/merge_quick_action_shared_examples.rb"
+- "./spec/support/shared_examples/requests/api/graphql/packages/group_and_project_packages_list_shared_examples.rb"
+- "./spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb"
+- "./spec/support/shared_examples/requests/api/logging_application_context_shared_examples.rb"
+- "./spec/support/shared_examples/requests/api/status_shared_examples.rb"
+- "./spec/support/shared_examples/requests/graphql_shared_examples.rb"
+- "./spec/support/shared_examples/services/onboarding_progress_shared_examples.rb"
+- "./spec/support/shared_examples/services/packages_shared_examples.rb"
+- "./spec/support/shared_examples/workers/idempotency_shared_examples.rb"
+- "./spec/tasks/gitlab/generate_sample_prometheus_data_spec.rb"
+- "./spec/workers/pipeline_process_worker_spec.rb"
+- "./spec/workers/pipeline_schedule_worker_spec.rb"
diff --git a/spec/support/database/gitlab_schema.rb b/spec/support/database/gitlab_schema.rb
new file mode 100644
index 00000000000..fe05fb998e6
--- /dev/null
+++ b/spec/support/database/gitlab_schema.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+# This module gathes information about table to schema mapping
+# to understand table affinity
+module Database
+ module GitlabSchema
+ def self.table_schemas(tables)
+ tables.map { |table| table_schema(table) }.to_set
+ end
+
+ def self.table_schema(name)
+ tables_to_schema[name] || :undefined
+ end
+
+ def self.tables_to_schema
+ @tables_to_schema ||= all_classes_with_schema.to_h do |klass|
+ [klass.table_name, klass.gitlab_schema]
+ end
+ end
+
+ def self.all_classes_with_schema
+ ActiveRecord::Base.descendants.reject(&:abstract_class?).select(&:gitlab_schema?) # rubocop:disable Database/MultipleDatabases
+ end
+ end
+end
diff --git a/spec/support/database/multiple_databases.rb b/spec/support/database/multiple_databases.rb
new file mode 100644
index 00000000000..8ce642a682c
--- /dev/null
+++ b/spec/support/database/multiple_databases.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Database
+ module MultipleDatabases
+ def skip_if_multiple_databases_not_setup
+ skip 'Skipping because multiple databases not set up' unless Gitlab::Database.has_config?(:ci)
+ end
+ end
+end
diff --git a/spec/support/database/prevent_cross_database_modification.rb b/spec/support/database/prevent_cross_database_modification.rb
index 460ee99391b..b4c968e3c41 100644
--- a/spec/support/database/prevent_cross_database_modification.rb
+++ b/spec/support/database/prevent_cross_database_modification.rb
@@ -74,18 +74,20 @@ module Database
return if cross_database_context[:transaction_depth_by_db].values.all?(&:zero?)
- tables = PgQuery.parse(sql).dml_tables
+ parsed_query = PgQuery.parse(sql)
+ tables = sql.downcase.include?(' for update') ? parsed_query.tables : parsed_query.dml_tables
return if tables.empty?
cross_database_context[:modified_tables_by_db][database].merge(tables)
all_tables = cross_database_context[:modified_tables_by_db].values.map(&:to_a).flatten
+ schemas = Database::GitlabSchema.table_schemas(all_tables)
- unless PreventCrossJoins.only_ci_or_only_main?(all_tables)
+ if schemas.many?
raise Database::PreventCrossDatabaseModification::CrossDatabaseModificationAcrossUnsupportedTablesError,
- "Cross-database data modification queries (CI and Main) were detected within " \
- "a transaction '#{all_tables.join(", ")}' discovered"
+ "Cross-database data modification of '#{schemas.to_a.join(", ")}' were detected within " \
+ "a transaction modifying the '#{all_tables.to_a.join(", ")}'"
end
end
end
diff --git a/spec/support/database/prevent_cross_joins.rb b/spec/support/database/prevent_cross_joins.rb
index 789721ccd38..4b78aa9014c 100644
--- a/spec/support/database/prevent_cross_joins.rb
+++ b/spec/support/database/prevent_cross_joins.rb
@@ -11,7 +11,7 @@
#
# class User
# def ci_owned_runners
-# ::Gitlab::Database.allow_cross_joins_across_databases!(url: link-to-issue-url)
+# ::Gitlab::Database.allow_cross_joins_across_databases(url: link-to-issue-url)
#
# ...
# end
@@ -21,33 +21,43 @@ module Database
module PreventCrossJoins
CrossJoinAcrossUnsupportedTablesError = Class.new(StandardError)
+ ALLOW_THREAD_KEY = :allow_cross_joins_across_databases
+
def self.validate_cross_joins!(sql)
- return if Thread.current[:allow_cross_joins_across_databases]
+ return if Thread.current[ALLOW_THREAD_KEY]
+
+ # Allow spec/support/database_cleaner.rb queries to disable/enable triggers for many tables
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/339396
+ return if sql.include?("DISABLE TRIGGER") || sql.include?("ENABLE TRIGGER")
# PgQuery might fail in some cases due to limited nesting:
# https://github.com/pganalyze/pg_query/issues/209
- tables = PgQuery.parse(sql).tables
+ #
+ # Also, we disable GC while parsing because of https://github.com/pganalyze/pg_query/issues/226
+ begin
+ GC.disable
+ tables = PgQuery.parse(sql).tables
+ ensure
+ GC.enable
+ end
- unless only_ci_or_only_main?(tables)
+ schemas = Database::GitlabSchema.table_schemas(tables)
+
+ if schemas.include?(:gitlab_ci) && schemas.include?(:gitlab_main)
+ Thread.current[:has_cross_join_exception] = true
raise CrossJoinAcrossUnsupportedTablesError,
- "Unsupported cross-join across '#{tables.join(", ")}' discovered " \
- "when executing query '#{sql}'"
+ "Unsupported cross-join across '#{tables.join(", ")}' modifying '#{schemas.to_a.join(", ")}' discovered " \
+ "when executing query '#{sql}'. Please refer to https://docs.gitlab.com/ee/development/database/multiple_databases.html#removing-joins-between-ci_-and-non-ci_-tables for details on how to resolve this exception."
end
end
- # Returns true if a set includes only CI tables, or includes only non-CI tables
- def self.only_ci_or_only_main?(tables)
- tables.all? { |table| CiTables.include?(table) } ||
- tables.none? { |table| CiTables.include?(table) }
- end
-
module SpecHelpers
def with_cross_joins_prevented
subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |event|
::Database::PreventCrossJoins.validate_cross_joins!(event.payload[:sql])
end
- Thread.current[:allow_cross_joins_across_databases] = false
+ Thread.current[ALLOW_THREAD_KEY] = false
yield
ensure
@@ -57,8 +67,12 @@ module Database
module GitlabDatabaseMixin
def allow_cross_joins_across_databases(url:)
- Thread.current[:allow_cross_joins_across_databases] = true
- super
+ old_value = Thread.current[ALLOW_THREAD_KEY]
+ Thread.current[ALLOW_THREAD_KEY] = true
+
+ yield
+ ensure
+ Thread.current[ALLOW_THREAD_KEY] = old_value
end
end
end
@@ -67,11 +81,18 @@ end
Gitlab::Database.singleton_class.prepend(
Database::PreventCrossJoins::GitlabDatabaseMixin)
+ALLOW_LIST = Set.new(YAML.load_file(File.join(__dir__, 'cross-join-allowlist.yml'))).freeze
+
RSpec.configure do |config|
config.include(::Database::PreventCrossJoins::SpecHelpers)
- # TODO: remove `:prevent_cross_joins` to enable the check by default
- config.around(:each, :prevent_cross_joins) do |example|
- with_cross_joins_prevented { example.run }
+ config.around do |example|
+ Thread.current[:has_cross_join_exception] = false
+
+ if ALLOW_LIST.include?(example.file_path)
+ example.run
+ else
+ with_cross_joins_prevented { example.run }
+ end
end
end
diff --git a/spec/support/database_cleaner.rb b/spec/support/database_cleaner.rb
index 01bf390d9e9..b31881e3082 100644
--- a/spec/support/database_cleaner.rb
+++ b/spec/support/database_cleaner.rb
@@ -14,7 +14,7 @@ RSpec.configure do |config|
end
config.append_after(:context, :migration) do
- delete_from_all_tables!
+ delete_from_all_tables!(except: ['work_item_types'])
# Postgres maximum number of columns in a table is 1600 (https://github.com/postgres/postgres/blob/de41869b64d57160f58852eab20a27f248188135/src/include/access/htup_details.h#L23-L47).
# And since:
@@ -61,7 +61,7 @@ RSpec.configure do |config|
example.run
- delete_from_all_tables!
+ delete_from_all_tables!(except: ['work_item_types'])
self.class.use_transactional_tests = true
end
diff --git a/spec/support/database_load_balancing.rb b/spec/support/database_load_balancing.rb
index 03fa7886295..f22c69ea613 100644
--- a/spec/support/database_load_balancing.rb
+++ b/spec/support/database_load_balancing.rb
@@ -4,7 +4,10 @@ RSpec.configure do |config|
config.before(:each, :db_load_balancing) do
allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(true)
- proxy = ::Gitlab::Database::LoadBalancing::ConnectionProxy.new([Gitlab::Database.main.config['host']])
+ config = Gitlab::Database::LoadBalancing::Configuration
+ .new(ActiveRecord::Base, [Gitlab::Database.main.config['host']])
+ lb = ::Gitlab::Database::LoadBalancing::LoadBalancer.new(config)
+ proxy = ::Gitlab::Database::LoadBalancing::ConnectionProxy.new(lb)
allow(ActiveRecord::Base).to receive(:load_balancing_proxy).and_return(proxy)
diff --git a/spec/support/db_cleaner.rb b/spec/support/db_cleaner.rb
index 155dc3c17d9..940ff2751d3 100644
--- a/spec/support/db_cleaner.rb
+++ b/spec/support/db_cleaner.rb
@@ -12,7 +12,7 @@ module DbCleaner
end
def deletion_except_tables
- []
+ ['work_item_types']
end
def setup_database_cleaner
diff --git a/spec/support/helpers/bare_repo_operations.rb b/spec/support/helpers/bare_repo_operations.rb
index 98fa13db6c2..e29e12a15f6 100644
--- a/spec/support/helpers/bare_repo_operations.rb
+++ b/spec/support/helpers/bare_repo_operations.rb
@@ -17,26 +17,6 @@ class BareRepoOperations
commit_id[0]
end
- # Based on https://stackoverflow.com/a/25556917/1856239
- def commit_file(file, dst_path, branch = 'master')
- head_id = execute(['show', '--format=format:%H', '--no-patch', branch], allow_failure: true)[0] || Gitlab::Git::EMPTY_TREE_ID
-
- execute(['read-tree', '--empty'])
- execute(['read-tree', head_id])
-
- blob_id = execute(['hash-object', '--stdin', '-w']) do |stdin|
- stdin.write(file.read)
- end
-
- execute(['update-index', '--add', '--cacheinfo', '100644', blob_id[0], dst_path])
-
- tree_id = execute(['write-tree'])
-
- commit_id = commit_tree(tree_id[0], "Add #{dst_path}", parent: head_id)
-
- execute(['update-ref', "refs/heads/#{branch}", commit_id])
- end
-
private
def execute(args, allow_failure: false)
diff --git a/spec/support/helpers/cycle_analytics_helpers.rb b/spec/support/helpers/cycle_analytics_helpers.rb
index e48c8125d84..3ec52f8c832 100644
--- a/spec/support/helpers/cycle_analytics_helpers.rb
+++ b/spec/support/helpers/cycle_analytics_helpers.rb
@@ -23,12 +23,39 @@ module CycleAnalyticsHelpers
end
end
+ def select_event_label(sel)
+ page.within(sel) do
+ find('.dropdown-toggle').click
+ page.find(".dropdown-menu").all(".dropdown-item")[1].click
+ end
+ end
+
+ def fill_in_custom_label_stage_fields
+ index = page.all('[data-testid="value-stream-stage-fields"]').length
+ last_stage = page.all('[data-testid="value-stream-stage-fields"]').last
+
+ within last_stage do
+ find('[name*="custom-stage-name-"]').fill_in with: "Cool custom label stage - name #{index}"
+ select_dropdown_option_by_value "custom-stage-start-event-", :issue_label_added
+ select_dropdown_option_by_value "custom-stage-end-event-", :issue_label_removed
+
+ select_event_label("[data-testid*='custom-stage-start-event-label-']")
+ select_event_label("[data-testid*='custom-stage-end-event-label-']")
+ end
+ end
+
def add_custom_stage_to_form
page.find_button(s_('CreateValueStreamForm|Add another stage')).click
fill_in_custom_stage_fields
end
+ def add_custom_label_stage_to_form
+ page.find_button(s_('CreateValueStreamForm|Add another stage')).click
+
+ fill_in_custom_label_stage_fields
+ end
+
def save_value_stream(custom_value_stream_name)
fill_in 'create-value-stream-name', with: custom_value_stream_name
@@ -44,12 +71,12 @@ module CycleAnalyticsHelpers
save_value_stream(custom_value_stream_name)
end
- def wait_for_stages_to_load(selector = '.js-path-navigation')
+ def wait_for_stages_to_load(selector = '[data-testid="vsa-path-navigation"]')
expect(page).to have_selector selector
wait_for_requests
end
- def select_group(target_group, ready_selector = '.js-path-navigation')
+ def select_group(target_group, ready_selector = '[data-testid="vsa-path-navigation"]')
visit group_analytics_cycle_analytics_path(target_group)
wait_for_stages_to_load(ready_selector)
diff --git a/spec/support/helpers/email_helpers.rb b/spec/support/helpers/email_helpers.rb
index 6df33e68629..d0f6fd466d0 100644
--- a/spec/support/helpers/email_helpers.rb
+++ b/spec/support/helpers/email_helpers.rb
@@ -2,7 +2,7 @@
module EmailHelpers
def sent_to_user(user, recipients: email_recipients)
- recipients.count { |to| to == user.notification_email }
+ recipients.count { |to| to == user.notification_email_or_default }
end
def reset_delivered_emails!
@@ -45,7 +45,7 @@ module EmailHelpers
end
def find_email_for(user)
- ActionMailer::Base.deliveries.find { |d| d.to.include?(user.notification_email) }
+ ActionMailer::Base.deliveries.find { |d| d.to.include?(user.notification_email_or_default) }
end
def have_referable_subject(referable, include_project: true, reply: false)
diff --git a/spec/support/helpers/features/members_table_helpers.rb b/spec/support/helpers/features/members_helpers.rb
index 2e86e014a1b..2e86e014a1b 100644
--- a/spec/support/helpers/features/members_table_helpers.rb
+++ b/spec/support/helpers/features/members_helpers.rb
diff --git a/spec/support/helpers/javascript_fixtures_helpers.rb b/spec/support/helpers/javascript_fixtures_helpers.rb
index 4c90b907d2d..5174c145a93 100644
--- a/spec/support/helpers/javascript_fixtures_helpers.rb
+++ b/spec/support/helpers/javascript_fixtures_helpers.rb
@@ -42,9 +42,12 @@ module JavaScriptFixturesHelpers
# Public: Reads a GraphQL query from the filesystem as a string
#
- # query_path - file path to the GraphQL query, relative to `app/assets/javascripts`
- def get_graphql_query_as_string(query_path)
- path = Rails.root / 'app/assets/javascripts' / query_path
+ # query_path - file path to the GraphQL query, relative to `app/assets/javascripts`.
+ # ee - boolean, when true `query_path` will be looked up in `/ee`.
+ def get_graphql_query_as_string(query_path, ee: false)
+ base = (ee ? 'ee/' : '') + 'app/assets/javascripts'
+
+ path = Rails.root / base / query_path
queries = Gitlab::Graphql::Queries.find(path)
if queries.length == 1
queries.first.text(mode: Gitlab.ee? ? :ee : :ce )
diff --git a/spec/support/helpers/live_debugger.rb b/spec/support/helpers/live_debugger.rb
index f4199d518a3..d196a6dc746 100644
--- a/spec/support/helpers/live_debugger.rb
+++ b/spec/support/helpers/live_debugger.rb
@@ -16,7 +16,7 @@ module LiveDebugger
puts "The current user credentials are: #{@current_user.username} / #{@current_user.password}" if @current_user
puts "Press any key to resume the execution of the example!!"
- `open #{current_url}` if is_headless_disabled?
+ `open #{current_url}` unless is_headless_disabled?
loop until $stdin.getch
diff --git a/spec/support/helpers/migrations_helpers.rb b/spec/support/helpers/migrations_helpers.rb
index ef212938af5..7799e49d4c1 100644
--- a/spec/support/helpers/migrations_helpers.rb
+++ b/spec/support/helpers/migrations_helpers.rb
@@ -30,7 +30,7 @@ module MigrationsHelpers
end
end
- klass.tap { Gitlab::Database::Partitioning::PartitionManager.new.sync_partitions }
+ klass.tap { Gitlab::Database::Partitioning.sync_partitions([klass]) }
end
def migrations_paths
diff --git a/spec/support/helpers/reference_parser_helpers.rb b/spec/support/helpers/reference_parser_helpers.rb
index a6a7948d9d9..b9796ebbe62 100644
--- a/spec/support/helpers/reference_parser_helpers.rb
+++ b/spec/support/helpers/reference_parser_helpers.rb
@@ -5,9 +5,10 @@ module ReferenceParserHelpers
Nokogiri::HTML.fragment('<a></a>').children[0]
end
- def expect_gathered_references(result, visible, not_visible_count)
+ def expect_gathered_references(result, visible, nodes, visible_nodes)
expect(result[:visible]).to eq(visible)
- expect(result[:not_visible].count).to eq(not_visible_count)
+ expect(result[:nodes]).to eq(nodes)
+ expect(result[:visible_nodes]).to eq(visible_nodes)
end
RSpec.shared_examples 'no project N+1 queries' do
diff --git a/spec/support/helpers/session_helpers.rb b/spec/support/helpers/session_helpers.rb
new file mode 100644
index 00000000000..4ef099a393e
--- /dev/null
+++ b/spec/support/helpers/session_helpers.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module SessionHelpers
+ def expect_single_session_with_authenticated_ttl
+ expect_single_session_with_expiration(Settings.gitlab['session_expire_delay'] * 60)
+ end
+
+ def expect_single_session_with_short_ttl
+ expect_single_session_with_expiration(Settings.gitlab['unauthenticated_session_expire_delay'])
+ end
+
+ def expect_single_session_with_expiration(expiration)
+ session_keys = get_session_keys
+
+ expect(session_keys.size).to eq(1)
+ expect(get_ttl(session_keys.first)).to be_within(5).of(expiration)
+ end
+
+ def get_session_keys
+ Gitlab::Redis::SharedState.with { |redis| redis.scan_each(match: 'session:gitlab:*').to_a }
+ end
+
+ def get_ttl(key)
+ Gitlab::Redis::SharedState.with { |redis| redis.ttl(key) }
+ end
+end
diff --git a/spec/support/helpers/stub_gitlab_calls.rb b/spec/support/helpers/stub_gitlab_calls.rb
index 3824ff2b68d..5ab778c11cb 100644
--- a/spec/support/helpers/stub_gitlab_calls.rb
+++ b/spec/support/helpers/stub_gitlab_calls.rb
@@ -18,6 +18,10 @@ module StubGitlabCalls
stub_ci_pipeline_yaml_file(gitlab_ci_yaml)
end
+ def gitlab_ci_yaml
+ File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
+ end
+
def stub_ci_pipeline_yaml_file(ci_yaml_content)
allow_any_instance_of(Repository)
.to receive(:gitlab_ci_yml_for)
diff --git a/spec/support/helpers/stub_gitlab_data.rb b/spec/support/helpers/stub_gitlab_data.rb
deleted file mode 100644
index ed518393c03..00000000000
--- a/spec/support/helpers/stub_gitlab_data.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-
-module StubGitlabData
- def gitlab_ci_yaml
- File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
- end
-end
diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb
index aa5fcf222f2..badd4e8212c 100644
--- a/spec/support/helpers/test_env.rb
+++ b/spec/support/helpers/test_env.rb
@@ -452,6 +452,10 @@ module TestEnv
example_group
end
+ def seed_db
+ Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter.import
+ end
+
private
# These are directories that should be preserved at cleanup time
diff --git a/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb b/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb
index 8a88f0335a9..2fbc01a9195 100644
--- a/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb
+++ b/spec/support/services/migrate_to_ghost_user_service_shared_examples.rb
@@ -32,7 +32,7 @@ RSpec.shared_examples "migrating a deleted user's associated records to the ghos
expect(user).to be_blocked
end
- it 'migrates all associated fields to te "Ghost user"' do
+ it 'migrates all associated fields to the "Ghost user"' do
service.execute
migrated_record = record_class.find_by_id(record.id)
@@ -46,40 +46,19 @@ RSpec.shared_examples "migrating a deleted user's associated records to the ghos
context "when #{record_class_name} migration fails and is rolled back" do
before do
expect_any_instance_of(ActiveRecord::Associations::CollectionProxy)
- .to receive(:update_all).and_raise(ActiveRecord::Rollback)
+ .to receive(:update_all).and_raise(ActiveRecord::StatementTimeout)
end
it 'rolls back the user block' do
- service.execute
+ expect { service.execute }.to raise_error(ActiveRecord::StatementTimeout)
expect(user.reload).not_to be_blocked
end
- it "doesn't unblock an previously-blocked user" do
+ it "doesn't unblock a previously-blocked user" do
user.block
- service.execute
-
- expect(user.reload).to be_blocked
- end
- end
-
- context "when #{record_class_name} migration fails with a non-rollback exception" do
- before do
- expect_any_instance_of(ActiveRecord::Associations::CollectionProxy)
- .to receive(:update_all).and_raise(ArgumentError)
- end
-
- it 'rolls back the user block' do
- service.execute rescue nil
-
- expect(user.reload).not_to be_blocked
- end
-
- it "doesn't unblock an previously-blocked user" do
- user.block
-
- service.execute rescue nil
+ expect { service.execute }.to raise_error(ActiveRecord::StatementTimeout)
expect(user.reload).to be_blocked
end
diff --git a/spec/support/shared_contexts/email_shared_context.rb b/spec/support/shared_contexts/email_shared_context.rb
index 14c6c85cc43..0dc66eeb2ee 100644
--- a/spec/support/shared_contexts/email_shared_context.rb
+++ b/spec/support/shared_contexts/email_shared_context.rb
@@ -18,6 +18,15 @@ RSpec.shared_context :email_shared_context do
end
end
+def email_fixture(path)
+ fixture_file(path).gsub('project_id', project.project_id.to_s)
+end
+
+def service_desk_fixture(path, slug: nil, key: 'mykey')
+ slug ||= project.full_path_slug.to_s
+ fixture_file(path).gsub('project_slug', slug).gsub('project_key', key)
+end
+
RSpec.shared_examples :reply_processing_shared_examples do
context 'when the user could not be found' do
before do
diff --git a/spec/support/shared_contexts/finders/packages/npm/package_finder_shared_context.rb b/spec/support/shared_contexts/finders/packages/npm/package_finder_shared_context.rb
new file mode 100644
index 00000000000..a2cb9d41f45
--- /dev/null
+++ b/spec/support/shared_contexts/finders/packages/npm/package_finder_shared_context.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+RSpec.shared_context 'last_of_each_version setup context' do
+ let_it_be(:package1) { create(:npm_package, name: 'test', version: '1.2.3', project: project) }
+ let_it_be(:package2) { create(:npm_package, name: 'test2', version: '1.2.3', project: project) }
+
+ let(:package_name) { 'test' }
+ let(:version) { '1.2.3' }
+
+ before do
+ # create a duplicated package without triggering model validation errors
+ package2.update_column(:name, 'test')
+ end
+end
diff --git a/spec/support/shared_contexts/graphql/resolvers/runners_resolver_shared_context.rb b/spec/support/shared_contexts/graphql/resolvers/runners_resolver_shared_context.rb
new file mode 100644
index 00000000000..aa857cfdb70
--- /dev/null
+++ b/spec/support/shared_contexts/graphql/resolvers/runners_resolver_shared_context.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_context 'runners resolver setup' do
+ let_it_be(:user) { create_default(:user, :admin) }
+ let_it_be(:group) { create(:group, :public) }
+ let_it_be(:subgroup) { create(:group, :public, parent: group) }
+ let_it_be(:project) { create(:project, :public, group: group) }
+
+ let_it_be(:inactive_project_runner) do
+ create(:ci_runner, :project, projects: [project], description: 'inactive project runner', token: 'abcdef', active: false, contacted_at: 1.minute.ago, tag_list: %w(project_runner))
+ end
+
+ let_it_be(:offline_project_runner) do
+ create(:ci_runner, :project, projects: [project], description: 'offline project runner', token: 'defghi', contacted_at: 1.day.ago, tag_list: %w(project_runner active_runner))
+ end
+
+ let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], token: 'mnopqr', description: 'group runner', contacted_at: 2.seconds.ago) }
+ let_it_be(:subgroup_runner) { create(:ci_runner, :group, groups: [subgroup], token: 'mnopqr', description: 'subgroup runner', contacted_at: 1.second.ago) }
+ let_it_be(:instance_runner) { create(:ci_runner, :instance, description: 'shared runner', token: 'stuvxz', contacted_at: 2.minutes.ago, tag_list: %w(instance_runner active_runner)) }
+end
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 2c56411ca4c..b9cde12c537 100644
--- a/spec/support/shared_contexts/issuable/merge_request_shared_context.rb
+++ b/spec/support/shared_contexts/issuable/merge_request_shared_context.rb
@@ -16,7 +16,7 @@ RSpec.shared_context 'merge request show action' do
assign(:merge_request, merge_request)
assign(:note, note)
assign(:noteable, merge_request)
- assign(:pipelines, [])
+ assign(:number_of_pipelines, 0)
assign(:issuable_sidebar, serialize_issuable_sidebar(user, project, merge_request))
preload_view_requirements(merge_request, note)
diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb
index 8ae0885056e..2abc52fce85 100644
--- a/spec/support/shared_contexts/navbar_structure_context.rb
+++ b/spec/support/shared_contexts/navbar_structure_context.rb
@@ -118,7 +118,8 @@ RSpec.shared_context 'project navbar structure' do
_('Access Tokens'),
_('Repository'),
_('CI/CD'),
- _('Monitor')
+ _('Monitor'),
+ (s_('UsageQuota|Usage Quotas') if Feature.enabled?(:project_storage_ui, default_enabled: :yaml))
]
}
].compact
diff --git a/spec/support/shared_contexts/pages_zip_with_spoofed_size_shared_context.rb b/spec/support/shared_contexts/pages_zip_with_spoofed_size_shared_context.rb
deleted file mode 100644
index 4cec5ab3b74..00000000000
--- a/spec/support/shared_contexts/pages_zip_with_spoofed_size_shared_context.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-# frozen_string_literal: true
-
-# the idea of creating zip archive with spoofed size is borrowed from
-# https://github.com/rubyzip/rubyzip/pull/403/files#diff-118213fb4baa6404a40f89e1147661ebR88
-RSpec.shared_context 'pages zip with spoofed size' do
- let(:real_zip_path) { Tempfile.new(['real', '.zip']).path }
- let(:fake_zip_path) { Tempfile.new(['fake', '.zip']).path }
-
- before do
- full_file_name = 'public/index.html'
- true_size = 500_000
- fake_size = 1
-
- ::Zip::File.open(real_zip_path, ::Zip::File::CREATE) do |zf|
- zf.get_output_stream(full_file_name) do |os|
- os.write 'a' * true_size
- end
- end
-
- compressed_size = nil
- ::Zip::File.open(real_zip_path) do |zf|
- a_entry = zf.find_entry(full_file_name)
- compressed_size = a_entry.compressed_size
- end
-
- true_size_bytes = [compressed_size, true_size, full_file_name.size].pack('LLS')
- fake_size_bytes = [compressed_size, fake_size, full_file_name.size].pack('LLS')
-
- data = File.binread(real_zip_path)
- data.gsub! true_size_bytes, fake_size_bytes
-
- File.open(fake_zip_path, 'wb') do |file|
- file.write data
- end
- end
-
- after do
- File.delete(real_zip_path) if File.exist?(real_zip_path)
- File.delete(fake_zip_path) if File.exist?(fake_zip_path)
- end
-end
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 815108be447..89f290d8d68 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
@@ -8,14 +8,20 @@ RSpec.shared_context 'npm api setup' do
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(:package1, reload: true) { create(:npm_package, project: project, name: "@#{group.path}/scoped_package", version: '1.2.4') }
+ let_it_be(:package, reload: true) { create(:npm_package, project: project, name: "@#{group.path}/scoped_package", version: '1.2.3') }
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) }
- let_it_be(:job, reload: true) { create(:ci_build, user: user, status: :running) }
+ let_it_be(:job, reload: true) { create(:ci_build, user: user, status: :running, project: project) }
let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) }
let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
let(:package_name) { package.name }
+
+ before do
+ # create a duplicated package without triggering model validation errors
+ package1.update_column(:version, '1.2.3')
+ end
end
RSpec.shared_context 'set package name from package name type' do
diff --git a/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb b/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb
index 6b49a415889..2b810e790f0 100644
--- a/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb
+++ b/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb
@@ -6,21 +6,25 @@ RSpec.shared_context 'stubbed service ping metrics definitions' do
let(:metrics_definitions) { standard_metrics + subscription_metrics + operational_metrics + optional_metrics }
let(:standard_metrics) do
[
- metric_attributes('uuid', "standard")
+ metric_attributes('uuid', 'standard'),
+ metric_attributes('recorded_at', 'standard'),
+ metric_attributes('settings.collected_data_categories', 'standard', 'object')
]
end
let(:operational_metrics) do
[
- metric_attributes('counts.merge_requests', "operational"),
+ metric_attributes('counts.merge_requests', 'operational'),
metric_attributes('counts.todos', "operational")
]
end
let(:optional_metrics) do
[
- metric_attributes('counts.boards', "optional"),
- metric_attributes('gitaly.filesystems', '').except('data_category')
+ metric_attributes('counts.boards', 'optional', 'number'),
+ metric_attributes('gitaly.filesystems', '').except('data_category'),
+ metric_attributes('usage_activity_by_stage.monitor.projects_with_enabled_alert_integrations_histogram', 'optional', 'object'),
+ metric_attributes('topology', 'optional', 'object')
]
end
@@ -34,10 +38,11 @@ RSpec.shared_context 'stubbed service ping metrics definitions' do
)
end
- def metric_attributes(key_path, category)
+ def metric_attributes(key_path, category, value_type = 'string')
{
'key_path' => key_path,
- 'data_category' => category
+ 'data_category' => category,
+ 'value_type' => value_type
}
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 cadc753513d..1e303197990 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
@@ -3,14 +3,10 @@
RSpec.shared_examples 'multiple issue boards' do
context 'authorized user' do
before do
- stub_feature_flags(board_new_list: false)
-
parent.add_maintainer(user)
login_as(user)
- stub_feature_flags(board_new_list: false)
-
visit boards_path
wait_for_requests
end
@@ -79,13 +75,13 @@ RSpec.shared_examples 'multiple issue boards' do
expect(page).to have_content(board2.name)
end
- click_button 'Add list'
+ click_button 'Create list'
- wait_for_requests
+ click_button 'Select a label'
- page.within '.dropdown-menu-issues-board-new' do
- click_link planning.title
- end
+ page.choose(planning.title)
+
+ click_button 'Add to board'
wait_for_requests
diff --git a/spec/support/shared_examples/controllers/concerns/integrations_actions_shared_examples.rb b/spec/support/shared_examples/controllers/concerns/integrations_actions_shared_examples.rb
new file mode 100644
index 00000000000..748a3acf17b
--- /dev/null
+++ b/spec/support/shared_examples/controllers/concerns/integrations_actions_shared_examples.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples IntegrationsActions do
+ let(:integration) do
+ create(:datadog_integration,
+ integration_attributes.merge(
+ api_url: 'http://example.com',
+ api_key: 'secret'
+ )
+ )
+ end
+
+ describe 'GET #edit' do
+ before do
+ get :edit, params: routing_params
+ end
+
+ it 'assigns the integration' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:integration)).to eq(integration)
+ end
+ end
+
+ describe 'PUT #update' do
+ let(:params) do
+ {
+ datadog_env: 'env',
+ datadog_service: 'service'
+ }
+ end
+
+ before do
+ put :update, params: routing_params.merge(integration: params)
+ end
+
+ it 'updates the integration with the provided params and redirects to the form' do
+ expect(response).to redirect_to(routing_params.merge(action: :edit))
+ expect(integration.reload).to have_attributes(params)
+ end
+
+ context 'when sending a password field' do
+ let(:params) { super().merge(api_key: 'new') }
+
+ it 'updates the integration with the password and other params' do
+ expect(response).to be_redirect
+ expect(integration.reload).to have_attributes(params)
+ end
+ end
+
+ context 'when sending a blank password field' do
+ let(:params) { super().merge(api_key: '') }
+
+ it 'ignores the password field and saves the other params' do
+ expect(response).to be_redirect
+ expect(integration.reload).to have_attributes(params.merge(api_key: 'secret'))
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb b/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb
index a9c6da7bc2b..0ffa32dec9e 100644
--- a/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb
@@ -82,16 +82,6 @@ RSpec.shared_examples 'a GitHub-ish import controller: GET status' do
expect(json_response.dig("provider_repos", 1, "id")).to eq(org_repo.id)
end
- it "does not show already added project" do
- project = create(:project, import_type: provider, namespace: user.namespace, import_status: :finished, import_source: 'asd/vim')
- stub_client(repos: [repo], orgs: [], each_page: [OpenStruct.new(objects: [repo])].to_enum)
-
- get :status, format: :json
-
- expect(json_response.dig("imported_projects", 0, "id")).to eq(project.id)
- expect(json_response.dig("provider_repos")).to eq([])
- end
-
it "touches the etag cache store" do
stub_client(repos: [], orgs: [], each_page: [])
diff --git a/spec/support/shared_examples/controllers/import_controller_status_shared_examples.rb b/spec/support/shared_examples/controllers/import_controller_status_shared_examples.rb
index b9ae0e23e26..44baadaaade 100644
--- a/spec/support/shared_examples/controllers/import_controller_status_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/import_controller_status_shared_examples.rb
@@ -19,14 +19,4 @@ RSpec.shared_examples 'import controller status' do
expect(json_response.dig("imported_projects", 0, "id")).to eq(project.id)
expect(json_response.dig("provider_repos", 0, "id")).to eq(repo_id)
end
-
- it "does not show already added project" do
- project = create(:project, import_type: provider_name, namespace: user.namespace, import_status: :finished, import_source: import_source)
- stub_client(client_repos_field => [repo])
-
- get :status, format: :json
-
- expect(json_response.dig("imported_projects", 0, "id")).to eq(project.id)
- expect(json_response.dig("provider_repos")).to eq([])
- end
end
diff --git a/spec/support/shared_examples/controllers/issuable_anonymous_search_disabled_examples.rb b/spec/support/shared_examples/controllers/issuable_anonymous_search_disabled_examples.rb
new file mode 100644
index 00000000000..e77acb93798
--- /dev/null
+++ b/spec/support/shared_examples/controllers/issuable_anonymous_search_disabled_examples.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'issuable list with anonymous search disabled' do |action|
+ let(:controller_action) { :index }
+ let(:params_with_search) { params.merge(search: 'some search term') }
+
+ context 'when disable_anonymous_search is enabled' do
+ before do
+ stub_feature_flags(disable_anonymous_search: true)
+ end
+
+ it 'shows a flash message' do
+ get controller_action, params: params_with_search
+
+ expect(flash.now[:notice]).to eq('You must sign in to search for specific terms.')
+ end
+
+ context 'when search param is not given' do
+ it 'does not show a flash message' do
+ get controller_action, params: params
+
+ expect(flash.now[:notice]).to be_nil
+ end
+ end
+
+ context 'when user is signed-in' do
+ it 'does not show a flash message' do
+ sign_in(create(:user))
+ get controller_action, params: params_with_search
+
+ expect(flash.now[:notice]).to be_nil
+ end
+ end
+
+ context 'when format is not HTML' do
+ it 'does not show a flash message' do
+ get controller_action, params: params_with_search.merge(format: :atom)
+
+ expect(flash.now[:notice]).to be_nil
+ end
+ end
+ end
+
+ context 'when disable_anonymous_search is disabled' do
+ before do
+ stub_feature_flags(disable_anonymous_search: false)
+ end
+
+ it 'does not show a flash message' do
+ get controller_action, params: params_with_search
+
+ expect(flash.now[:notice]).to be_nil
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/atom/issuable_shared_examples.rb b/spec/support/shared_examples/features/atom/issuable_shared_examples.rb
new file mode 100644
index 00000000000..17993830f37
--- /dev/null
+++ b/spec/support/shared_examples/features/atom/issuable_shared_examples.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples "an authenticated issuable atom feed" do
+ it "renders atom feed with common issuable information" do
+ expect(response_headers['Content-Type'])
+ .to have_content('application/atom+xml')
+ expect(body).to have_selector('author email', text: issuable.author_public_email)
+ expect(body).to have_selector('assignees assignee email', text: issuable.assignees.first.public_email)
+ expect(body).to have_selector('assignee email', text: issuable.assignees.first.public_email)
+ expect(body).to have_selector('entry summary', text: issuable.title)
+ end
+end
diff --git a/spec/support/shared_examples/features/content_editor_shared_examples.rb b/spec/support/shared_examples/features/content_editor_shared_examples.rb
new file mode 100644
index 00000000000..2332285540a
--- /dev/null
+++ b/spec/support/shared_examples/features/content_editor_shared_examples.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'edits content using the content editor' do
+ it 'formats text as bold using bubble menu' do
+ content_editor_testid = '[data-testid="content-editor"] [contenteditable]'
+
+ expect(page).to have_css(content_editor_testid)
+
+ find(content_editor_testid).send_keys 'Typing text in the content editor'
+ find(content_editor_testid).send_keys [:shift, :left]
+
+ expect(page).to have_css('[data-testid="formatting-bubble-menu"]')
+ end
+end
diff --git a/spec/support/shared_examples/features/deploy_token_shared_examples.rb b/spec/support/shared_examples/features/deploy_token_shared_examples.rb
index fd77297a490..e70f9b52c09 100644
--- a/spec/support/shared_examples/features/deploy_token_shared_examples.rb
+++ b/spec/support/shared_examples/features/deploy_token_shared_examples.rb
@@ -1,15 +1,22 @@
# frozen_string_literal: true
RSpec.shared_examples 'a deploy token in settings' do
- it 'view deploy tokens' do
+ it 'view deploy tokens', :js do
+ user.update!(time_display_relative: true)
+
+ visit page_path
+
within('.deploy-tokens') do
expect(page).to have_content(deploy_token.name)
expect(page).to have_content('read_repository')
expect(page).to have_content('read_registry')
+ expect(page).to have_content('in 4 days')
end
end
it 'add a new deploy token' do
+ visit page_path
+
fill_in 'deploy_token_name', with: 'new_deploy_key'
fill_in 'deploy_token_expires_at', with: (Date.today + 1.month).to_s
fill_in 'deploy_token_username', with: 'deployer'
@@ -24,4 +31,18 @@ RSpec.shared_examples 'a deploy token in settings' do
expect(page).to have_selector("input[name='deploy-token'][readonly='readonly']")
end
end
+
+ context 'when User#time_display_relative is false', :js do
+ before do
+ user.update!(time_display_relative: false)
+ end
+
+ it 'shows absolute times for expires_at' do
+ visit page_path
+
+ within('.deploy-tokens') do
+ expect(page).to have_content(deploy_token.expires_at.strftime('%b %d'))
+ 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 fb2e422559d..318ba67b9e9 100644
--- a/spec/support/shared_examples/features/discussion_comments_shared_example.rb
+++ b/spec/support/shared_examples/features/discussion_comments_shared_example.rb
@@ -7,7 +7,7 @@ RSpec.shared_examples 'thread comments for commit and snippet' do |resource_name
let(:menu_selector) { "#{dropdown_selector} .dropdown-menu" }
let(:submit_selector) { "#{form_selector} .js-comment-submit-button" }
let(:close_selector) { "#{form_selector} .btn-comment-and-close" }
- let(:comments_selector) { '.timeline > .note.timeline-entry' }
+ let(:comments_selector) { '.timeline > .note.timeline-entry:not(.being-posted)' }
let(:comment) { 'My comment' }
it 'clicking "Comment" will post a comment' do
@@ -187,7 +187,7 @@ RSpec.shared_examples 'thread comments for issue, epic and merge request' do |re
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(:comments_selector) { '.timeline > .note.timeline-entry:not(.being-posted)' }
let(:comment) { 'My comment' }
it 'clicking "Comment" will post a comment' do
@@ -197,6 +197,8 @@ RSpec.shared_examples 'thread comments for issue, epic and merge request' do |re
find(submit_button_selector).click
+ wait_for_all_requests
+
expect(page).to have_content(comment)
new_comment = all(comments_selector).last
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 c0cfc27ceaf..149486320ae 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
@@ -15,7 +15,7 @@ RSpec.shared_examples 'issuable invite members' do
page.within '.dropdown-menu-user' do
expect(page).to have_link('Invite Members')
- expect(page).to have_selector('[data-track-event="click_invite_members"]')
+ expect(page).to have_selector('[data-track-action="click_invite_members"]')
expect(page).to have_selector('[data-track-label="edit_assignee"]')
end
diff --git a/spec/support/shared_examples/features/manage_applications_shared_examples.rb b/spec/support/shared_examples/features/manage_applications_shared_examples.rb
index 38bb87eaed2..0161899cb76 100644
--- a/spec/support/shared_examples/features/manage_applications_shared_examples.rb
+++ b/spec/support/shared_examples/features/manage_applications_shared_examples.rb
@@ -9,9 +9,11 @@ RSpec.shared_examples 'manage applications' do
visit new_application_path
expect(page).to have_content 'Add new application'
+ expect(find('#doorkeeper_application_expire_access_tokens')).to be_checked
fill_in :doorkeeper_application_name, with: application_name
fill_in :doorkeeper_application_redirect_uri, with: application_redirect_uri
+ uncheck :doorkeeper_application_expire_access_tokens
check :doorkeeper_application_scopes_read_user
click_on 'Save application'
@@ -22,6 +24,8 @@ RSpec.shared_examples 'manage applications' do
click_on 'Edit'
+ expect(find('#doorkeeper_application_expire_access_tokens')).not_to be_checked
+
application_name_changed = "#{application_name} changed"
fill_in :doorkeeper_application_name, with: application_name_changed
diff --git a/spec/support/shared_examples/features/rss_shared_examples.rb b/spec/support/shared_examples/features/rss_shared_examples.rb
index c7c2aeea358..0991de21d8d 100644
--- a/spec/support/shared_examples/features/rss_shared_examples.rb
+++ b/spec/support/shared_examples/features/rss_shared_examples.rb
@@ -25,3 +25,23 @@ RSpec.shared_examples "it has an RSS button without a feed token" do
.to have_css("a:has(.qa-rss-icon):not([href*='feed_token'])") # rubocop:disable QA/SelectorUsage
end
end
+
+RSpec.shared_examples "updates atom feed link" do |type|
+ it "for #{type}" do
+ sign_in(user)
+ visit path
+
+ link = find_link('Subscribe to RSS feed')
+ params = CGI.parse(URI.parse(link[:href]).query)
+ auto_discovery_link = find("link[type='application/atom+xml']", visible: false)
+ auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query)
+
+ expected = {
+ 'feed_token' => [user.feed_token],
+ 'assignee_id' => [user.id.to_s]
+ }
+
+ expect(params).to include(expected)
+ expect(auto_discovery_params).to include(expected)
+ end
+end
diff --git a/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb
index 9587da0233e..7ced8508a31 100644
--- a/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb
+++ b/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb
@@ -136,6 +136,14 @@ RSpec.shared_examples 'User updates wiki page' do
expect(find('textarea#wiki_content').value).to eq('Updated Wiki Content')
end
end
+
+ context 'when using the content editor' do
+ before do
+ click_button 'Use the new editor'
+ end
+
+ it_behaves_like 'edits content using the content editor'
+ end
end
context 'when the page is in a subdir', :js do
diff --git a/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb
index 61feeff57bb..96df5a5f972 100644
--- a/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb
+++ b/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb
@@ -157,7 +157,7 @@ RSpec.shared_examples 'User views a wiki page' do
expect(page).to have_link('updated home', href: wiki_page_path(wiki, wiki_page, version_id: commit2, action: :diff))
end
- it 'between the current and the previous version of a page' do
+ it 'between the current and the previous version of a page', :js do
commit = wiki.commit
visit wiki_page_path(wiki, wiki_page, version_id: commit, action: :diff)
@@ -169,7 +169,7 @@ RSpec.shared_examples 'User views a wiki page' do
expect_diff_links(commit)
end
- it 'between two old versions of a page' do
+ it 'between two old versions of a page', :js do
wiki_page.update(message: 'latest home change', content: 'updated [another link](other-page)') # rubocop:disable Rails/SaveBang:
commit = wiki.commit('HEAD^')
visit wiki_page_path(wiki, wiki_page, version_id: commit, action: :diff)
@@ -184,7 +184,7 @@ RSpec.shared_examples 'User views a wiki page' do
expect_diff_links(commit)
end
- it 'for the oldest version of a page' do
+ it 'for the oldest version of a page', :js do
commit = wiki.commit('HEAD^')
visit wiki_page_path(wiki, wiki_page, version_id: commit, action: :diff)
diff --git a/spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb b/spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb
new file mode 100644
index 00000000000..6342064beb8
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/cycle_analytics/deployment_metrics.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+shared_examples 'deployment metrics examples' do
+ def create_deployment(args)
+ project = args[:project]
+ environment = project.environments.production.first || create(:environment, :production, project: project)
+ create(:deployment, :success, args.merge(environment: environment))
+
+ # this is needed for the dora_deployment_frequency_in_vsa feature flag so we have aggregated data
+ ::Dora::DailyMetrics::RefreshWorker.new.perform(environment.id, Time.current.to_date.to_s) if Gitlab.ee?
+ end
+
+ describe "#deploys" do
+ subject { stage_summary.third }
+
+ context 'when from date is given' do
+ before do
+ travel_to(5.days.ago) { create_deployment(project: project) }
+ create_deployment(project: project)
+ end
+
+ it "finds the number of deploys made created after the 'from date'" do
+ expect(subject[:value]).to eq('1')
+ end
+
+ it 'returns the localized title' do
+ Gitlab::I18n.with_locale(:ru) do
+ expect(subject[:title]).to eq(n_('Deploy', 'Deploys', 1))
+ end
+ end
+ end
+
+ it "doesn't find commits from other projects" do
+ travel_to(5.days.from_now) do
+ create_deployment(project: create(:project, :repository))
+ end
+
+ expect(subject[:value]).to eq('-')
+ end
+
+ context 'when `to` parameter is given' do
+ before do
+ travel_to(5.days.ago) { create_deployment(project: project) }
+ travel_to(5.days.from_now) { create_deployment(project: project) }
+ end
+
+ it "doesn't find any record" do
+ options[:to] = Time.now
+
+ expect(subject[:value]).to eq('-')
+ end
+
+ it "finds records created between `from` and `to` range" do
+ options[:from] = 10.days.ago
+ options[:to] = 10.days.from_now
+
+ expect(subject[:value]).to eq('2')
+ end
+ end
+ end
+
+ describe '#deployment_frequency' do
+ subject { stage_summary.fourth[:value] }
+
+ it 'includes the unit: `per day`' do
+ expect(stage_summary.fourth[:unit]).to eq _('per day')
+ end
+
+ before do
+ travel_to(5.days.ago) { create_deployment(project: project) }
+ end
+
+ it 'returns 0.0 when there were deploys but the frequency was too low' do
+ options[:from] = 30.days.ago
+
+ # 1 deployment over 30 days
+ # frequency of 0.03, rounded off to 0.0
+ expect(subject).to eq('0')
+ end
+
+ it 'returns `-` when there were no deploys' do
+ options[:from] = 4.days.ago
+
+ # 0 deployment in the last 4 days
+ expect(subject).to eq('-')
+ end
+
+ context 'when `to` is nil' do
+ it 'includes range until now' do
+ options[:from] = 6.days.ago
+ options[:to] = nil
+
+ # 1 deployment over 7 days
+ expect(subject).to eq('0.1')
+ end
+ end
+
+ context 'when `to` is given' do
+ before do
+ travel_to(5.days.from_now) { create_deployment(project: project, finished_at: Time.zone.now) }
+ end
+
+ it 'finds records created between `from` and `to` range' do
+ options[:from] = 10.days.ago
+ options[:to] = 10.days.from_now
+
+ # 2 deployments over 20 days
+ expect(subject).to eq('0.1')
+ end
+
+ context 'when `from` and `to` are within a day' do
+ it 'returns the number of deployments made on that day' do
+ freeze_time do
+ create_deployment(project: project, finished_at: Time.current)
+ options[:from] = Time.current.yesterday.beginning_of_day
+ options[:to] = Time.current.end_of_day
+
+ expect(subject).to eq('0.5')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb
index 89b793d5e16..708bc71ae96 100644
--- a/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb
@@ -39,6 +39,7 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name|
allow(fake_duplicate_job).to receive(:scheduled?).and_return(false)
allow(fake_duplicate_job).to receive(:check!).and_return('the jid')
allow(fake_duplicate_job).to receive(:idempotent?).and_return(true)
+ allow(fake_duplicate_job).to receive(:update_latest_wal_location!)
allow(fake_duplicate_job).to receive(:options).and_return({})
job_hash = {}
@@ -63,6 +64,7 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name|
.with(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob::DUPLICATE_KEY_TTL)
.and_return('the jid'))
allow(fake_duplicate_job).to receive(:idempotent?).and_return(true)
+ allow(fake_duplicate_job).to receive(:update_latest_wal_location!)
job_hash = {}
expect(fake_duplicate_job).to receive(:duplicate?).and_return(true)
@@ -83,6 +85,7 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name|
allow(fake_duplicate_job).to(
receive(:check!).with(time_diff.to_i).and_return('the jid'))
allow(fake_duplicate_job).to receive(:idempotent?).and_return(true)
+ allow(fake_duplicate_job).to receive(:update_latest_wal_location!)
job_hash = {}
expect(fake_duplicate_job).to receive(:duplicate?).and_return(true)
@@ -105,6 +108,13 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name|
allow(fake_duplicate_job).to receive(:options).and_return({})
allow(fake_duplicate_job).to receive(:existing_jid).and_return('the jid')
allow(fake_duplicate_job).to receive(:idempotent?).and_return(true)
+ allow(fake_duplicate_job).to receive(:update_latest_wal_location!)
+ end
+
+ it 'updates latest wal location' do
+ expect(fake_duplicate_job).to receive(:update_latest_wal_location!)
+
+ strategy.schedule({ 'jid' => 'new jid' }) {}
end
it 'drops the job' do
@@ -136,4 +146,46 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name|
end
end
end
+
+ describe '#perform' do
+ let(:proc) { -> {} }
+ let(:job) { { 'jid' => 'new jid', 'wal_locations' => { 'main' => '0/1234', 'ci' => '0/1234' } } }
+ let(:wal_locations) do
+ {
+ main: '0/D525E3A8',
+ ci: 'AB/12345'
+ }
+ end
+
+ before do
+ allow(fake_duplicate_job).to receive(:delete!)
+ allow(fake_duplicate_job).to receive(:latest_wal_locations).and_return( wal_locations )
+ end
+
+ it 'updates job hash with dedup_wal_locations' do
+ strategy.perform(job) do
+ proc.call
+ end
+
+ expect(job['dedup_wal_locations']).to eq(wal_locations)
+ end
+
+ shared_examples 'does not update job hash' do
+ it 'does not update job hash with dedup_wal_locations' do
+ strategy.perform(job) do
+ proc.call
+ end
+
+ expect(job).not_to include('dedup_wal_locations')
+ end
+ end
+
+ context 'when latest_wal_location is empty' do
+ before do
+ allow(fake_duplicate_job).to receive(:latest_wal_locations).and_return( {} )
+ end
+
+ include_examples 'does not update job hash'
+ end
+ end
end
diff --git a/spec/support/shared_examples/mailers/notify_shared_examples.rb b/spec/support/shared_examples/mailers/notify_shared_examples.rb
index b10ebb4d2a3..e1f7a9030e2 100644
--- a/spec/support/shared_examples/mailers/notify_shared_examples.rb
+++ b/spec/support/shared_examples/mailers/notify_shared_examples.rb
@@ -2,7 +2,7 @@
RSpec.shared_examples 'a multiple recipients email' do
it 'is sent to the given recipient' do
- is_expected.to deliver_to recipient.notification_email
+ is_expected.to deliver_to recipient.notification_email_or_default
end
end
@@ -21,7 +21,7 @@ end
RSpec.shared_examples 'an email sent to a user' do
it 'is sent to user\'s global notification email address' do
- expect(subject).to deliver_to(recipient.notification_email)
+ expect(subject).to deliver_to(recipient.notification_email_or_default)
end
context 'with group notification email' do
@@ -227,7 +227,7 @@ RSpec.shared_examples 'a note email' do
aggregate_failures do
expect(sender.display_name).to eq("#{note_author.name} (@#{note_author.username})")
expect(sender.address).to eq(gitlab_sender)
- expect(subject).to deliver_to(recipient.notification_email)
+ expect(subject).to deliver_to(recipient.notification_email_or_default)
end
end
diff --git a/spec/support/shared_examples/models/concerns/featurable_shared_examples.rb b/spec/support/shared_examples/models/concerns/featurable_shared_examples.rb
new file mode 100644
index 00000000000..2f165ef604f
--- /dev/null
+++ b/spec/support/shared_examples/models/concerns/featurable_shared_examples.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'access level validation' do |features|
+ features.each do |feature|
+ it "does not allow public access level for #{feature}" do
+ field = "#{feature}_access_level".to_sym
+ container_features.update_attribute(field, ProjectFeature::PUBLIC)
+
+ expect(container_features.valid?).to be_falsy, "#{field} failed"
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/concerns/sanitizable_shared_examples.rb b/spec/support/shared_examples/models/concerns/sanitizable_shared_examples.rb
new file mode 100644
index 00000000000..ed94a71892d
--- /dev/null
+++ b/spec/support/shared_examples/models/concerns/sanitizable_shared_examples.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'sanitizable' do |factory, fields|
+ let(:attributes) { fields.to_h { |field| [field, input] } }
+
+ it 'includes Sanitizable' do
+ expect(described_class).to include(Sanitizable)
+ end
+
+ fields.each do |field|
+ subject do
+ record = build(factory, attributes)
+ record.valid?
+
+ record.public_send(field)
+ end
+
+ describe "##{field}" do
+ context 'when input includes javascript tags' do
+ let(:input) { 'hello<script>alert(1)</script>' }
+
+ it 'gets sanitized' do
+ expect(subject).to eq('hello')
+ end
+ end
+ end
+
+ describe "##{field} validation" do
+ context 'when input contains pre-escaped html entities' do
+ let_it_be(:input) { '&lt;script&gt;alert(1)&lt;/script&gt;' }
+
+ subject { build(factory, attributes) }
+
+ it 'is not valid', :aggregate_failures do
+ expect(subject).not_to be_valid
+ expect(subject.errors.details[field].flat_map(&:values)).to include('cannot contain escaped HTML entities')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/member_shared_examples.rb b/spec/support/shared_examples/models/member_shared_examples.rb
index c111d250d34..56c202cb228 100644
--- a/spec/support/shared_examples/models/member_shared_examples.rb
+++ b/spec/support/shared_examples/models/member_shared_examples.rb
@@ -300,8 +300,21 @@ RSpec.shared_examples_for "member creation" do
end
end
end
+end
+
+RSpec.shared_examples_for "bulk member creation" do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:admin) { create(:admin) }
+
+ describe '#execute' do
+ it 'raises an error when exiting_members is not passed in the args hash' do
+ expect do
+ described_class.new(source, user, :maintainer, current_user: user).execute
+ end.to raise_error(ArgumentError, 'existing_members must be included in the args hash')
+ end
+ end
- describe '.add_users' do
+ describe '.add_users', :aggregate_failures do
let_it_be(:user1) { create(:user) }
let_it_be(:user2) { create(:user) }
@@ -310,8 +323,8 @@ RSpec.shared_examples_for "member creation" do
expect(members).to be_a Array
expect(members.size).to eq(2)
- expect(members.first).to be_a member_type
- expect(members.first).to be_persisted
+ expect(members).to all(be_a(member_type))
+ expect(members).to all(be_persisted)
end
it 'returns an empty array' do
@@ -329,5 +342,42 @@ RSpec.shared_examples_for "member creation" do
expect(members.size).to eq(4)
expect(members.first).to be_invite
end
+
+ context 'with de-duplication' do
+ it 'with the same user by id and user' do
+ members = described_class.add_users(source, [user1.id, user1, user1.id, user2, user2.id, user2], :maintainer)
+
+ expect(members).to be_a Array
+ expect(members.size).to eq(2)
+ expect(members).to all(be_a(member_type))
+ expect(members).to all(be_persisted)
+ end
+
+ it 'with the same user sent more than once' do
+ members = described_class.add_users(source, [user1, user1], :maintainer)
+
+ expect(members).to be_a Array
+ expect(members.size).to eq(1)
+ expect(members).to all(be_a(member_type))
+ expect(members).to all(be_persisted)
+ end
+ end
+
+ context 'when a member already exists' do
+ before do
+ source.add_user(user1, :developer)
+ end
+
+ it 'supports existing users as expected' do
+ user3 = create(:user)
+
+ members = described_class.add_users(source, [user1.id, user2, user3.id], :maintainer)
+
+ expect(members).to be_a Array
+ expect(members.size).to eq(3)
+ expect(members).to all(be_a(member_type))
+ expect(members).to all(be_persisted)
+ end
+ end
end
end
diff --git a/spec/support/shared_examples/models/mentionable_shared_examples.rb b/spec/support/shared_examples/models/mentionable_shared_examples.rb
index 07c5f730e95..e23658d1774 100644
--- a/spec/support/shared_examples/models/mentionable_shared_examples.rb
+++ b/spec/support/shared_examples/models/mentionable_shared_examples.rb
@@ -207,7 +207,7 @@ RSpec.shared_examples 'an editable mentionable' do
end
RSpec.shared_examples 'mentions in description' do |mentionable_type|
- shared_examples 'when storing user mentions' do
+ context 'when storing user mentions' do
before do
mentionable.store_mentions!
end
@@ -238,26 +238,10 @@ RSpec.shared_examples 'mentions in description' do |mentionable_type|
end
end
end
-
- context 'when store_mentions_without_subtransaction is enabled' do
- before do
- stub_feature_flags(store_mentions_without_subtransaction: true)
- end
-
- it_behaves_like 'when storing user mentions'
- end
-
- context 'when store_mentions_without_subtransaction is disabled' do
- before do
- stub_feature_flags(store_mentions_without_subtransaction: false)
- end
-
- it_behaves_like 'when storing user mentions'
- end
end
RSpec.shared_examples 'mentions in notes' do |mentionable_type|
- shared_examples 'when mentionable notes contain mentions' do
+ context 'when mentionable notes contain mentions' do
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:group) { create(:group) }
@@ -277,22 +261,6 @@ RSpec.shared_examples 'mentions in notes' do |mentionable_type|
expect(mentionable.referenced_groups(user)).to eq [group]
end
end
-
- context 'when store_mentions_without_subtransaction is enabled' do
- before do
- stub_feature_flags(store_mentions_without_subtransaction: true)
- end
-
- it_behaves_like 'when mentionable notes contain mentions'
- end
-
- context 'when store_mentions_without_subtransaction is disabled' do
- before do
- stub_feature_flags(store_mentions_without_subtransaction: false)
- end
-
- it_behaves_like 'when mentionable notes contain mentions'
- end
end
RSpec.shared_examples 'load mentions from DB' do |mentionable_type|
diff --git a/spec/support/shared_examples/namespaces/traversal_scope_examples.rb b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb
index 4d328c03641..74b1bacc560 100644
--- a/spec/support/shared_examples/namespaces/traversal_scope_examples.rb
+++ b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb
@@ -31,6 +31,131 @@ RSpec.shared_examples 'namespace traversal scopes' do
it { expect(subject.where_values_hash).not_to have_key(:type) }
end
+ describe '.order_by_depth' do
+ subject { described_class.where(id: [group_1, nested_group_1, deep_nested_group_1]).order_by_depth(direction) }
+
+ context 'ascending' do
+ let(:direction) { :asc }
+
+ it { is_expected.to eq [deep_nested_group_1, nested_group_1, group_1] }
+ end
+
+ context 'descending' do
+ let(:direction) { :desc }
+
+ it { is_expected.to eq [group_1, nested_group_1, deep_nested_group_1] }
+ end
+ end
+
+ describe '.normal_select' do
+ let(:query_result) { described_class.where(id: group_1).normal_select }
+
+ subject { query_result.column_names }
+
+ it { is_expected.to eq described_class.column_names }
+ end
+
+ shared_examples '.self_and_ancestors' do
+ subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_ancestors }
+
+ it { is_expected.to contain_exactly(group_1, nested_group_1, group_2, nested_group_2) }
+
+ context 'when include_self is false' do
+ subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_ancestors(include_self: false) }
+
+ it { is_expected.to contain_exactly(group_1, group_2) }
+ end
+
+ context 'when hierarchy_order is ascending' do
+ subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_ancestors(hierarchy_order: :asc) }
+
+ # Recursive order per level is not defined.
+ it { is_expected.to contain_exactly(nested_group_1, nested_group_2, group_1, group_2) }
+ it { expect(subject[0, 2]).to contain_exactly(nested_group_1, nested_group_2) }
+ it { expect(subject[2, 2]).to contain_exactly(group_1, group_2) }
+ end
+
+ context 'when hierarchy_order is descending' do
+ subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_ancestors(hierarchy_order: :desc) }
+
+ # Recursive order per level is not defined.
+ it { is_expected.to contain_exactly(nested_group_1, nested_group_2, group_1, group_2) }
+ it { expect(subject[0, 2]).to contain_exactly(group_1, group_2) }
+ it { expect(subject[2, 2]).to contain_exactly(nested_group_1, nested_group_2) }
+ end
+ end
+
+ describe '.self_and_ancestors' do
+ context "use_traversal_ids_ancestor_scopes feature flag is true" do
+ before do
+ stub_feature_flags(use_traversal_ids: true)
+ stub_feature_flags(use_traversal_ids_for_ancestor_scopes: true)
+ end
+
+ it_behaves_like '.self_and_ancestors'
+
+ it 'not make recursive queries' do
+ expect { described_class.where(id: [nested_group_1]).self_and_ancestors.load }.not_to make_queries_matching(/WITH RECURSIVE/)
+ end
+ end
+
+ context "use_traversal_ids_ancestor_scopes feature flag is false" do
+ before do
+ stub_feature_flags(use_traversal_ids_for_ancestor_scopes: false)
+ end
+
+ it_behaves_like '.self_and_ancestors'
+
+ it 'make recursive queries' do
+ expect { described_class.where(id: [nested_group_1]).self_and_ancestors.load }.to make_queries_matching(/WITH RECURSIVE/)
+ end
+ end
+ end
+
+ shared_examples '.self_and_ancestor_ids' do
+ subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_ancestor_ids.pluck(:id) }
+
+ it { is_expected.to contain_exactly(group_1.id, nested_group_1.id, group_2.id, nested_group_2.id) }
+
+ context 'when include_self is false' do
+ subject do
+ described_class
+ .where(id: [nested_group_1, nested_group_2])
+ .self_and_ancestor_ids(include_self: false)
+ .pluck(:id)
+ end
+
+ it { is_expected.to contain_exactly(group_1.id, group_2.id) }
+ end
+ end
+
+ describe '.self_and_ancestor_ids' do
+ context "use_traversal_ids_ancestor_scopes feature flag is true" do
+ before do
+ stub_feature_flags(use_traversal_ids: true)
+ stub_feature_flags(use_traversal_ids_for_ancestor_scopes: true)
+ end
+
+ it_behaves_like '.self_and_ancestor_ids'
+
+ it 'make recursive queries' do
+ expect { described_class.where(id: [nested_group_1]).self_and_ancestor_ids.load }.not_to make_queries_matching(/WITH RECURSIVE/)
+ end
+ end
+
+ context "use_traversal_ids_ancestor_scopes feature flag is false" do
+ before do
+ stub_feature_flags(use_traversal_ids_for_ancestor_scopes: false)
+ end
+
+ it_behaves_like '.self_and_ancestor_ids'
+
+ it 'make recursive queries' do
+ expect { described_class.where(id: [nested_group_1]).self_and_ancestor_ids.load }.to make_queries_matching(/WITH RECURSIVE/)
+ end
+ end
+ end
+
describe '.self_and_descendants' do
subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_descendants }
diff --git a/spec/support/shared_examples/requests/api/helm_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/helm_packages_shared_examples.rb
index 1ad38a17f9c..acbcf4f7f3d 100644
--- a/spec/support/shared_examples/requests/api/helm_packages_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/helm_packages_shared_examples.rb
@@ -36,8 +36,8 @@ RSpec.shared_examples 'process helm service index request' do |user_type, status
expect(yaml_response.keys).to contain_exactly('apiVersion', 'entries', 'generated', 'serverInfo')
expect(yaml_response['entries']).to be_a(Hash)
- expect(yaml_response['entries'].keys).to contain_exactly(package.name)
- expect(yaml_response['serverInfo']).to eq({ 'contextPath' => "/api/v4/projects/#{project.id}/packages/helm" })
+ expect(yaml_response['entries'].keys).to contain_exactly(package.name, package2.name)
+ expect(yaml_response['serverInfo']).to eq({ 'contextPath' => "/api/v4/projects/#{project_id}/packages/helm" })
package_entry = yaml_response['entries'][package.name]
@@ -45,6 +45,14 @@ RSpec.shared_examples 'process helm service index request' do |user_type, status
expect(package_entry.first.keys).to contain_exactly('name', 'version', 'apiVersion', 'created', 'digest', 'urls')
expect(package_entry.first['digest']).to eq('fd2b2fa0329e80a2a602c2bb3b40608bcd6ee5cf96cf46fd0d2800a4c129c9db')
expect(package_entry.first['urls']).to eq(["charts/#{package.name}-#{package.version}.tgz"])
+
+ package_entry = yaml_response['entries'][package2.name]
+
+ expect(package_entry.length).to eq(1)
+ expect(package_entry.first.keys).to contain_exactly('name', 'version', 'apiVersion', 'created', 'digest', 'urls', 'description')
+ expect(package_entry.first['digest']).to eq('file2')
+ expect(package_entry.first['description']).to eq('hello from stable channel')
+ expect(package_entry.first['urls']).to eq(['charts/filename2.tgz'])
end
end
end
@@ -174,6 +182,13 @@ RSpec.shared_examples 'process helm download content request' do |user_type, sta
context "for user type #{user_type}" do
before do
project.send("add_#{user_type}", user) if user_type != :anonymous && user_type != :not_a_member
+
+ expect_next_found_instance_of(::Packages::PackageFile) do |package_file|
+ expect(package_file).to receive(:file).and_wrap_original do |m, *args|
+ expect(package_file.id).to eq(package_file2.id)
+ m.call(*args)
+ end
+ end
end
it_behaves_like 'a package tracking event', 'API::HelmPackages', 'pull_package'
@@ -189,7 +204,7 @@ end
RSpec.shared_examples 'rejects helm access with unknown project id' do
context 'with an unknown project' do
- let(:project) { OpenStruct.new(id: 1234567890) }
+ let(:project_id) { 1234567890 }
context 'as anonymous' do
it_behaves_like 'rejects helm packages access', :anonymous, :unauthorized
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 0390e60747f..2af7b616659 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
@@ -21,11 +21,24 @@ RSpec.shared_examples 'handling get metadata requests' do |scope: :project|
expect(response).to match_response_schema('public_api/v4/packages/npm_package')
expect(json_response['name']).to eq(package.name)
expect(json_response['versions'][package.version]).to match_schema('public_api/v4/packages/npm_package_version')
- ::Packages::Npm::PackagePresenter::NPM_VALID_DEPENDENCY_TYPES.each do |dependency_type|
+ ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type|
expect(json_response.dig('versions', package.version, dependency_type.to_s)).to be_any
end
expect(json_response['dist-tags']).to match_schema('public_api/v4/packages/npm_package_tags')
end
+
+ it 'avoids N+1 database queries' do
+ control = ActiveRecord::QueryRecorder.new { get(url, headers: headers) }
+
+ create_list(:npm_package, 5, project: project, name: package_name).each do |npm_package|
+ ::Packages::DependencyLink.dependency_types.keys.each do |dependency_type|
+ create(:packages_dependency_link, package: package, dependency_type: dependency_type)
+ end
+ end
+
+ # query count can slightly change between the examples so we're using a custom threshold
+ expect { get(url, headers: headers) }.not_to exceed_query_limit(control).with_threshold(4)
+ end
end
shared_examples 'reject metadata request' do |status:|
diff --git a/spec/support/shared_examples/requests/api/packages_shared_examples.rb b/spec/support/shared_examples/requests/api/packages_shared_examples.rb
index ecde4ee8565..eb650b7a09f 100644
--- a/spec/support/shared_examples/requests/api/packages_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/packages_shared_examples.rb
@@ -153,3 +153,15 @@ RSpec.shared_examples 'a package tracking event' do |category, action|
expect_snowplow_event(category: category, action: action, **snowplow_gitlab_standard_context)
end
end
+
+RSpec.shared_examples 'not a package tracking event' do
+ before do
+ stub_feature_flags(collect_package_events: true)
+ end
+
+ it 'does not create a gitlab tracking event', :snowplow, :aggregate_failures do
+ expect { subject }.not_to change { Packages::Event.count }
+
+ expect_no_snowplow_event
+ end
+end
diff --git a/spec/support/shared_examples/requests/rack_attack_shared_examples.rb b/spec/support/shared_examples/requests/rack_attack_shared_examples.rb
index 95817624658..2a19ff6f590 100644
--- a/spec/support/shared_examples/requests/rack_attack_shared_examples.rb
+++ b/spec/support/shared_examples/requests/rack_attack_shared_examples.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
#
# Requires let variables:
-# * throttle_setting_prefix: "throttle_authenticated_api", "throttle_authenticated_web", "throttle_protected_paths", "throttle_authenticated_packages_api"
+# * throttle_setting_prefix: "throttle_authenticated_api", "throttle_authenticated_web", "throttle_protected_paths", "throttle_authenticated_packages_api", "throttle_authenticated_git_lfs", "throttle_authenticated_files_api"
# * request_method
# * request_args
# * other_user_request_args
@@ -14,7 +14,9 @@ RSpec.shared_examples 'rate-limited token-authenticated requests' do
"throttle_protected_paths" => "throttle_authenticated_protected_paths_api",
"throttle_authenticated_api" => "throttle_authenticated_api",
"throttle_authenticated_web" => "throttle_authenticated_web",
- "throttle_authenticated_packages_api" => "throttle_authenticated_packages_api"
+ "throttle_authenticated_packages_api" => "throttle_authenticated_packages_api",
+ "throttle_authenticated_git_lfs" => "throttle_authenticated_git_lfs",
+ "throttle_authenticated_files_api" => "throttle_authenticated_files_api"
}
end
@@ -165,7 +167,7 @@ RSpec.shared_examples 'rate-limited token-authenticated requests' do
end
# Requires let variables:
-# * throttle_setting_prefix: "throttle_authenticated_web" or "throttle_protected_paths"
+# * throttle_setting_prefix: "throttle_authenticated_web", "throttle_protected_paths", "throttle_authenticated_git_lfs"
# * user
# * url_that_requires_authentication
# * request_method
@@ -176,7 +178,8 @@ RSpec.shared_examples 'rate-limited web authenticated requests' do
let(:throttle_types) do
{
"throttle_protected_paths" => "throttle_authenticated_protected_paths_web",
- "throttle_authenticated_web" => "throttle_authenticated_web"
+ "throttle_authenticated_web" => "throttle_authenticated_web",
+ "throttle_authenticated_git_lfs" => "throttle_authenticated_git_lfs"
}
end
@@ -385,3 +388,194 @@ RSpec.shared_examples 'tracking when dry-run mode is set' do
end
end
end
+
+# Requires let variables:
+# * throttle_name: "throttle_unauthenticated_api", "throttle_unauthenticated_web"
+# * throttle_setting_prefix: "throttle_unauthenticated_api", "throttle_unauthenticated"
+# * url_that_does_not_require_authentication
+# * url_that_is_not_matched
+# * requests_per_period
+# * period_in_seconds
+# * period
+RSpec.shared_examples 'rate-limited unauthenticated requests' do
+ before do
+ # Set low limits
+ settings_to_set[:"#{throttle_setting_prefix}_requests_per_period"] = requests_per_period
+ settings_to_set[:"#{throttle_setting_prefix}_period_in_seconds"] = period_in_seconds
+ end
+
+ context 'when the throttle is enabled' do
+ before do
+ settings_to_set[:"#{throttle_setting_prefix}_enabled"] = true
+ stub_application_setting(settings_to_set)
+ end
+
+ it 'rejects requests over the rate limit' do
+ # At first, allow requests under the rate limit.
+ requests_per_period.times do
+ get url_that_does_not_require_authentication
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ # the last straw
+ expect_rejection { get url_that_does_not_require_authentication }
+ end
+
+ context 'with custom response text' do
+ before do
+ stub_application_setting(rate_limiting_response_text: 'Custom response')
+ end
+
+ it 'rejects requests over the rate limit' do
+ # At first, allow requests under the rate limit.
+ requests_per_period.times do
+ get url_that_does_not_require_authentication
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ # the last straw
+ expect_rejection { get url_that_does_not_require_authentication }
+ expect(response.body).to eq("Custom response\n")
+ end
+ end
+
+ it 'allows requests after throttling and then waiting for the next period' do
+ requests_per_period.times do
+ get url_that_does_not_require_authentication
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ expect_rejection { get url_that_does_not_require_authentication }
+
+ travel_to(period.from_now) do
+ requests_per_period.times do
+ get url_that_does_not_require_authentication
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ expect_rejection { get url_that_does_not_require_authentication }
+ end
+ end
+
+ it 'counts requests from different IPs separately' do
+ requests_per_period.times do
+ get url_that_does_not_require_authentication
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ expect_next_instance_of(Rack::Attack::Request) do |instance|
+ expect(instance).to receive(:ip).at_least(:once).and_return('1.2.3.4')
+ end
+
+ # would be over limit for the same IP
+ get url_that_does_not_require_authentication
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ context 'when the request is not matched by the throttle' do
+ it 'does not throttle the requests' do
+ (1 + requests_per_period).times do
+ get url_that_is_not_matched
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ context 'when the request is to the api internal endpoints' do
+ it 'allows requests over the rate limit' do
+ (1 + requests_per_period).times do
+ get '/api/v4/internal/check', params: { secret_token: Gitlab::Shell.secret_token }
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ context 'when the request is authenticated by a runner token' do
+ let(:request_jobs_url) { '/api/v4/jobs/request' }
+ let(:runner) { create(:ci_runner) }
+
+ it 'does not count as unauthenticated' do
+ (1 + requests_per_period).times do
+ post request_jobs_url, params: { token: runner.token }
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+ end
+
+ context 'when the request is to a health endpoint' do
+ let(:health_endpoint) { '/-/metrics' }
+
+ it 'does not throttle the requests' do
+ (1 + requests_per_period).times do
+ get health_endpoint
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ context 'when the request is to a container registry notification endpoint' do
+ let(:secret_token) { 'secret_token' }
+ let(:events) { [{ action: 'push' }] }
+ let(:registry_endpoint) { '/api/v4/container_registry_event/events' }
+ let(:registry_headers) { { 'Content-Type' => ::API::ContainerRegistryEvent::DOCKER_DISTRIBUTION_EVENTS_V1_JSON } }
+
+ before do
+ allow(Gitlab.config.registry).to receive(:notification_secret) { secret_token }
+
+ event = spy(:event)
+ allow(::ContainerRegistry::Event).to receive(:new).and_return(event)
+ allow(event).to receive(:supported?).and_return(true)
+ end
+
+ it 'does not throttle the requests' do
+ (1 + requests_per_period).times do
+ post registry_endpoint,
+ params: { events: events }.to_json,
+ headers: registry_headers.merge('Authorization' => secret_token)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ it 'logs RackAttack info into structured logs' do
+ requests_per_period.times do
+ get url_that_does_not_require_authentication
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ arguments = a_hash_including({
+ message: 'Rack_Attack',
+ env: :throttle,
+ remote_ip: '127.0.0.1',
+ request_method: 'GET',
+ path: url_that_does_not_require_authentication,
+ matched: throttle_name
+ })
+
+ expect(Gitlab::AuthLogger).to receive(:error).with(arguments)
+
+ get url_that_does_not_require_authentication
+ end
+
+ it_behaves_like 'tracking when dry-run mode is set' do
+ def do_request
+ get url_that_does_not_require_authentication
+ end
+ end
+ end
+
+ context 'when the throttle is disabled' do
+ before do
+ settings_to_set[:"#{throttle_setting_prefix}_enabled"] = false
+ stub_application_setting(settings_to_set)
+ end
+
+ it 'allows requests over the rate limit' do
+ (1 + requests_per_period).times do
+ get url_that_does_not_require_authentication
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/services/dependency_proxy_ttl_policies_shared_examples.rb b/spec/support/shared_examples/services/dependency_proxy_ttl_policies_shared_examples.rb
new file mode 100644
index 00000000000..f6692646ca8
--- /dev/null
+++ b/spec/support/shared_examples/services/dependency_proxy_ttl_policies_shared_examples.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'updating the dependency proxy image ttl policy attributes' do |from: {}, to:|
+ it_behaves_like 'not creating the dependency proxy image ttl policy'
+
+ it 'updates the dependency proxy image ttl policy' do
+ expect { subject }
+ .to change { group.dependency_proxy_image_ttl_policy.reload.enabled }.from(from[:enabled]).to(to[:enabled])
+ .and change { group.dependency_proxy_image_ttl_policy.reload.ttl }.from(from[:ttl]).to(to[:ttl])
+ end
+end
+
+RSpec.shared_examples 'not creating the dependency proxy image ttl policy' do
+ it "doesn't create the dependency proxy image ttl policy" do
+ expect { subject }.not_to change { DependencyProxy::ImageTtlGroupPolicy.count }
+ end
+end
+
+RSpec.shared_examples 'creating the dependency proxy image ttl policy' do
+ it 'creates a new package setting' do
+ expect { subject }.to change { DependencyProxy::ImageTtlGroupPolicy.count }.by(1)
+ end
+
+ it 'saves the settings' do
+ subject
+
+ expect(group.dependency_proxy_image_ttl_policy).to have_attributes(
+ enabled: ttl_policy[:enabled],
+ ttl: ttl_policy[:ttl]
+ )
+ end
+
+ it_behaves_like 'returning a success'
+end
diff --git a/spec/support/shared_examples/services/incident_shared_examples.rb b/spec/support/shared_examples/services/incident_shared_examples.rb
index 9fced12b543..0277cce975a 100644
--- a/spec/support/shared_examples/services/incident_shared_examples.rb
+++ b/spec/support/shared_examples/services/incident_shared_examples.rb
@@ -13,6 +13,7 @@
RSpec.shared_examples 'incident issue' do
it 'has incident as issue type' do
expect(issue.issue_type).to eq('incident')
+ expect(issue.work_item_type.base_type).to eq('incident')
end
end
@@ -41,6 +42,7 @@ RSpec.shared_examples 'not an incident issue' do
it 'has not incident as issue type' do
expect(issue.issue_type).not_to eq('incident')
+ expect(issue.work_item_type.base_type).not_to eq('incident')
end
it 'has not an incident label' do
diff --git a/spec/support/shared_examples/services/users/dismiss_user_callout_service_shared_examples.rb b/spec/support/shared_examples/services/users/dismiss_user_callout_service_shared_examples.rb
new file mode 100644
index 00000000000..09820593cdb
--- /dev/null
+++ b/spec/support/shared_examples/services/users/dismiss_user_callout_service_shared_examples.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples_for 'dismissing user callout' do |model|
+ it 'creates a new user callout' do
+ expect { execute }.to change { model.count }.by(1)
+ end
+
+ it 'returns a user callout' do
+ expect(execute).to be_an_instance_of(model)
+ end
+
+ it 'sets the dismissed_at attribute to current time' do
+ freeze_time do
+ expect(execute).to have_attributes(dismissed_at: Time.current)
+ end
+ end
+
+ it 'updates an existing callout dismissed_at time' do
+ freeze_time do
+ old_time = 1.day.ago
+ new_time = Time.current
+ attributes = params.merge(dismissed_at: old_time, user: user)
+ existing_callout = create("#{model.name.split('::').last.underscore}".to_sym, attributes)
+
+ expect { execute }.to change { existing_callout.reload.dismissed_at }.from(old_time).to(new_time)
+ end
+ end
+
+ it 'does not update an invalid record with dismissed_at time', :aggregate_failures do
+ callout = described_class.new(
+ container: nil, current_user: user, params: { feature_name: nil }
+ ).execute
+
+ expect(callout.dismissed_at).to be_nil
+ expect(callout).to be_invalid
+ end
+end
diff --git a/spec/support/shared_examples/work_item_base_types_importer.rb b/spec/support/shared_examples/work_item_base_types_importer.rb
new file mode 100644
index 00000000000..7d652be8d05
--- /dev/null
+++ b/spec/support/shared_examples/work_item_base_types_importer.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'work item base types importer' do
+ it 'creates all base work item types' do
+ # Fixtures need to run on a pristine DB, but the test suite preloads the base types before(:suite)
+ WorkItem::Type.delete_all
+
+ expect { subject }.to change(WorkItem::Type, :count).from(0).to(WorkItem::Type::BASE_TYPES.count)
+ end
+end
diff --git a/spec/support_specs/database/prevent_cross_database_modification_spec.rb b/spec/support_specs/database/prevent_cross_database_modification_spec.rb
index 4fd55d59db0..e86559bb14a 100644
--- a/spec/support_specs/database/prevent_cross_database_modification_spec.rb
+++ b/spec/support_specs/database/prevent_cross_database_modification_spec.rb
@@ -66,7 +66,7 @@ RSpec.describe 'Database::PreventCrossDatabaseModification' do
pipeline.touch
end
end
- end.to raise_error /Cross-database data modification queries/
+ end.to raise_error /Cross-database data modification/
end
end
@@ -84,7 +84,7 @@ RSpec.describe 'Database::PreventCrossDatabaseModification' do
context 'when data modification happens in a transaction' do
it 'raises error' do
Project.transaction do
- expect { run_queries }.to raise_error /Cross-database data modification queries/
+ expect { run_queries }.to raise_error /Cross-database data modification/
end
end
@@ -93,12 +93,31 @@ RSpec.describe 'Database::PreventCrossDatabaseModification' do
Project.transaction(requires_new: true) do
project.touch
Project.transaction(requires_new: true) do
- expect { pipeline.touch }.to raise_error /Cross-database data modification queries/
+ expect { pipeline.touch }.to raise_error /Cross-database data modification/
end
end
end
end
end
+
+ context 'when executing a SELECT FOR UPDATE query' do
+ def run_queries
+ project.touch
+ pipeline.lock!
+ end
+
+ context 'outside transaction' do
+ it { expect { run_queries }.not_to raise_error }
+ end
+
+ context 'when data modification happens in a transaction' do
+ it 'raises error' do
+ Project.transaction do
+ expect { run_queries }.to raise_error /Cross-database data modification/
+ end
+ end
+ end
+ end
end
context 'when CI association is modified through project' do
@@ -127,7 +146,7 @@ RSpec.describe 'Database::PreventCrossDatabaseModification' do
ApplicationRecord.transaction do
create(:ci_pipeline)
end
- end.to raise_error /Cross-database data modification queries/
+ end.to raise_error /Cross-database data modification/
end
it 'skips raising error on factory creation' do
diff --git a/spec/support_specs/database/prevent_cross_joins_spec.rb b/spec/support_specs/database/prevent_cross_joins_spec.rb
index dd4ed9c40b8..e9a95fe77a5 100644
--- a/spec/support_specs/database/prevent_cross_joins_spec.rb
+++ b/spec/support_specs/database/prevent_cross_joins_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Database::PreventCrossJoins do
- context 'when running in :prevent_cross_joins scope', :prevent_cross_joins do
+ context 'when running in a default scope' do
context 'when only non-CI tables are used' do
it 'does not raise exception' do
expect { main_only_query }.not_to raise_error
@@ -24,23 +24,33 @@ RSpec.describe Database::PreventCrossJoins do
context 'when allow_cross_joins_across_databases is used' do
it 'does not raise exception' do
- Gitlab::Database.allow_cross_joins_across_databases(url: 'http://issue-url')
+ expect { main_and_ci_query_allowlisted }.not_to raise_error
+ end
+ end
- expect { main_and_ci_query }.not_to raise_error
+ context 'when allow_cross_joins_across_databases is used' do
+ it 'does not raise exception' do
+ expect { main_and_ci_query_allowlist_nested }.not_to raise_error
end
end
end
end
- context 'when running in a default scope' do
- context 'when CI and non-CI tables are used' do
- it 'does not raise exception' do
- expect { main_and_ci_query }.not_to raise_error
- end
+ private
+
+ def main_and_ci_query_allowlisted
+ Gitlab::Database.allow_cross_joins_across_databases(url: 'http://issue-url') do
+ main_and_ci_query
end
end
- private
+ def main_and_ci_query_allowlist_nested
+ Gitlab::Database.allow_cross_joins_across_databases(url: 'http://issue-url') do
+ main_and_ci_query_allowlisted
+
+ main_and_ci_query
+ end
+ end
def main_only_query
Issue.joins(:project).last
diff --git a/spec/tasks/gitlab/db_rake_spec.rb b/spec/tasks/gitlab/db_rake_spec.rb
index 8e98a42510e..91cd09fc6e6 100644
--- a/spec/tasks/gitlab/db_rake_spec.rb
+++ b/spec/tasks/gitlab/db_rake_spec.rb
@@ -129,13 +129,14 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do
let(:output) { StringIO.new }
before do
- structure_files = %w[db/structure.sql db/ci_structure.sql]
+ structure_files = %w[structure.sql ci_structure.sql]
allow(File).to receive(:open).and_call_original
- structure_files.each do |structure_file|
+ structure_files.each do |structure_file_name|
+ structure_file = File.join(ActiveRecord::Tasks::DatabaseTasks.db_dir, structure_file_name)
stub_file_read(structure_file, content: input)
- allow(File).to receive(:open).with(Rails.root.join(structure_file).to_s, any_args).and_yield(output)
+ allow(File).to receive(:open).with(structure_file.to_s, any_args).and_yield(output)
end
end
diff --git a/spec/tasks/gitlab/product_intelligence_rake_spec.rb b/spec/tasks/gitlab/product_intelligence_rake_spec.rb
deleted file mode 100644
index 029e181ad06..00000000000
--- a/spec/tasks/gitlab/product_intelligence_rake_spec.rb
+++ /dev/null
@@ -1,80 +0,0 @@
-# frozen_string_literal: true
-
-require 'rake_helper'
-
-RSpec.describe 'gitlab:product_intelligence:activate_metrics', :silence_stdout do
- def fake_metric(key_path, milestone: 'test_milestone', status: 'implemented')
- Gitlab::Usage::MetricDefinition.new(key_path, { key_path: key_path, milestone: milestone, status: status })
- end
-
- before do
- Rake.application.rake_require 'tasks/gitlab/product_intelligence'
- stub_warn_user_is_not_gitlab
- end
-
- describe 'activate_metrics' do
- it 'fails if the MILESTONE env var is not set' do
- stub_env('MILESTONE' => nil)
-
- expect { run_rake_task('gitlab:product_intelligence:activate_metrics') }.to raise_error(RuntimeError, 'Please supply the MILESTONE env var')
- end
-
- context 'with MILESTONE env var' do
- subject do
- updated_metrics = []
-
- file = double('file')
- allow(file).to receive(:<<) { |contents| updated_metrics << YAML.safe_load(contents) }
- allow(File).to receive(:open).and_yield(file)
-
- stub_env('MILESTONE' => 'test_milestone')
- run_rake_task('gitlab:product_intelligence:activate_metrics')
-
- updated_metrics
- end
-
- let(:metric_definitions) do
- {
- matching_metric: fake_metric('matching_metric'),
- matching_metric2: fake_metric('matching_metric2'),
- other_status_metric: fake_metric('other_status_metric', status: 'deprecated'),
- other_milestone_metric: fake_metric('other_milestone_metric', milestone: 'other_milestone')
- }
- end
-
- before do
- allow(Gitlab::Usage::MetricDefinition).to receive(:definitions).and_return(metric_definitions)
- end
-
- context 'with metric matching status and milestone' do
- it 'updates matching_metric yaml file' do
- expect(subject).to eq([
- { 'key_path' => 'matching_metric', 'milestone' => 'test_milestone', 'status' => 'data_available' },
- { 'key_path' => 'matching_metric2', 'milestone' => 'test_milestone', 'status' => 'data_available' }
- ])
- end
- end
-
- context 'without metrics definitions' do
- let(:metric_definitions) { {} }
-
- it 'runs successfully with no updates' do
- expect(subject).to eq([])
- end
- end
-
- context 'without matching metrics' do
- let(:metric_definitions) do
- {
- other_status_metric: fake_metric('other_status_metric', status: 'deprecated'),
- other_milestone_metric: fake_metric('other_milestone_metric', milestone: 'other_milestone')
- }
- end
-
- it 'runs successfully with no updates' do
- expect(subject).to eq([])
- end
- end
- end
- end
-end
diff --git a/spec/tooling/danger/project_helper_spec.rb b/spec/tooling/danger/project_helper_spec.rb
index f52c5e02544..c7715eb43fc 100644
--- a/spec/tooling/danger/project_helper_spec.rb
+++ b/spec/tooling/danger/project_helper_spec.rb
@@ -60,7 +60,6 @@ RSpec.describe Tooling::Danger::ProjectHelper do
'app/views/foo' | [:frontend]
'public/foo' | [:frontend]
'scripts/frontend/foo' | [:frontend]
- 'spec/javascripts/foo' | [:frontend]
'spec/frontend/bar' | [:frontend]
'spec/frontend_integration/bar' | [:frontend]
'vendor/assets/foo' | [:frontend]
@@ -73,7 +72,6 @@ RSpec.describe Tooling::Danger::ProjectHelper do
'ee/app/assets/foo' | [:frontend]
'ee/app/views/foo' | [:frontend]
- 'ee/spec/javascripts/foo' | [:frontend]
'ee/spec/frontend/bar' | [:frontend]
'ee/spec/frontend_integration/bar' | [:frontend]
@@ -220,7 +218,7 @@ RSpec.describe Tooling::Danger::ProjectHelper do
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: changelog, database, documentation, duplicate_yarn_dependencies, eslint, gitaly, karma, pajamas, pipeline, prettier, product_intelligence, utility_css, vue_shared_documentation')
+ expect(described_class.local_warning_message).to eq('==> Only the following Danger rules can be run locally: changelog, database, documentation, duplicate_yarn_dependencies, eslint, gitaly, pajamas, pipeline, prettier, product_intelligence, utility_css, vue_shared_documentation')
end
end
diff --git a/spec/tooling/graphql/docs/renderer_spec.rb b/spec/tooling/graphql/docs/renderer_spec.rb
index de5ec928921..1c9605304ff 100644
--- a/spec/tooling/graphql/docs/renderer_spec.rb
+++ b/spec/tooling/graphql/docs/renderer_spec.rb
@@ -535,8 +535,8 @@ RSpec.describe Tooling::Graphql::Docs::Renderer do
| Name | Type | Description |
| ---- | ---- | ----------- |
- | <a id="timeframeend"></a>`end` | [`Date!`](#date) | The end of the range. |
- | <a id="timeframestart"></a>`start` | [`Date!`](#date) | The start of the range. |
+ | <a id="timeframeend"></a>`end` | [`Date!`](#date) | End of the range. |
+ | <a id="timeframestart"></a>`start` | [`Date!`](#date) | Start of the range. |
DOC
end
diff --git a/spec/validators/gitlab/utils/zoom_url_validator_spec.rb b/spec/validators/gitlab/zoom_url_validator_spec.rb
index 392d8b3a2fe..308a6be78eb 100644
--- a/spec/validators/gitlab/utils/zoom_url_validator_spec.rb
+++ b/spec/validators/gitlab/zoom_url_validator_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Utils::ZoomUrlValidator do
+RSpec.describe Gitlab::ZoomUrlValidator do
let(:zoom_meeting) { build(:zoom_meeting) }
describe 'validations' do
diff --git a/spec/views/groups/group_members/index.html.haml_spec.rb b/spec/views/groups/group_members/index.html.haml_spec.rb
new file mode 100644
index 00000000000..8e190c24495
--- /dev/null
+++ b/spec/views/groups/group_members/index.html.haml_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'groups/group_members/index', :aggregate_failures do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+
+ before do
+ allow(view).to receive(:group_members_app_data).and_return({})
+ allow(view).to receive(:current_user).and_return(user)
+ assign(:group, group)
+ assign(:group_member, build(:group_member, group: group))
+ end
+
+ context 'when user can invite members for the group' do
+ before do
+ group.add_owner(user)
+ end
+
+ context 'when modal is enabled' do
+ it 'renders as expected' do
+ render
+
+ expect(rendered).to have_content('Group members')
+ expect(rendered).to have_content('You can invite a new member')
+
+ expect(rendered).to have_selector('.js-invite-group-trigger')
+ expect(rendered).to have_selector('.js-invite-members-trigger')
+ expect(response).to render_template(partial: 'groups/_invite_members_modal')
+
+ expect(rendered).not_to have_selector('#invite-member-tab')
+ expect(rendered).not_to have_selector('#invite-group-tab')
+ expect(response).not_to render_template(partial: 'shared/members/_invite_group')
+ end
+ end
+
+ context 'when modal is not enabled' do
+ before do
+ stub_feature_flags(invite_members_group_modal: false)
+ end
+
+ it 'renders as expected' do
+ render
+
+ expect(rendered).to have_content('Group members')
+ expect(rendered).to have_content('You can invite a new member')
+
+ expect(rendered).to have_selector('#invite-member-tab')
+ expect(rendered).to have_selector('#invite-group-tab')
+ expect(response).to render_template(partial: 'shared/members/_invite_group')
+
+ expect(rendered).not_to have_selector('.js-invite-group-trigger')
+ expect(rendered).not_to have_selector('.js-invite-members-trigger')
+ expect(response).not_to render_template(partial: 'groups/_invite_members_modal')
+ end
+ end
+ end
+
+ context 'when user can not invite members for the group' do
+ it 'renders as expected', :aggregate_failures do
+ render
+
+ expect(rendered).not_to have_content('Group members')
+ expect(rendered).not_to have_content('You can invite a new member')
+ end
+ end
+end
diff --git a/spec/views/help/instance_configuration.html.haml_spec.rb b/spec/views/help/instance_configuration.html.haml_spec.rb
index 7b431bb4180..c4542046a9d 100644
--- a/spec/views/help/instance_configuration.html.haml_spec.rb
+++ b/spec/views/help/instance_configuration.html.haml_spec.rb
@@ -9,6 +9,7 @@ RSpec.describe 'help/instance_configuration' do
let(:ssh_settings) { settings[:ssh_algorithms_hashes] }
before do
+ create(:plan, name: 'plan1', title: 'Plan 1')
assign(:instance_configuration, instance_configuration)
end
@@ -17,7 +18,9 @@ RSpec.describe 'help/instance_configuration' do
expect(rendered).to have_link(nil, href: '#ssh-host-keys-fingerprints') if ssh_settings.any?
expect(rendered).to have_link(nil, href: '#gitlab-pages')
- expect(rendered).to have_link(nil, href: '#gitlab-ci')
+ expect(rendered).to have_link(nil, href: '#size-limits')
+ expect(rendered).to have_link(nil, href: '#package-registry')
+ expect(rendered).to have_link(nil, href: '#rate-limits')
end
it 'has several sections' do
@@ -25,7 +28,9 @@ RSpec.describe 'help/instance_configuration' do
expect(rendered).to have_css('h2#ssh-host-keys-fingerprints') if ssh_settings.any?
expect(rendered).to have_css('h2#gitlab-pages')
- expect(rendered).to have_css('h2#gitlab-ci')
+ expect(rendered).to have_css('h2#size-limits')
+ expect(rendered).to have_css('h2#package-registry')
+ expect(rendered).to have_css('h2#rate-limits')
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 3afebfbedab..adfe1cee6d6 100644
--- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
@@ -68,8 +68,8 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
end
describe 'Learn GitLab' do
- it 'has a link to the learn GitLab experiment' do
- allow(view).to receive(:learn_gitlab_experiment_enabled?).and_return(true)
+ it 'has a link to the learn GitLab' do
+ allow(view).to receive(:learn_gitlab_enabled?).and_return(true)
allow_next_instance_of(LearnGitlab::Onboarding) do |onboarding|
expect(onboarding).to receive(:completed_percentage).and_return(20)
end
@@ -968,6 +968,32 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
end
end
end
+
+ describe 'Usage Quotas' do
+ context 'with project_storage_ui feature flag enabled' do
+ before do
+ stub_feature_flags(project_storage_ui: true)
+ end
+
+ it 'has a link to Usage Quotas' do
+ render
+
+ expect(rendered).to have_link('Usage Quotas', href: project_usage_quotas_path(project))
+ end
+ end
+
+ context 'with project_storage_ui feature flag disabled' do
+ before do
+ stub_feature_flags(project_storage_ui: false)
+ end
+
+ it 'does not have a link to Usage Quotas' do
+ render
+
+ expect(rendered).not_to have_link('Usage Quotas', href: project_usage_quotas_path(project))
+ end
+ end
+ end
end
describe 'Hidden menus' do
diff --git a/spec/views/profiles/notifications/show.html.haml_spec.rb b/spec/views/profiles/notifications/show.html.haml_spec.rb
new file mode 100644
index 00000000000..9cdf8124fcf
--- /dev/null
+++ b/spec/views/profiles/notifications/show.html.haml_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'profiles/notifications/show' do
+ let(:groups) { GroupsFinder.new(user).execute.page(1) }
+ let(:user) { create(:user) }
+
+ before do
+ assign(:group_notifications, [])
+ assign(:project_notifications, [])
+ assign(:user, user)
+ assign(:user_groups, groups)
+ allow(controller).to receive(:current_user).and_return(user)
+ allow(view).to receive(:experiment_enabled?)
+ end
+
+ context 'when there is no database value for User#notification_email' do
+ let(:option_default) { _('Use primary email (%{email})') % { email: user.email } }
+ let(:option_primary_email) { user.email }
+ let(:options) { [option_default, option_primary_email] }
+
+ it 'displays the correct elements' do
+ render
+
+ expect(rendered).to have_select('user_notification_email', options: options, selected: nil)
+ end
+ end
+end
diff --git a/spec/views/projects/diffs/_stats.html.haml_spec.rb b/spec/views/projects/diffs/_stats.html.haml_spec.rb
deleted file mode 100644
index f0580b50349..00000000000
--- a/spec/views/projects/diffs/_stats.html.haml_spec.rb
+++ /dev/null
@@ -1,58 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'projects/diffs/_stats.html.haml' do
- let(:project) { create(:project, :repository) }
- let(:commit) { project.commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') }
-
- def render_view
- render partial: "projects/diffs/stats", locals: { diff_files: commit.diffs.diff_files }
- end
-
- context 'when the commit contains several changes' do
- it 'uses plural for additions' do
- render_view
-
- expect(rendered).to have_text('additions')
- end
-
- it 'uses plural for deletions' do
- render_view
- end
- end
-
- context 'when the commit contains no addition and no deletions' do
- let(:commit) { project.commit('4cd80ccab63c82b4bad16faa5193fbd2aa06df40') }
-
- it 'uses plural for additions' do
- render_view
-
- expect(rendered).to have_text('additions')
- end
-
- it 'uses plural for deletions' do
- render_view
-
- expect(rendered).to have_text('deletions')
- end
- end
-
- context 'when the commit contains exactly one addition and one deletion' do
- let(:commit) { project.commit('08f22f255f082689c0d7d39d19205085311542bc') }
-
- it 'uses singular for additions' do
- render_view
-
- expect(rendered).to have_text('addition')
- expect(rendered).not_to have_text('additions')
- end
-
- it 'uses singular for deletions' do
- render_view
-
- expect(rendered).to have_text('deletion')
- expect(rendered).not_to have_text('deletions')
- end
- end
-end
diff --git a/spec/views/projects/empty.html.haml_spec.rb b/spec/views/projects/empty.html.haml_spec.rb
index 70da4fc9e27..416dfc10174 100644
--- a/spec/views/projects/empty.html.haml_spec.rb
+++ b/spec/views/projects/empty.html.haml_spec.rb
@@ -53,7 +53,7 @@ RSpec.describe 'projects/empty' do
it 'shows invite members info', :aggregate_failures do
render
- expect(rendered).to have_selector('[data-track-event=render]')
+ expect(rendered).to have_selector('[data-track-action=render]')
expect(rendered).to have_selector('[data-track-label=invite_members_empty_project]')
expect(rendered).to have_content('Invite your team')
expect(rendered).to have_content('Add members to this project and start collaborating with your team.')
diff --git a/spec/views/projects/merge_requests/_commits.html.haml_spec.rb b/spec/views/projects/merge_requests/_commits.html.haml_spec.rb
index fd77c4eb372..f0273c1716f 100644
--- a/spec/views/projects/merge_requests/_commits.html.haml_spec.rb
+++ b/spec/views/projects/merge_requests/_commits.html.haml_spec.rb
@@ -21,7 +21,7 @@ RSpec.describe 'projects/merge_requests/_commits.html.haml', :sidekiq_might_not_
controller.prepend_view_path('app/views/projects')
assign(:merge_request, merge_request)
- assign(:commits, merge_request.commits)
+ assign(:commits, merge_request.commits(load_from_gitaly: true))
assign(:hidden_commit_count, 0)
end
@@ -34,6 +34,12 @@ RSpec.describe 'projects/merge_requests/_commits.html.haml', :sidekiq_might_not_
expect(rendered).to have_link(href: href)
end
+ it 'shows signature verification badge' do
+ render
+
+ expect(rendered).to have_css('.gpg-status-box')
+ end
+
context 'when there are hidden commits' do
before do
assign(:hidden_commit_count, 1)
diff --git a/spec/views/projects/project_members/index.html.haml_spec.rb b/spec/views/projects/project_members/index.html.haml_spec.rb
new file mode 100644
index 00000000000..b9b0d57bcb5
--- /dev/null
+++ b/spec/views/projects/project_members/index.html.haml_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'projects/project_members/index', :aggregate_failures do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:source) { create(:project, :empty_repo) }
+ let_it_be(:project) { ProjectPresenter.new(source, current_user: user) }
+
+ before do
+ allow(view).to receive(:project_members_app_data_json).and_return({})
+ allow(view).to receive(:current_user).and_return(user)
+ assign(:project, project)
+ assign(:project_member, build(:project_member, project: source))
+ end
+
+ context 'when user can invite members for the project' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ context 'when modal is enabled' do
+ it 'renders as expected' do
+ render
+
+ expect(rendered).to have_content('Project members')
+ expect(rendered).to have_content('You can invite a new member')
+ expect(rendered).to have_selector('.js-import-a-project-modal')
+ expect(rendered).to have_selector('.js-invite-group-trigger')
+ expect(rendered).to have_selector('.js-invite-members-trigger')
+ expect(rendered).not_to have_content('Members can be added by project')
+ expect(response).to render_template(partial: 'projects/_invite_members_modal')
+ end
+
+ context 'when project is not allowed to share with group' do
+ before do
+ project.namespace.share_with_group_lock = true
+ end
+
+ it 'renders as expected' do
+ render
+
+ expect(rendered).not_to have_selector('.js-invite-group-trigger')
+ end
+ end
+ end
+
+ context 'when modal is not enabled' do
+ before do
+ stub_feature_flags(invite_members_group_modal: false)
+ end
+
+ it 'renders as expected' do
+ render
+
+ expect(rendered).to have_content('Project members')
+ expect(rendered).to have_content('You can invite a new member')
+ expect(rendered).not_to have_selector('.js-invite-group-trigger')
+ expect(rendered).not_to have_selector('.js-invite-members-trigger')
+ expect(rendered).not_to have_content('Members can be added by project')
+ expect(response).not_to render_template(partial: 'projects/_invite_members_modal')
+ expect(response).to render_template(partial: 'shared/members/_invite_member')
+ end
+
+ context 'when project can not be shared' do
+ before do
+ project.namespace.share_with_group_lock = true
+ end
+
+ it 'renders as expected' do
+ render
+
+ expect(rendered).to have_content('Project members')
+ expect(rendered).to have_content('You can invite a new member')
+ expect(response).not_to render_template(partial: 'projects/_invite_members_modal')
+ end
+ end
+ end
+ end
+
+ context 'when user can not invite members or group for the project' do
+ context 'when project can be shared' do
+ it 'renders as expected', :aggregate_failures do
+ render
+
+ expect(rendered).to have_content('Project members')
+ expect(rendered).not_to have_content('You can invite a new member')
+ expect(rendered).not_to have_selector('.js-import-a-project-modal')
+ expect(rendered).not_to have_selector('.js-invite-group-trigger')
+ expect(rendered).not_to have_selector('.js-invite-members-trigger')
+ expect(rendered).to have_content('Members can be added by project')
+ expect(response).not_to render_template(partial: 'projects/_invite_members_modal')
+ end
+ end
+ end
+end
diff --git a/spec/views/search/_results.html.haml_spec.rb b/spec/views/search/_results.html.haml_spec.rb
index ecfcf74edc1..dcf1f46b46c 100644
--- a/spec/views/search/_results.html.haml_spec.rb
+++ b/spec/views/search/_results.html.haml_spec.rb
@@ -74,7 +74,7 @@ RSpec.describe 'search/_results' do
it 'renders the click text event tracking attributes' do
render
- expect(rendered).to have_selector('[data-track-event=click_text]')
+ expect(rendered).to have_selector('[data-track-action=click_text]')
expect(rendered).to have_selector('[data-track-property=search_result]')
end
end
@@ -83,7 +83,7 @@ RSpec.describe 'search/_results' do
it 'does not render the click text event tracking attributes' do
render
- expect(rendered).not_to have_selector('[data-track-event=click_text]')
+ expect(rendered).not_to have_selector('[data-track-action=click_text]')
expect(rendered).not_to have_selector('[data-track-property=search_result]')
end
end
@@ -105,7 +105,7 @@ RSpec.describe 'search/_results' do
it 'renders the click text event tracking attributes' do
render
- expect(rendered).to have_selector('[data-track-event=click_text]')
+ expect(rendered).to have_selector('[data-track-action=click_text]')
expect(rendered).to have_selector('[data-track-property=search_result]')
end
end
@@ -114,7 +114,7 @@ RSpec.describe 'search/_results' do
it 'does not render the click text event tracking attributes' do
render
- expect(rendered).not_to have_selector('[data-track-event=click_text]')
+ expect(rendered).not_to have_selector('[data-track-action=click_text]')
expect(rendered).not_to have_selector('[data-track-property=search_result]')
end
end
diff --git a/spec/views/shared/access_tokens/_table.html.haml_spec.rb b/spec/views/shared/access_tokens/_table.html.haml_spec.rb
index 489675b5683..0a23768b4f1 100644
--- a/spec/views/shared/access_tokens/_table.html.haml_spec.rb
+++ b/spec/views/shared/access_tokens/_table.html.haml_spec.rb
@@ -19,7 +19,6 @@ RSpec.describe 'shared/access_tokens/_table.html.haml' do
allow(view).to receive(:personal_access_token_expiration_enforced?).and_return(token_expiry_enforced?)
allow(view).to receive(:show_profile_token_expiry_notification?).and_return(true)
- allow(view).to receive(:distance_of_time_in_words_to_now).and_return('4 days')
if project
project.add_maintainer(user)
@@ -140,7 +139,6 @@ RSpec.describe 'shared/access_tokens/_table.html.haml' do
# Expiry
expect(rendered).to have_content 'Expired', count: 2
- expect(rendered).to have_content 'In 4 days'
# Revoke buttons
expect(rendered).to have_link 'Revoke', href: 'path/', class: 'btn-danger-secondary', count: 1
diff --git a/spec/workers/authorized_project_update/user_refresh_from_replica_worker_spec.rb b/spec/workers/authorized_project_update/user_refresh_from_replica_worker_spec.rb
index 027ce3b7f89..0da58343773 100644
--- a/spec/workers/authorized_project_update/user_refresh_from_replica_worker_spec.rb
+++ b/spec/workers/authorized_project_update/user_refresh_from_replica_worker_spec.rb
@@ -51,20 +51,5 @@ RSpec.describe AuthorizedProjectUpdate::UserRefreshFromReplicaWorker do
execute_worker
end
end
-
- context 'when the feature flag `user_refresh_from_replica_worker_uses_replica_db` is disabled' do
- before do
- stub_feature_flags(user_refresh_from_replica_worker_uses_replica_db: false)
- end
-
- it 'calls Users::RefreshAuthorizedProjectsService' do
- source = 'AuthorizedProjectUpdate::UserRefreshFromReplicaWorker'
- expect_next_instance_of(Users::RefreshAuthorizedProjectsService, user, { source: source }) do |service|
- expect(service).to receive(:execute)
- end
-
- execute_worker
- end
- end
end
end
diff --git a/spec/workers/background_migration_worker_spec.rb b/spec/workers/background_migration_worker_spec.rb
index 4575c270042..7892eb89e80 100644
--- a/spec/workers/background_migration_worker_spec.rb
+++ b/spec/workers/background_migration_worker_spec.rb
@@ -14,7 +14,17 @@ RSpec.describe BackgroundMigrationWorker, :clean_gitlab_redis_shared_state do
describe '#perform' do
before do
allow(worker).to receive(:jid).and_return(1)
- expect(worker).to receive(:always_perform?).and_return(false)
+ allow(worker).to receive(:always_perform?).and_return(false)
+ end
+
+ it 'can run scheduled job and retried job concurrently' do
+ expect(Gitlab::BackgroundMigration)
+ .to receive(:perform)
+ .with('Foo', [10, 20])
+ .exactly(2).time
+
+ worker.perform('Foo', [10, 20])
+ worker.perform('Foo', [10, 20], described_class::MAX_LEASE_ATTEMPTS - 1)
end
context 'when lease can be obtained' do
@@ -39,7 +49,7 @@ RSpec.describe BackgroundMigrationWorker, :clean_gitlab_redis_shared_state do
before do
expect(Gitlab::BackgroundMigration).not_to receive(:perform)
- worker.lease_for('Foo').try_obtain
+ worker.lease_for('Foo', false).try_obtain
end
it 'reschedules the migration and decrements the lease_attempts' do
@@ -51,6 +61,10 @@ RSpec.describe BackgroundMigrationWorker, :clean_gitlab_redis_shared_state do
end
context 'when lease_attempts is 1' do
+ before do
+ worker.lease_for('Foo', true).try_obtain
+ end
+
it 'reschedules the migration and decrements the lease_attempts' do
expect(described_class)
.to receive(:perform_in)
@@ -61,6 +75,10 @@ RSpec.describe BackgroundMigrationWorker, :clean_gitlab_redis_shared_state do
end
context 'when lease_attempts is 0' do
+ before do
+ worker.lease_for('Foo', true).try_obtain
+ end
+
it 'gives up performing the migration' do
expect(described_class).not_to receive(:perform_in)
expect(Sidekiq.logger).to receive(:warn).with(
diff --git a/spec/workers/bulk_import_worker_spec.rb b/spec/workers/bulk_import_worker_spec.rb
index 205bf23f36d..b67c5c62f76 100644
--- a/spec/workers/bulk_import_worker_spec.rb
+++ b/spec/workers/bulk_import_worker_spec.rb
@@ -84,17 +84,20 @@ RSpec.describe BulkImportWorker do
expect { subject.perform(bulk_import.id) }
.to change(BulkImports::Tracker, :count)
- .by(BulkImports::Stage.pipelines.size * 2)
+ .by(BulkImports::Groups::Stage.pipelines.size * 2)
expect(entity_1.trackers).not_to be_empty
expect(entity_2.trackers).not_to be_empty
end
context 'when there are created entities to process' do
- it 'marks a batch of entities as started, enqueues EntityWorker, ExportRequestWorker and reenqueues' do
+ let_it_be(:bulk_import) { create(:bulk_import, :created) }
+
+ before do
stub_const("#{described_class}::DEFAULT_BATCH_SIZE", 1)
+ end
- bulk_import = create(:bulk_import, :created)
+ it 'marks a batch of entities as started, enqueues EntityWorker, ExportRequestWorker and reenqueues' do
create(:bulk_import_entity, :created, bulk_import: bulk_import)
create(:bulk_import_entity, :created, bulk_import: bulk_import)
@@ -106,6 +109,16 @@ RSpec.describe BulkImportWorker do
expect(bulk_import.entities.map(&:status_name)).to contain_exactly(:created, :started)
end
+
+ context 'when there are project entities to process' do
+ it 'does not enqueue ExportRequestWorker' do
+ create(:bulk_import_entity, :created, :project_entity, bulk_import: bulk_import)
+
+ expect(BulkImports::ExportRequestWorker).not_to receive(:perform_async)
+
+ subject.perform(bulk_import.id)
+ end
+ end
end
context 'when exception occurs' do
diff --git a/spec/workers/bulk_imports/pipeline_worker_spec.rb b/spec/workers/bulk_imports/pipeline_worker_spec.rb
index 972a4158194..56f28654ac5 100644
--- a/spec/workers/bulk_imports/pipeline_worker_spec.rb
+++ b/spec/workers/bulk_imports/pipeline_worker_spec.rb
@@ -21,6 +21,10 @@ RSpec.describe BulkImports::PipelineWorker do
before do
stub_const('FakePipeline', pipeline_class)
+
+ allow(BulkImports::Groups::Stage)
+ .to receive(:pipelines)
+ .and_return([[0, pipeline_class]])
end
it 'runs the given pipeline successfully' do
@@ -30,12 +34,6 @@ RSpec.describe BulkImports::PipelineWorker do
pipeline_name: 'FakePipeline'
)
- expect(BulkImports::Stage)
- .to receive(:pipeline_exists?)
- .with('FakePipeline')
- .twice
- .and_return(true)
-
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
expect(logger)
.to receive(:info)
@@ -110,7 +108,7 @@ RSpec.describe BulkImports::PipelineWorker do
expect(Gitlab::ErrorTracking)
.to receive(:track_exception)
.with(
- instance_of(NameError),
+ instance_of(BulkImports::Error),
entity_id: entity.id,
pipeline_name: pipeline_tracker.pipeline_name
)
@@ -157,10 +155,10 @@ RSpec.describe BulkImports::PipelineWorker do
before do
stub_const('NdjsonPipeline', ndjson_pipeline)
- allow(BulkImports::Stage)
- .to receive(:pipeline_exists?)
- .with('NdjsonPipeline')
- .and_return(true)
+
+ allow(BulkImports::Groups::Stage)
+ .to receive(:pipelines)
+ .and_return([[0, ndjson_pipeline]])
end
it 'runs the pipeline successfully' do
diff --git a/spec/workers/ci/external_pull_requests/create_pipeline_worker_spec.rb b/spec/workers/ci/external_pull_requests/create_pipeline_worker_spec.rb
new file mode 100644
index 00000000000..116a0e4d035
--- /dev/null
+++ b/spec/workers/ci/external_pull_requests/create_pipeline_worker_spec.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::ExternalPullRequests::CreatePipelineWorker do
+ let_it_be(:project) { create(:project, :auto_devops, :repository) }
+ let_it_be(:user) { project.owner }
+ let_it_be(:external_pull_request) do
+ branch = project.repository.branches.last
+ create(:external_pull_request, project: project, source_branch: branch.name, source_sha: branch.target)
+ end
+
+ let(:worker) { described_class.new }
+
+ describe '#perform' do
+ let(:project_id) { project.id }
+ let(:user_id) { user.id }
+ let(:external_pull_request_id) { external_pull_request.id }
+
+ subject(:perform) { worker.perform(project_id, user_id, external_pull_request_id) }
+
+ it 'creates the pipeline' do
+ pipeline = perform.payload
+
+ expect(pipeline).to be_valid
+ expect(pipeline).to be_persisted
+ expect(pipeline).to be_external_pull_request_event
+ expect(pipeline.project).to eq(project)
+ expect(pipeline.user).to eq(user)
+ expect(pipeline.external_pull_request).to eq(external_pull_request)
+ expect(pipeline.status).to eq('created')
+ expect(pipeline.ref).to eq(external_pull_request.source_branch)
+ expect(pipeline.sha).to eq(external_pull_request.source_sha)
+ expect(pipeline.source_sha).to eq(external_pull_request.source_sha)
+ expect(pipeline.target_sha).to eq(external_pull_request.target_sha)
+ end
+
+ shared_examples_for 'not calling service' do
+ it 'does not call the service' do
+ expect(Ci::CreatePipelineService).not_to receive(:new)
+ perform
+ end
+ end
+
+ context 'when the project not found' do
+ let(:project_id) { non_existing_record_id }
+
+ it_behaves_like 'not calling service'
+ end
+
+ context 'when the user not found' do
+ let(:user_id) { non_existing_record_id }
+
+ it_behaves_like 'not calling service'
+ end
+
+ context 'when the pull request not found' do
+ let(:external_pull_request_id) { non_existing_record_id }
+
+ it_behaves_like 'not calling service'
+ end
+
+ context 'when the pull request does not belong to the project' do
+ let(:external_pull_request_id) { create(:external_pull_request).id }
+
+ it_behaves_like 'not calling service'
+ end
+ end
+end
diff --git a/spec/workers/concerns/worker_attributes_spec.rb b/spec/workers/concerns/worker_attributes_spec.rb
index d4b17c65f46..ad9d5eeccbe 100644
--- a/spec/workers/concerns/worker_attributes_spec.rb
+++ b/spec/workers/concerns/worker_attributes_spec.rb
@@ -35,45 +35,17 @@ RSpec.describe WorkerAttributes do
end
end
- context 'when job is idempotent' do
- context 'when data_consistency is not :always' do
- it 'raise exception' do
- worker.idempotent!
-
- expect { worker.data_consistency(:sticky) }
- .to raise_error("Class can't be marked as idempotent if data_consistency is not set to :always")
- end
- end
-
- context 'when feature_flag is provided' do
- before do
- stub_feature_flags(test_feature_flag: false)
- skip_feature_flags_yaml_validation
- skip_default_enabled_yaml_check
- end
-
- it 'returns correct feature flag value' do
- worker.data_consistency(:sticky, feature_flag: :test_feature_flag)
-
- expect(worker.get_data_consistency_feature_flag_enabled?).not_to be_truthy
- end
+ context 'when feature_flag is provided' do
+ before do
+ stub_feature_flags(test_feature_flag: false)
+ skip_feature_flags_yaml_validation
+ skip_default_enabled_yaml_check
end
- end
- end
-
- describe '.idempotent!' do
- it 'sets `idempotent` attribute of the worker class to true' do
- worker.idempotent!
- expect(worker.send(:class_attributes)[:idempotent]).to eq(true)
- end
-
- context 'when data consistency is not :always' do
- it 'raise exception' do
- worker.data_consistency(:sticky)
+ it 'returns correct feature flag value' do
+ worker.data_consistency(:sticky, feature_flag: :test_feature_flag)
- expect { worker.idempotent! }
- .to raise_error("Class can't be marked as idempotent if data_consistency is not set to :always")
+ expect(worker.get_data_consistency_feature_flag_enabled?).not_to be_truthy
end
end
end
diff --git a/spec/workers/database/partition_management_worker_spec.rb b/spec/workers/database/partition_management_worker_spec.rb
index 01b7f209b2d..9ded36743a8 100644
--- a/spec/workers/database/partition_management_worker_spec.rb
+++ b/spec/workers/database/partition_management_worker_spec.rb
@@ -6,16 +6,14 @@ RSpec.describe Database::PartitionManagementWorker do
describe '#perform' do
subject { described_class.new.perform }
- let(:manager) { instance_double('PartitionManager', sync_partitions: nil) }
let(:monitoring) { instance_double('PartitionMonitoring', report_metrics: nil) }
before do
- allow(Gitlab::Database::Partitioning::PartitionManager).to receive(:new).and_return(manager)
allow(Gitlab::Database::Partitioning::PartitionMonitoring).to receive(:new).and_return(monitoring)
end
- it 'delegates to PartitionManager' do
- expect(manager).to receive(:sync_partitions)
+ it 'delegates to Partitioning' do
+ expect(Gitlab::Database::Partitioning).to receive(:sync_partitions)
subject
end
diff --git a/spec/workers/deployments/finished_worker_spec.rb b/spec/workers/deployments/finished_worker_spec.rb
deleted file mode 100644
index d0a26ae1547..00000000000
--- a/spec/workers/deployments/finished_worker_spec.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Deployments::FinishedWorker do
- let(:worker) { described_class.new }
-
- describe '#perform' do
- before do
- allow(ProjectServiceWorker).to receive(:perform_async)
- end
-
- it 'links merge requests to the deployment' do
- deployment = create(:deployment)
- service = instance_double(Deployments::LinkMergeRequestsService)
-
- expect(Deployments::LinkMergeRequestsService)
- .to receive(:new)
- .with(deployment)
- .and_return(service)
-
- expect(service).to receive(:execute)
-
- worker.perform(deployment.id)
- end
-
- it 'executes project services for deployment_hooks' do
- deployment = create(:deployment)
- project = deployment.project
- service = create(:service, type: 'SlackService', project: project, deployment_events: true, active: true)
-
- worker.perform(deployment.id)
-
- expect(ProjectServiceWorker).to have_received(:perform_async).with(service.id, an_instance_of(Hash))
- end
-
- it 'does not execute an inactive service' do
- deployment = create(:deployment)
- project = deployment.project
- create(:service, type: 'SlackService', project: project, deployment_events: true, active: false)
-
- worker.perform(deployment.id)
-
- expect(ProjectServiceWorker).not_to have_received(:perform_async)
- end
-
- it 'does nothing if a deployment with the given id does not exist' do
- worker.perform(0)
-
- expect(ProjectServiceWorker).not_to have_received(:perform_async)
- end
-
- it 'execute webhooks' do
- deployment = create(:deployment)
- project = deployment.project
- web_hook = create(:project_hook, deployment_events: true, project: project)
-
- expect_next_instance_of(WebHookService, web_hook, an_instance_of(Hash), "deployment_hooks") do |service|
- expect(service).to receive(:async_execute)
- end
-
- worker.perform(deployment.id)
- end
- end
-end
diff --git a/spec/workers/deployments/hooks_worker_spec.rb b/spec/workers/deployments/hooks_worker_spec.rb
index 5d8edf85dd9..b4a91cff2ac 100644
--- a/spec/workers/deployments/hooks_worker_spec.rb
+++ b/spec/workers/deployments/hooks_worker_spec.rb
@@ -52,7 +52,6 @@ RSpec.describe Deployments::HooksWorker do
it_behaves_like 'worker with data consistency',
described_class,
- feature_flag: :load_balancing_for_deployments_hooks_worker,
data_consistency: :delayed
end
end
diff --git a/spec/workers/deployments/success_worker_spec.rb b/spec/workers/deployments/success_worker_spec.rb
deleted file mode 100644
index d9996e66919..00000000000
--- a/spec/workers/deployments/success_worker_spec.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Deployments::SuccessWorker do
- subject { described_class.new.perform(deployment&.id) }
-
- context 'when successful deployment' do
- let(:deployment) { create(:deployment, :success) }
-
- it 'executes Deployments::UpdateEnvironmentService' do
- expect(Deployments::UpdateEnvironmentService)
- .to receive(:new).with(deployment).and_call_original
-
- subject
- end
- end
-
- context 'when canceled deployment' do
- let(:deployment) { create(:deployment, :canceled) }
-
- it 'does not execute Deployments::UpdateEnvironmentService' do
- expect(Deployments::UpdateEnvironmentService).not_to receive(:new)
-
- subject
- end
- end
-
- context 'when deploy record does not exist' do
- let(:deployment) { nil }
-
- it 'does not execute Deployments::UpdateEnvironmentService' do
- expect(Deployments::UpdateEnvironmentService).not_to receive(:new)
-
- subject
- end
- end
-end
diff --git a/spec/workers/environments/auto_stop_worker_spec.rb b/spec/workers/environments/auto_stop_worker_spec.rb
new file mode 100644
index 00000000000..1983cfa18ea
--- /dev/null
+++ b/spec/workers/environments/auto_stop_worker_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Environments::AutoStopWorker do
+ include CreateEnvironmentsHelpers
+
+ subject { worker.perform(environment_id) }
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
+ let_it_be(:reporter) { create(:user).tap { |u| project.add_reporter(u) } }
+
+ before_all do
+ project.repository.add_branch(developer, 'review/feature', 'master')
+ end
+
+ let!(:environment) { create_review_app(user, project, 'review/feature').environment }
+ let(:environment_id) { environment.id }
+ let(:worker) { described_class.new }
+ let(:user) { developer }
+
+ it 'stops the environment' do
+ expect { subject }
+ .to change { Environment.find_by_name('review/feature').state }
+ .from('available').to('stopped')
+ end
+
+ it 'executes the stop action' do
+ expect { subject }
+ .to change { Ci::Build.find_by_name('stop_review_app').status }
+ .from('manual').to('pending')
+ end
+
+ context 'when user does not have a permission to play the stop action' do
+ let(:user) { reporter }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Access::AccessDeniedError)
+ end
+ end
+
+ context 'when the environment has already been stopped' do
+ before do
+ environment.stop!
+ end
+
+ it 'does not execute the stop action' do
+ expect { subject }
+ .not_to change { Ci::Build.find_by_name('stop_review_app').status }
+ end
+ end
+
+ context 'when there are no deployments and associted stop actions' do
+ let!(:environment) { create(:environment) }
+
+ it 'stops the environment' do
+ subject
+
+ expect(environment.reload).to be_stopped
+ end
+ end
+
+ context 'when there are no corresponding environment record' do
+ let!(:environment) { double(:environment, id: non_existing_record_id) }
+
+ it 'ignores the invalid record' do
+ expect { subject }.not_to raise_error
+ end
+ end
+end
diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb
index ea1f0153f83..235a1f6e3dd 100644
--- a/spec/workers/every_sidekiq_worker_spec.rb
+++ b/spec/workers/every_sidekiq_worker_spec.rb
@@ -436,6 +436,7 @@ RSpec.describe 'Every Sidekiq worker' do
'TodosDestroyer::ConfidentialEpicWorker' => 3,
'TodosDestroyer::ConfidentialIssueWorker' => 3,
'TodosDestroyer::DestroyedIssuableWorker' => 3,
+ 'TodosDestroyer::DestroyedDesignsWorker' => 3,
'TodosDestroyer::EntityLeaveWorker' => 3,
'TodosDestroyer::GroupPrivateWorker' => 3,
'TodosDestroyer::PrivateFeaturesWorker' => 3,
@@ -452,6 +453,7 @@ RSpec.describe 'Every Sidekiq worker' do
'WaitForClusterCreationWorker' => 3,
'WebHookWorker' => 4,
'WebHooks::DestroyWorker' => 3,
+ 'WebHooks::LogExecutionWorker' => 3,
'Wikis::GitGarbageCollectWorker' => false,
'X509CertificateRevokeWorker' => 3
}
diff --git a/spec/workers/expire_job_cache_worker_spec.rb b/spec/workers/expire_job_cache_worker_spec.rb
index cbd9dd39336..6b14ccea105 100644
--- a/spec/workers/expire_job_cache_worker_spec.rb
+++ b/spec/workers/expire_job_cache_worker_spec.rb
@@ -13,44 +13,9 @@ RSpec.describe ExpireJobCacheWorker do
let(:job_args) { job.id }
- include_examples 'an idempotent worker' do
- it 'invalidates Etag caching for the job path' do
- job_path = "/#{project.full_path}/builds/#{job.id}.json"
-
- spy_store = Gitlab::EtagCaching::Store.new
-
- allow(Gitlab::EtagCaching::Store).to receive(:new) { spy_store }
-
- expect(spy_store).to receive(:touch)
- .exactly(worker_exec_times).times
- .with(job_path)
- .and_call_original
-
- expect(ExpirePipelineCacheWorker).to receive(:perform_async)
- .with(pipeline.id)
- .exactly(worker_exec_times).times
-
- subject
- end
- end
-
- it 'does not perform extra queries', :aggregate_failures do
- worker = described_class.new
- recorder = ActiveRecord::QueryRecorder.new { worker.perform(job.id) }
-
- occurences = recorder.data.values.flat_map {|v| v[:occurrences]}
- project_queries = occurences.select {|s| s.include?('FROM "projects"')}
- namespace_queries = occurences.select {|s| s.include?('FROM "namespaces"')}
- route_queries = occurences.select {|s| s.include?('FROM "routes"')}
-
- # This worker is run 1 million times an hour, so we need to save as much
- # queries as possible.
- expect(recorder.count).to be <= 1
-
- expect(project_queries.size).to eq(0)
- expect(namespace_queries.size).to eq(0)
- expect(route_queries.size).to eq(0)
- end
+ it_behaves_like 'worker with data consistency',
+ described_class,
+ data_consistency: :delayed
end
context 'when there is no job in the pipeline' do
diff --git a/spec/workers/expire_pipeline_cache_worker_spec.rb b/spec/workers/expire_pipeline_cache_worker_spec.rb
index 8c24aaa985b..f4c4df2e752 100644
--- a/spec/workers/expire_pipeline_cache_worker_spec.rb
+++ b/spec/workers/expire_pipeline_cache_worker_spec.rb
@@ -18,23 +18,6 @@ RSpec.describe ExpirePipelineCacheWorker do
subject.perform(pipeline.id)
end
- it 'does not perform extra queries', :aggregate_failures do
- recorder = ActiveRecord::QueryRecorder.new { subject.perform(pipeline.id) }
-
- project_queries = recorder.data.values.flat_map {|v| v[:occurrences]}.select {|s| s.include?('FROM "projects"')}
- namespace_queries = recorder.data.values.flat_map {|v| v[:occurrences]}.select {|s| s.include?('FROM "namespaces"')}
- route_queries = recorder.data.values.flat_map {|v| v[:occurrences]}.select {|s| s.include?('FROM "routes"')}
-
- # This worker is run 1 million times an hour, so we need to save as much
- # queries as possible.
- expect(recorder.count).to be <= 6
-
- # These arises from #update_etag_cache
- expect(project_queries.size).to eq(1)
- expect(namespace_queries.size).to eq(1)
- expect(route_queries.size).to eq(1)
- end
-
it "doesn't do anything if the pipeline not exist" do
expect_any_instance_of(Ci::ExpirePipelineCacheService).not_to receive(:execute)
expect_any_instance_of(Gitlab::EtagCaching::Store).not_to receive(:touch)
diff --git a/spec/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker_spec.rb
index f2a28ec40b8..c0dd4f488cc 100644
--- a/spec/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportIssuesAndDiffNotesWorker do
it 'imports the issues and diff notes' do
client = double(:client)
- described_class::IMPORTERS.each do |klass|
+ worker.importers(project).each do |klass|
importer = double(:importer)
waiter = Gitlab::JobWaiter.new(2, '123')
@@ -31,4 +31,45 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportIssuesAndDiffNotesWorker do
worker.import(client, project)
end
end
+
+ describe '#importers' do
+ context 'when project group is present' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:group) { create(:group, projects: [project]) }
+
+ context 'when feature flag github_importer_single_endpoint_notes_import is enabled' do
+ it 'includes single endpoint diff notes importer' do
+ project = create(:project)
+ group = create(:group, projects: [project])
+
+ stub_feature_flags(github_importer_single_endpoint_notes_import: group)
+
+ expect(worker.importers(project)).to contain_exactly(
+ Gitlab::GithubImport::Importer::IssuesImporter,
+ Gitlab::GithubImport::Importer::SingleEndpointDiffNotesImporter
+ )
+ end
+ end
+
+ context 'when feature flag github_importer_single_endpoint_notes_import is disabled' do
+ it 'includes default diff notes importer' do
+ stub_feature_flags(github_importer_single_endpoint_notes_import: false)
+
+ expect(worker.importers(project)).to contain_exactly(
+ Gitlab::GithubImport::Importer::IssuesImporter,
+ Gitlab::GithubImport::Importer::DiffNotesImporter
+ )
+ end
+ end
+ end
+
+ context 'when project group is missing' do
+ it 'includes default diff notes importer' do
+ expect(worker.importers(project)).to contain_exactly(
+ Gitlab::GithubImport::Importer::IssuesImporter,
+ Gitlab::GithubImport::Importer::DiffNotesImporter
+ )
+ end
+ end
+ end
end
diff --git a/spec/workers/gitlab/github_import/stage/import_notes_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_notes_worker_spec.rb
index 73b19239f4a..f9f21e4dfa2 100644
--- a/spec/workers/gitlab/github_import/stage/import_notes_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/stage/import_notes_worker_spec.rb
@@ -8,18 +8,21 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportNotesWorker do
describe '#import' do
it 'imports all the notes' do
- importer = double(:importer)
client = double(:client)
- waiter = Gitlab::JobWaiter.new(2, '123')
- expect(Gitlab::GithubImport::Importer::NotesImporter)
- .to receive(:new)
- .with(project, client)
- .and_return(importer)
+ worker.importers(project).each do |klass|
+ importer = double(:importer)
+ waiter = Gitlab::JobWaiter.new(2, '123')
- expect(importer)
- .to receive(:execute)
- .and_return(waiter)
+ expect(klass)
+ .to receive(:new)
+ .with(project, client)
+ .and_return(importer)
+
+ expect(importer)
+ .to receive(:execute)
+ .and_return(waiter)
+ end
expect(Gitlab::GithubImport::AdvanceStageWorker)
.to receive(:perform_async)
@@ -28,4 +31,43 @@ RSpec.describe Gitlab::GithubImport::Stage::ImportNotesWorker do
worker.import(client, project)
end
end
+
+ describe '#importers' do
+ context 'when project group is present' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:group) { create(:group, projects: [project]) }
+
+ context 'when feature flag github_importer_single_endpoint_notes_import is enabled' do
+ it 'includes single endpoint mr and issue notes importers' do
+ project = create(:project)
+ group = create(:group, projects: [project])
+
+ stub_feature_flags(github_importer_single_endpoint_notes_import: group)
+
+ expect(worker.importers(project)).to contain_exactly(
+ Gitlab::GithubImport::Importer::SingleEndpointMergeRequestNotesImporter,
+ Gitlab::GithubImport::Importer::SingleEndpointIssueNotesImporter
+ )
+ end
+ end
+
+ context 'when feature flag github_importer_single_endpoint_notes_import is disabled' do
+ it 'includes default notes importer' do
+ stub_feature_flags(github_importer_single_endpoint_notes_import: false)
+
+ expect(worker.importers(project)).to contain_exactly(
+ Gitlab::GithubImport::Importer::NotesImporter
+ )
+ end
+ end
+ end
+
+ context 'when project group is missing' do
+ it 'includes default diff notes importer' do
+ expect(worker.importers(project)).to contain_exactly(
+ Gitlab::GithubImport::Importer::NotesImporter
+ )
+ end
+ end
+ end
end
diff --git a/spec/workers/issue_rebalancing_worker_spec.rb b/spec/workers/issue_rebalancing_worker_spec.rb
index b6e9429d78e..cba42a1577e 100644
--- a/spec/workers/issue_rebalancing_worker_spec.rb
+++ b/spec/workers/issue_rebalancing_worker_spec.rb
@@ -8,41 +8,29 @@ RSpec.describe IssueRebalancingWorker do
let_it_be(:project) { create(:project, group: group) }
let_it_be(:issue) { create(:issue, project: project) }
- context 'when block_issue_repositioning is enabled' do
- before do
- stub_feature_flags(block_issue_repositioning: group)
- end
-
- it 'does not run an instance of IssueRebalancingService' do
- expect(IssueRebalancingService).not_to receive(:new)
-
- described_class.new.perform(nil, issue.project_id)
- end
- end
-
shared_examples 'running the worker' do
- it 'runs an instance of IssueRebalancingService' do
+ it 'runs an instance of Issues::RelativePositionRebalancingService' do
service = double(execute: nil)
service_param = arguments.second.present? ? kind_of(Project.id_in([project]).class) : kind_of(group&.all_projects.class)
- expect(IssueRebalancingService).to receive(:new).with(service_param).and_return(service)
+ expect(Issues::RelativePositionRebalancingService).to receive(:new).with(service_param).and_return(service)
described_class.new.perform(*arguments)
end
- it 'anticipates there being too many issues' do
+ it 'anticipates there being too many concurent rebalances' do
service = double
service_param = arguments.second.present? ? kind_of(Project.id_in([project]).class) : kind_of(group&.all_projects.class)
- allow(service).to receive(:execute).and_raise(IssueRebalancingService::TooManyIssues)
- expect(IssueRebalancingService).to receive(:new).with(service_param).and_return(service)
- expect(Gitlab::ErrorTracking).to receive(:log_exception).with(IssueRebalancingService::TooManyIssues, include(project_id: arguments.second, root_namespace_id: arguments.third))
+ allow(service).to receive(:execute).and_raise(Issues::RelativePositionRebalancingService::TooManyConcurrentRebalances)
+ expect(Issues::RelativePositionRebalancingService).to receive(:new).with(service_param).and_return(service)
+ expect(Gitlab::ErrorTracking).to receive(:log_exception).with(Issues::RelativePositionRebalancingService::TooManyConcurrentRebalances, include(project_id: arguments.second, root_namespace_id: arguments.third))
described_class.new.perform(*arguments)
end
it 'takes no action if the value is nil' do
- expect(IssueRebalancingService).not_to receive(:new)
+ expect(Issues::RelativePositionRebalancingService).not_to receive(:new)
expect(Gitlab::ErrorTracking).not_to receive(:log_exception)
described_class.new.perform # all arguments are nil
@@ -52,7 +40,7 @@ RSpec.describe IssueRebalancingWorker do
shared_examples 'safely handles non-existent ids' do
it 'anticipates the inability to find the issue' do
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(ArgumentError, include(project_id: arguments.second, root_namespace_id: arguments.third))
- expect(IssueRebalancingService).not_to receive(:new)
+ expect(Issues::RelativePositionRebalancingService).not_to receive(:new)
described_class.new.perform(*arguments)
end
diff --git a/spec/workers/namespaceless_project_destroy_worker_spec.rb b/spec/workers/namespaceless_project_destroy_worker_spec.rb
index cd66af82364..93e8415f3bb 100644
--- a/spec/workers/namespaceless_project_destroy_worker_spec.rb
+++ b/spec/workers/namespaceless_project_destroy_worker_spec.rb
@@ -48,12 +48,6 @@ RSpec.describe NamespacelessProjectDestroyWorker do
subject.perform(project.id)
end
-
- it 'does not do anything in Project#legacy_remove_pages method' do
- expect(Gitlab::PagesTransfer).not_to receive(:new)
-
- subject.perform(project.id)
- end
end
context 'project forked from another' do
diff --git a/spec/workers/packages/helm/extraction_worker_spec.rb b/spec/workers/packages/helm/extraction_worker_spec.rb
index 258413a3410..daebbda3077 100644
--- a/spec/workers/packages/helm/extraction_worker_spec.rb
+++ b/spec/workers/packages/helm/extraction_worker_spec.rb
@@ -23,10 +23,10 @@ RSpec.describe Packages::Helm::ExtractionWorker, type: :worker do
subject { described_class.new.perform(channel, package_file_id) }
- shared_examples 'handling error' do
+ shared_examples 'handling error' do |error_class = Packages::Helm::ExtractFileMetadataService::ExtractionError|
it 'mark the package as errored', :aggregate_failures do
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(
- instance_of(Packages::Helm::ExtractFileMetadataService::ExtractionError),
+ instance_of(error_class),
project_id: package_file.package.project_id
)
expect { subject }
@@ -88,5 +88,15 @@ RSpec.describe Packages::Helm::ExtractionWorker, type: :worker do
it_behaves_like 'handling error'
end
+
+ context 'with an invalid Chart.yaml' do
+ before do
+ expect_next_instance_of(Gem::Package::TarReader::Entry) do |entry|
+ expect(entry).to receive(:read).and_return('{}')
+ end
+ end
+
+ it_behaves_like 'handling error', ActiveRecord::RecordInvalid
+ end
end
end
diff --git a/spec/workers/pages_remove_worker_spec.rb b/spec/workers/pages_remove_worker_spec.rb
index 864aa763fa9..9d49088b371 100644
--- a/spec/workers/pages_remove_worker_spec.rb
+++ b/spec/workers/pages_remove_worker_spec.rb
@@ -3,23 +3,9 @@
require 'spec_helper'
RSpec.describe PagesRemoveWorker do
- let(:project) { create(:project, path: "my.project")}
- let!(:domain) { create(:pages_domain, project: project) }
-
- subject { described_class.new.perform(project.id) }
-
- before do
- project.mark_pages_as_deployed
- end
-
- it 'deletes published pages' do
- expect(project.pages_deployed?).to be(true)
-
- expect_any_instance_of(Gitlab::PagesTransfer).to receive(:rename_project).and_return true
- expect(PagesWorker).to receive(:perform_in).with(5.minutes, :remove, project.namespace.full_path, anything)
-
- subject
-
- expect(project.reload.pages_deployed?).to be(false)
+ it 'does not raise error' do
+ expect do
+ described_class.new.perform(create(:project).id)
+ end.not_to raise_error
end
end
diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb
index c111c3164eb..ddd295215a1 100644
--- a/spec/workers/post_receive_spec.rb
+++ b/spec/workers/post_receive_spec.rb
@@ -22,6 +22,8 @@ RSpec.describe PostReceive do
create(:project, :repository, auto_cancel_pending_pipelines: 'disabled')
end
+ let(:job_args) { [gl_repository, key_id, base64_changes] }
+
def perform(changes: base64_changes)
described_class.new.perform(gl_repository, key_id, changes)
end
@@ -282,6 +284,8 @@ RSpec.describe PostReceive do
end
end
end
+
+ it_behaves_like 'an idempotent worker'
end
describe '#process_wiki_changes' do
@@ -352,6 +356,8 @@ RSpec.describe PostReceive do
perform
end
end
+
+ it_behaves_like 'an idempotent worker'
end
context 'webhook' do
@@ -458,6 +464,8 @@ RSpec.describe PostReceive do
end
end
end
+
+ it_behaves_like 'an idempotent worker'
end
context 'with PersonalSnippet' do
@@ -484,5 +492,7 @@ RSpec.describe PostReceive do
described_class.new.perform(gl_repository, key_id, base64_changes)
end
+
+ it_behaves_like 'an idempotent worker'
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 53f8d1bf5ba..393745958be 100644
--- a/spec/workers/purge_dependency_proxy_cache_worker_spec.rb
+++ b/spec/workers/purge_dependency_proxy_cache_worker_spec.rb
@@ -11,14 +11,9 @@ RSpec.describe PurgeDependencyProxyCacheWorker do
subject { described_class.new.perform(user.id, group_id) }
- before do
- stub_config(dependency_proxy: { enabled: true })
- group.create_dependency_proxy_setting!(enabled: true)
- end
-
describe '#perform' do
- shared_examples 'returns nil' do
- it 'returns nil', :aggregate_failures do
+ shared_examples 'not removing blobs and manifests' do
+ it 'does not remove blobs and manifests', :aggregate_failures do
expect { subject }.not_to change { group.dependency_proxy_blobs.size }
expect { subject }.not_to change { group.dependency_proxy_manifests.size }
expect(subject).to be_nil
@@ -43,26 +38,26 @@ RSpec.describe PurgeDependencyProxyCacheWorker do
end
context 'when admin mode is disabled' do
- it_behaves_like 'returns nil'
+ it_behaves_like 'not removing blobs and manifests'
end
end
context 'a non-admin user' do
let(:user) { create(:user) }
- it_behaves_like 'returns nil'
+ it_behaves_like 'not removing blobs and manifests'
end
context 'an invalid user id' do
let(:user) { double('User', id: 99999 ) }
- it_behaves_like 'returns nil'
+ it_behaves_like 'not removing blobs and manifests'
end
context 'an invalid group' do
let(:group_id) { 99999 }
- it_behaves_like 'returns nil'
+ it_behaves_like 'not removing blobs and manifests'
end
end
end
diff --git a/spec/workers/stuck_ci_jobs_worker_spec.rb b/spec/workers/stuck_ci_jobs_worker_spec.rb
index 84b2d87494e..e0a5d3c6c1c 100644
--- a/spec/workers/stuck_ci_jobs_worker_spec.rb
+++ b/spec/workers/stuck_ci_jobs_worker_spec.rb
@@ -5,311 +5,50 @@ require 'spec_helper'
RSpec.describe StuckCiJobsWorker do
include ExclusiveLeaseHelpers
- let!(:runner) { create :ci_runner }
- let!(:job) { create :ci_build, runner: runner }
- let(:worker_lease_key) { StuckCiJobsWorker::EXCLUSIVE_LEASE_KEY }
+ let(:worker_lease_key) { StuckCiJobsWorker::EXCLUSIVE_LEASE_KEY }
let(:worker_lease_uuid) { SecureRandom.uuid }
- let(:created_at) { }
- let(:updated_at) { }
+ let(:worker2) { described_class.new }
subject(:worker) { described_class.new }
before do
stub_exclusive_lease(worker_lease_key, worker_lease_uuid)
- job_attributes = { status: status }
- job_attributes[:created_at] = created_at if created_at
- job_attributes[:updated_at] = updated_at if updated_at
- job.update!(job_attributes)
end
- shared_examples 'job is dropped' do
- it "changes status" do
- worker.perform
- job.reload
-
- expect(job).to be_failed
- expect(job).to be_stuck_or_timeout_failure
- end
-
- context 'when job have data integrity problem' do
- it "does drop the job and logs the reason" do
- job.update_columns(yaml_variables: '[{"key" => "value"}]')
-
- expect(Gitlab::ErrorTracking).to receive(:track_exception)
- .with(anything, a_hash_including(build_id: job.id))
- .once
- .and_call_original
-
- worker.perform
- job.reload
-
- expect(job).to be_failed
- expect(job).to be_data_integrity_failure
+ describe '#perform' do
+ it 'executes an instance of Ci::StuckBuildsDropService' do
+ expect_next_instance_of(Ci::StuckBuilds::DropService) do |service|
+ expect(service).to receive(:execute).exactly(:once)
end
- end
- end
- shared_examples 'job is unchanged' do
- before do
worker.perform
- job.reload
end
- it "doesn't change status" do
- expect(job.status).to eq(status)
- end
- end
-
- context 'when job is pending' do
- let(:status) { 'pending' }
-
- context 'when job is not stuck' do
- before do
- allow_any_instance_of(Ci::Build).to receive(:stuck?).and_return(false)
- end
-
- context 'when job was updated_at more than 1 day ago' do
- let(:updated_at) { 1.5.days.ago }
-
- context 'when created_at is the same as updated_at' do
- let(:created_at) { 1.5.days.ago }
-
- it_behaves_like 'job is dropped'
- end
-
- context 'when created_at is before updated_at' do
- let(:created_at) { 3.days.ago }
-
- it_behaves_like 'job is dropped'
- end
-
- context 'when created_at is outside lookback window' do
- let(:created_at) { described_class::BUILD_LOOKBACK - 1.day }
-
- it_behaves_like 'job is unchanged'
- end
- end
-
- context 'when job was updated less than 1 day ago' do
- let(:updated_at) { 6.hours.ago }
-
- context 'when created_at is the same as updated_at' do
- let(:created_at) { 1.5.days.ago }
-
- it_behaves_like 'job is unchanged'
- end
-
- context 'when created_at is before updated_at' do
- let(:created_at) { 3.days.ago }
-
- it_behaves_like 'job is unchanged'
- end
-
- context 'when created_at is outside lookback window' do
- let(:created_at) { described_class::BUILD_LOOKBACK - 1.day }
-
- it_behaves_like 'job is unchanged'
- end
- end
-
- context 'when job was updated more than 1 hour ago' do
- let(:updated_at) { 2.hours.ago }
-
- context 'when created_at is the same as updated_at' do
- let(:created_at) { 2.hours.ago }
-
- it_behaves_like 'job is unchanged'
- end
-
- context 'when created_at is before updated_at' do
- let(:created_at) { 3.days.ago }
-
- it_behaves_like 'job is unchanged'
- end
-
- context 'when created_at is outside lookback window' do
- let(:created_at) { described_class::BUILD_LOOKBACK - 1.day }
-
- it_behaves_like 'job is unchanged'
- end
- end
- end
-
- context 'when job is stuck' do
- before do
- allow_any_instance_of(Ci::Build).to receive(:stuck?).and_return(true)
- end
-
- context 'when job was updated_at more than 1 hour ago' do
- let(:updated_at) { 1.5.hours.ago }
-
- context 'when created_at is the same as updated_at' do
- let(:created_at) { 1.5.hours.ago }
-
- it_behaves_like 'job is dropped'
- end
-
- context 'when created_at is before updated_at' do
- let(:created_at) { 3.days.ago }
-
- it_behaves_like 'job is dropped'
- end
-
- context 'when created_at is outside lookback window' do
- let(:created_at) { described_class::BUILD_LOOKBACK - 1.day }
-
- it_behaves_like 'job is unchanged'
- end
- end
-
- context 'when job was updated in less than 1 hour ago' do
- let(:updated_at) { 30.minutes.ago }
-
- context 'when created_at is the same as updated_at' do
- let(:created_at) { 30.minutes.ago }
-
- it_behaves_like 'job is unchanged'
- end
-
- context 'when created_at is before updated_at' do
- let(:created_at) { 2.days.ago }
-
- it_behaves_like 'job is unchanged'
- end
+ context 'with an exclusive lease' do
+ it 'does not execute concurrently' do
+ expect(worker).to receive(:remove_lease).exactly(:once)
+ expect(worker2).not_to receive(:remove_lease)
- context 'when created_at is outside lookback window' do
- let(:created_at) { described_class::BUILD_LOOKBACK - 1.day }
-
- it_behaves_like 'job is unchanged'
- end
- end
- end
- end
-
- context 'when job is running' do
- let(:status) { 'running' }
-
- context 'when job was updated_at more than an hour ago' do
- let(:updated_at) { 2.hours.ago }
-
- it_behaves_like 'job is dropped'
- end
-
- context 'when job was updated in less than 1 hour ago' do
- let(:updated_at) { 30.minutes.ago }
-
- it_behaves_like 'job is unchanged'
- end
- end
-
- %w(success skipped failed canceled).each do |status|
- context "when job is #{status}" do
- let(:status) { status }
- let(:updated_at) { 2.days.ago }
-
- context 'when created_at is the same as updated_at' do
- let(:created_at) { 2.days.ago }
-
- it_behaves_like 'job is unchanged'
- end
-
- context 'when created_at is before updated_at' do
- let(:created_at) { 3.days.ago }
-
- it_behaves_like 'job is unchanged'
- end
+ worker.perform
- context 'when created_at is outside lookback window' do
- let(:created_at) { described_class::BUILD_LOOKBACK - 1.day }
+ stub_exclusive_lease_taken(worker_lease_key)
- it_behaves_like 'job is unchanged'
+ worker2.perform
end
- end
- end
-
- context 'for deleted project' do
- let(:status) { 'running' }
- let(:updated_at) { 2.days.ago }
-
- before do
- job.project.update!(pending_delete: true)
- end
-
- it 'does drop job' do
- expect_any_instance_of(Ci::Build).to receive(:drop).and_call_original
- worker.perform
- end
- end
-
- describe 'drop stale scheduled builds' do
- let(:status) { 'scheduled' }
- let(:updated_at) { }
-
- context 'when scheduled at 2 hours ago but it is not executed yet' do
- let!(:job) { create(:ci_build, :scheduled, scheduled_at: 2.hours.ago) }
- it 'drops the stale scheduled build' do
- expect(Ci::Build.scheduled.count).to eq(1)
- expect(job).to be_scheduled
+ it 'can execute in sequence' do
+ expect(worker).to receive(:remove_lease).at_least(:once)
+ expect(worker2).to receive(:remove_lease).at_least(:once)
worker.perform
- job.reload
-
- expect(Ci::Build.scheduled.count).to eq(0)
- expect(job).to be_failed
- expect(job).to be_stale_schedule
+ worker2.perform
end
- end
-
- context 'when scheduled at 30 minutes ago but it is not executed yet' do
- let!(:job) { create(:ci_build, :scheduled, scheduled_at: 30.minutes.ago) }
- it 'does not drop the stale scheduled build yet' do
- expect(Ci::Build.scheduled.count).to eq(1)
- expect(job).to be_scheduled
+ it 'cancels exclusive leases after worker perform' do
+ expect_to_cancel_exclusive_lease(worker_lease_key, worker_lease_uuid)
worker.perform
-
- expect(Ci::Build.scheduled.count).to eq(1)
- expect(job).to be_scheduled
- end
- end
-
- context 'when there are no stale scheduled builds' do
- it 'does not drop the stale scheduled build yet' do
- expect { worker.perform }.not_to raise_error
end
end
end
-
- describe 'exclusive lease' do
- let(:status) { 'running' }
- let(:updated_at) { 2.days.ago }
- let(:worker2) { described_class.new }
-
- it 'is guard by exclusive lease when executed concurrently' do
- expect(worker).to receive(:drop).at_least(:once).and_call_original
- expect(worker2).not_to receive(:drop)
-
- worker.perform
-
- stub_exclusive_lease_taken(worker_lease_key)
-
- worker2.perform
- end
-
- it 'can be executed in sequence' do
- expect(worker).to receive(:drop).at_least(:once).and_call_original
- expect(worker2).to receive(:drop).at_least(:once).and_call_original
-
- worker.perform
- worker2.perform
- end
-
- it 'cancels exclusive leases after worker perform' do
- expect_to_cancel_exclusive_lease(worker_lease_key, worker_lease_uuid)
-
- worker.perform
- end
- end
end
diff --git a/spec/workers/todos_destroyer/destroyed_designs_worker_spec.rb b/spec/workers/todos_destroyer/destroyed_designs_worker_spec.rb
new file mode 100644
index 00000000000..113faeb0d2f
--- /dev/null
+++ b/spec/workers/todos_destroyer/destroyed_designs_worker_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe TodosDestroyer::DestroyedDesignsWorker do
+ let(:service) { double }
+
+ it 'calls the Todos::Destroy::DesignService with design_ids parameter' do
+ expect(::Todos::Destroy::DesignService).to receive(:new).with([1, 5]).and_return(service)
+ expect(service).to receive(:execute)
+
+ described_class.new.perform([1, 5])
+ end
+end